| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708 |
- <?php
- namespace App\Filament\Resources;
- use App\Filament\Resources\MarkdownImportResource\Pages;
- use App\Models\MarkdownImport;
- use BackedEnum;
- use Filament\Actions\BulkAction;
- use Filament\Actions\BulkActionGroup;
- use Filament\Actions\DeleteBulkAction;
- use Filament\Actions\EditAction;
- use Filament\Actions\Action;
- use Filament\Facades\Filament;
- use Filament\Notifications\Notification;
- use Filament\Forms\Components\FileUpload;
- use Filament\Forms\Components\Hidden;
- use Filament\Forms\Components\MarkdownEditor;
- use Filament\Schemas\Components\Section;
- use Filament\Forms\Components\Select;
- use Filament\Forms\Components\Toggle;
- use Filament\Forms\Components\TextInput;
- use Filament\Schemas\Components\Utilities\Get;
- use Filament\Schemas\Components\Utilities\Set;
- use Filament\Resources\Resource;
- use Filament\Schemas\Schema;
- use Filament\Tables;
- use Filament\Tables\Table;
- use Illuminate\Database\Eloquent\Builder;
- use Illuminate\Database\Eloquent\Model;
- use Illuminate\Support\Facades\Storage;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Collection;
- use UnitEnum;
- use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
- use App\Support\TextEncoding;
- use App\Rules\MarkdownFileExtension;
- use Filament\Tables\Columns\TextColumn;
- use Filament\Tables\Enums\FiltersLayout;
- class MarkdownImportResource extends Resource
- {
- protected static ?string $model = MarkdownImport::class;
- protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-arrow-up-tray';
- protected static ?string $navigationLabel = 'Markdown 导入';
- protected static ?string $modelLabel = 'Markdown 导入';
- protected static ?string $pluralModelLabel = 'Markdown 导入';
- protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
- protected static ?int $navigationSort = 1;
- protected static ?string $title = 'Markdown 试卷导入管理';
- protected static ?string $description = '导入 Markdown 格式的数学试卷,AI 智能识别题目,人工校对后入库';
- public static function mutateFormDataBeforeCreate(array $data): array
- {
- // 支持上传 markdown 文件:读取内容写入 original_markdown
- if (!empty($data['markdown_file']) && empty($data['original_markdown'])) {
- $path = $data['markdown_file'];
- if (is_string($path) && Storage::disk('local')->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'),
- 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'),
- ];
- }
- /**
- * 解析 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();
- }
- }
- }
|