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('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'), 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' => '其他', ]), ], layout: FiltersLayout::AboveContentCollapsible) ->actions([ EditAction::make() ->label('编辑'), 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('review') ->label('进入校对') ->icon('heroicon-o-clipboard-document-list') ->color('success') ->visible(fn (?Model $record): bool => in_array($record?->status, ['parsed', 'reviewed', 'completed'])) ->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(), ]), ]) ->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', 'candidates as accepted_count' => fn (Builder $query) => $query->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'), ]; } /** * 解析 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(); } } }