exists($path)) { $data['original_markdown'] = TextEncoding::toUtf8(Storage::disk('local')->get($path)); } } // 文件名默认取上传文件名(优先原始文件名,其次取存储路径 basename) if (empty($data['file_name']) && !empty($data['markdown_file'])) { $storedNames = $data['uploaded_file_names'] ?? null; if (is_array($storedNames) && !empty($storedNames)) { $data['file_name'] = (string) array_values($storedNames)[0]; } else { $path = is_array($data['markdown_file']) ? ($data['markdown_file'][0] ?? '') : (string) $data['markdown_file']; $data['file_name'] = $path !== '' ? basename($path) : null; } } // 文件名作为来源名称 if (!empty($data['file_name'])) { $data['source_name'] = $data['file_name']; $data['source_type'] = 'other'; } unset($data['markdown_file']); unset($data['uploaded_file_names']); return $data; } /** * 允许创建新的 Markdown 导入记录 */ public static function canCreate(): bool { return true; } public static function form(Schema $schema): Schema { return $schema ->schema([ Section::make('上传与来源信息') ->schema([ \Filament\Forms\Components\TextInput::make('file_name') ->label('文件名(来源名称)') ->required(fn (Get $get): bool => empty($get('markdown_file'))) ->maxLength(255), FileUpload::make('markdown_file') ->label('Markdown 文件(可选)') ->disk('local') ->directory('imports/markdown') ->helperText('仅支持 .md / .markdown / .txt;上传后会自动读取内容并填充编辑器') ->maxSize(10 * 1024) ->storeFileNamesIn('uploaded_file_names') ->dehydrated(true) ->preserveFilenames() ->rules([new MarkdownFileExtension()]) ->afterStateUpdated(function ($state, Set $set, Get $get): void { // 在提交表单前,FileUpload 的 state 可能还是 TemporaryUploadedFile(尚未保存到 disk) $first = is_array($state) ? ($state[0] ?? null) : $state; if ($first instanceof TemporaryUploadedFile) { $set('original_markdown', TextEncoding::toUtf8((string) @file_get_contents($first->getRealPath()))); if (empty($get('file_name'))) { $set('file_name', $first->getClientOriginalName()); } return; } $paths = is_array($state) ? $state : (empty($state) ? [] : [$state]); $path = (string) ($paths[0] ?? ''); if ($path === '') { return; } // 已保存到 disk 后:读取文件内容填充编辑器 if (Storage::disk('local')->exists($path)) { $set('original_markdown', TextEncoding::toUtf8(Storage::disk('local')->get($path))); } // 上传后的真实文件名:BaseFileUpload 会在保存时 storeFileName($storedFile, originalName) $storedNames = $get('uploaded_file_names'); if (is_string($storedNames) && $storedNames !== '') { $set('file_name', $storedNames); } elseif (empty($get('file_name'))) { $set('file_name', basename($path)); } }), Hidden::make('uploaded_file_names') ->dehydrated(true), ]) ->columns(2), Section::make('解析规则(可选)') ->schema([ Select::make('parse_mode') ->label('解析模式') ->options([ 'strict' => '严格模式', 'relaxed' => '宽松模式', ]) ->default('strict') ->dehydrated(false), TextInput::make('split_marker') ->label('分题符号') ->placeholder('如:---') ->dehydrated(false), TextInput::make('type_marker') ->label('题型标记') ->placeholder('如:#选择题') ->dehydrated(false), Toggle::make('auto_detect_images') ->label('自动识别图片') ->default(true) ->dehydrated(false), ]) ->columns(2) ->collapsed(), Section::make('Markdown 内容') ->schema([ MarkdownEditor::make('original_markdown') ->label('Markdown 内容(编辑器)') ->required(fn (Get $get): bool => empty($get('markdown_file'))) ->columnSpanFull() // 固定编辑器高度,避免内容过长把页面撑开 ->minHeight('45vh') ->maxHeight('45vh') ->toolbarButtons([ 'bold', 'italic', 'strike', 'blockquote', 'bulletList', 'orderedList', 'link', 'codeBlock', 'table', 'undo', 'redo', ]), ]), ]); } public static function table(Table $table): Table { return $table ->columns([ TextColumn::make('file_name') ->label('文件名') ->searchable() ->sortable(), TextColumn::make('remote_url') ->label('源文件') ->getStateUsing(fn (?Model $record) => $record?->remote_url ? '查看' : '—') ->icon('heroicon-o-document-arrow-down') ->color('primary') ->url(fn (?Model $record) => $record?->remote_url) ->openUrlInNewTab() ->toggleable(), TextColumn::make('filename_parse_status') ->label('命名解析') ->badge() ->getStateUsing(function (?Model $record): string { if (!$record) { return '未知'; } $parsed = $record->parseFilename(); return empty($parsed) ? '不规范' : '正常'; }) ->color(function (?Model $record): string { if (!$record) { return 'gray'; } return empty($record->parseFilename()) ? 'warning' : 'success'; }) ->tooltip(function (?Model $record): ?string { if (!$record) { return null; } $parsed = $record->parseFilename(); if (empty($parsed)) { return '系列_年级_学期_学科_名称'; } return sprintf( '系列:%s 年级:%s 学期:%s 学科:%s 名称:%s', $parsed['series'] ?? '-', $parsed['grade'] ?? '-', $parsed['term'] ?? '-', $parsed['subject'] ?? '-', $parsed['name'] ?? '-' ); }) ->url(fn (?Model $record): ?string => $record ? route('filament.admin.pages.markdown-import-workbench', [ 'import_id' => $record->id, ]) : null) ->openUrlInNewTab(), TextColumn::make('source_name') ->label('来源') ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('status') ->label('状态') ->badge() ->color(fn (string $state): string => match ($state) { 'pending' => 'gray', 'processing' => 'warning', 'parsed' => 'info', 'reviewed' => 'primary', 'completed' => 'success', 'failed' => 'danger', default => 'gray', }) ->getStateUsing(function (?Model $record): string { if (!$record) { return '—'; } return match ($record->status) { 'pending' => '待处理', 'processing' => $record->progress_label ?: '处理中', 'parsed' => '已解析(待校对)', 'reviewed' => '已校对(待入库)', 'completed' => '已完成(已入库)', 'failed' => '失败' . ($record->progress_message ? "({$record->progress_message})" : ''), default => (string) $record->status, }; }), TextColumn::make('progress_message') ->label('当前步骤') ->getStateUsing(fn (?Model $record) => $record?->progress_message ?: '—') ->wrap() ->limit(60), TextColumn::make('progress_label') ->label('进度') ->getStateUsing(fn (?Model $record) => $record?->progress_label ?: '—') ->color('gray') ->toggleable(), ProgressColumn::make('progress_percent') ->label('进度条') ->getStateUsing(function (?Model $record): ?int { if (!$record) { return null; } return $record->progress_percent; }) ->visible(fn (?Model $record): bool => $record?->status === 'processing') ->color(fn (?int $progress): string => match (true) { $progress === null => 'gray', $progress < 30 => 'danger', $progress < 70 => 'warning', default => 'success', }) ->formatStateUsing(fn (?int $state): string => $state !== null ? "{$state}%" : '—'), TextColumn::make('parsed_count') ->label('候选题数') ->getStateUsing(fn (?Model $record) => $record?->parsed_count ?? 0) ->sortable(), TextColumn::make('accepted_count') ->label('已接受') ->getStateUsing(fn (?Model $record) => $record?->accepted_count ?? 0) ->sortable(), TextColumn::make('created_at') ->label('导入时间') ->dateTime() ->sortable(), TextColumn::make('processing_started_at') ->label('开始') ->dateTime('m-d H:i') ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('processing_finished_at') ->label('结束') ->dateTime('m-d H:i') ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('error_message') ->label('错误') ->visible(fn (?Model $record): bool => $record?->status === 'failed') ->wrap() ->limit(80), ]) ->filters([ Tables\Filters\SelectFilter::make('status') ->label('状态') ->options([ 'pending' => '待处理', 'processing' => '处理中', 'parsed' => '已解析', 'reviewed' => '已校对', 'completed' => '已完成', 'failed' => '处理失败', ]), Tables\Filters\SelectFilter::make('source_type') ->label('来源类型') ->options([ 'textbook' => '教材', 'exam' => '考试', 'other' => '其他', ]), Tables\Filters\SelectFilter::make('filename_parse') ->label('命名规范') ->options([ 'valid' => '正常', 'invalid' => '不规范', ]) ->query(function (Builder $query, array $data) { $value = $data['value'] ?? null; $driver = DB::getDriverName(); $regex = '^.+_[0-9]+_[0-2]_.+_.+$'; if ($value === 'valid') { if ($driver === 'mysql') { $query->whereRaw('file_name REGEXP ?', [$regex]); } else { $query->where('file_name', 'like', '%_%_%_%_%'); } } if ($value === 'invalid') { if ($driver === 'mysql') { $query->where(function ($q) use ($regex) { $q->whereNull('file_name')->orWhereRaw('file_name NOT REGEXP ?', [$regex]); }); } else { $query->where(function ($q) { $q->whereNull('file_name')->orWhere('file_name', 'not like', '%_%_%_%_%'); }); } } }), ], layout: FiltersLayout::AboveContentCollapsible) ->actions([ EditAction::make() ->label('编辑'), Action::make('workbench') ->label('导入工作台') ->icon('heroicon-o-rectangle-stack') ->color('primary') ->visible(fn (?Model $record): bool => !empty($record?->parseFilename())) ->url(fn (?Model $record): string => route('filament.admin.pages.markdown-import-workbench', [ 'import_id' => $record?->id, ])), Action::make('run_pipeline') ->label('触发全流程') ->icon('heroicon-o-play-circle') ->color('success') ->requiresConfirmation() ->modalHeading('触发 Markdown 拆分 + AI 结构化') ->modalDescription('立即提交队列,按 source_file → source_paper → paper_part → candidate → AI 结构化 执行。') ->action(function (?Model $record) { if (!$record) { return; } dispatch(new \App\Jobs\ProcessMarkdownSplit($record->id)); $record->update([ 'status' => MarkdownImport::STATUS_PROCESSING, 'progress_stage' => MarkdownImport::STAGE_QUEUED, 'progress_message' => '已进入队列…', 'processing_started_at' => now(), 'processing_finished_at' => null, 'error_message' => null, ]); Notification::make() ->title('已提交解析队列') ->success() ->send(); }), Action::make('parse') ->label('解析 Markdown') ->icon('heroicon-o-cog-6-tooth') ->color('info') ->visible(fn (?Model $record): bool => in_array($record?->status, ['pending', 'failed'])) ->requiresConfirmation() ->modalHeading('解析 Markdown') ->modalDescription('将解析 Markdown 中的题目候选,并使用 AI 进行初步筛选。') ->action(function (?Model $record) { if ($record) { static::parseMarkdown($record); } }), Action::make('ai_parse') ->label('AI 解析') ->icon('heroicon-o-sparkles') ->color('warning') ->visible(fn (?Model $record): bool => in_array($record?->status, ['pending', 'processing', 'parsed', 'failed'])) ->requiresConfirmation() ->modalHeading('重新执行 AI 解析') ->modalDescription('将对所有候选题重新进行 AI 结构化解析,清除之前的解析标记。此操作不会重新拆分题目。') ->action(function (?Model $record) { if (!$record) { return; } static::triggerAiParsing($record); }), Action::make('review') ->label('进入校对') ->icon('heroicon-o-clipboard-document-list') ->color('success') ->visible(fn (?Model $record): bool => in_array($record?->status, ['parsed', 'reviewed', 'completed']) && !empty($record?->parseFilename())) ->url(function (?Model $record): string { // 根据状态跳转到不同页面 $importId = $record?->id; $status = $record?->status; // 兼容 PHP 7.4 的写法 if ($status === 'parsed') { return route('filament.admin.resources.pre-question-candidates.index', [ 'import_id' => $importId ]); } elseif (in_array($status, ['reviewed', 'completed'])) { return route('filament.admin.resources.pre-question-candidates.index', [ 'import_id' => $importId, 'tab' => 'reviewed' // 显示已校对标签页 ]); } return route('filament.admin.resources.pre-question-candidates.index', [ 'import_id' => $importId ]); }), Action::make('delete') ->label('删除') ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->modalHeading('删除导入记录') ->modalDescription('确定要删除这条导入记录吗?此操作不可撤销。') ->action(function (?Model $record) { if ($record) { $record->delete(); Notification::make() ->title('删除成功') ->success() ->send(); } }), ]) ->bulkActions([ BulkActionGroup::make([ DeleteBulkAction::make(), BulkAction::make('bulk_ai_parse') ->label('批量 AI 解析') ->icon('heroicon-o-sparkles') ->color('warning') ->requiresConfirmation() ->modalHeading('批量执行 AI 解析') ->modalDescription('将对选中的所有记录重新执行 AI 结构化解析,清除之前的解析标记。') ->action(function (Collection $records) { foreach ($records as $record) { static::triggerAiParsing($record); } }), ]), ]) ->recordClasses(fn (Model $record) => $record->status === 'failed' ? 'bg-rose-50/60' : null) ->defaultSort('created_at', 'desc') ->paginated([10, 25, 50, 100]); } public static function getEloquentQuery(): Builder { // 让 parsed_count / accepted_count 成为可排序的 SQL 字段(避免 order by accessor 报错) return parent::getEloquentQuery() ->withCount([ 'candidates as parsed_count' => fn (Builder $query) => $query->where('status', '!=', 'superseded'), 'candidates as accepted_count' => fn (Builder $query) => $query ->where('status', '!=', 'superseded') ->where('is_question_candidate', true), ]); } public static function getPages(): array { return [ 'index' => Pages\ListMarkdownImports::route('/'), 'create' => Pages\CreateMarkdownImport::route('/create'), 'edit' => Pages\EditMarkdownImport::route('/{record}/edit'), ]; } /** * 启用自动刷新,每5秒更新一次进度 */ public static function getPollingInterval(): ?string { return '5s'; } /** * 解析 Markdown */ public static function parseMarkdown(Model $record): void { try { // 验证状态 if (!in_array($record->status, ['pending', 'failed'], true)) { Notification::make() ->title('只能解析待处理或失败状态的记录') ->warning() ->send(); return; } // 验证 markdown 内容 if (empty($record->original_markdown)) { Notification::make() ->title('Markdown 内容不能为空') ->warning() ->send(); return; } // 失败状态重试:清空错误信息并重新进入待处理 if ($record->status === 'failed') { $record->update([ 'status' => 'pending', 'error_message' => null, ]); } // 先更新状态,确保列表页可见变化(避免“点了没反应”的体验) $record->update([ 'status' => 'processing', 'progress_stage' => \App\Models\MarkdownImport::STAGE_QUEUED, 'progress_message' => '已提交解析任务,等待处理…', 'progress_current' => 0, 'progress_total' => 0, 'progress_updated_at' => now(), 'processing_started_at' => now(), 'processing_finished_at' => null, 'error_message' => null, ]); \Log::info('Markdown import parse queued', [ 'import_id' => $record->id, 'status' => $record->status, 'stage' => $record->progress_stage, ]); // 派发异步任务 \App\Jobs\ProcessMarkdownSplit::dispatch($record->id); Notification::make() ->title('已提交解析任务,正在后台处理...') ->body('列表页将自动刷新显示进度;若长期无进度,请确认 queue worker 正在运行。') ->success() ->send(); } catch (\Exception $e) { Notification::make() ->title('解析失败:' . $e->getMessage()) ->danger() ->send(); } } /** * 重新执行 AI 解析 */ public static function triggerAiParsing(Model $record): void { try { // 检查是否有候选题 $candidateCount = \App\Models\PreQuestionCandidate::where('import_id', $record->id) ->where('status', '!=', 'superseded') ->count(); if ($candidateCount === 0) { Notification::make() ->title('没有找到候选题,无法执行 AI 解析') ->warning() ->send(); return; } // 清理旧的队列任务 \Illuminate\Support\Facades\DB::table('jobs') ->where('payload', 'like', '%"markdownImportId":' . $record->id . '%') ->orWhere('payload', 'like', '%"markdownImportId";i:' . $record->id . ';%') ->delete(); // 清除所有候选题的 AI 解析标记 $candidates = \App\Models\PreQuestionCandidate::where('import_id', $record->id) ->where('status', '!=', 'superseded') ->get(); foreach ($candidates as $candidate) { $meta = $candidate->meta ?? []; unset($meta['ai_parsed'], $meta['ai_parsed_at']); $candidate->update([ 'stem' => null, 'options' => null, 'images' => null, 'tables' => null, 'ai_confidence' => null, 'confidence' => null, 'status' => 'pending', 'meta' => $meta, ]); } // 更新导入记录状态 $record->update([ 'status' => 'processing', 'progress_stage' => \App\Models\MarkdownImport::STAGE_AI_PARSING, 'progress_message' => 'AI 解析中…', 'progress_current' => 0, 'progress_total' => $candidateCount, 'progress_updated_at' => now(), 'processing_started_at' => now(), 'processing_finished_at' => null, 'error_message' => null, ]); // 创建批次并派发 jobs $batchSize = 10; $batches = (int) ceil($candidateCount / $batchSize); for ($b = 0; $b < $batches; $b++) { $startSeq = ($b * $batchSize) + 1; $endSeq = min(($b + 1) * $batchSize, $candidateCount); \App\Jobs\ProcessMarkdownCandidateBatch::dispatch($record->id, $startSeq, $endSeq); } \Illuminate\Support\Facades\Log::info('AI parsing batches dispatched', [ 'import_id' => $record->id, 'total_candidates' => $candidateCount, 'batch_size' => $batchSize, 'batches' => $batches, ]); Notification::make() ->title('已提交 AI 解析任务') ->body("共 {$candidateCount} 个候选题,已分为 {$batches} 个批次并发处理") ->success() ->send(); } catch (\Exception $e) { Notification::make() ->title('AI 解析失败:' . $e->getMessage()) ->danger() ->send(); } } }