MarkdownImportResource.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. <?php
  2. namespace App\Filament\Resources;
  3. use App\Filament\Resources\MarkdownImportResource\Pages;
  4. use App\Models\MarkdownImport;
  5. use BackedEnum;
  6. use Filament\Actions\BulkActionGroup;
  7. use Filament\Actions\DeleteBulkAction;
  8. use Filament\Actions\EditAction;
  9. use Filament\Actions\Action;
  10. use Filament\Facades\Filament;
  11. use Filament\Notifications\Notification;
  12. use Filament\Forms\Components\FileUpload;
  13. use Filament\Forms\Components\Hidden;
  14. use Filament\Forms\Components\MarkdownEditor;
  15. use Filament\Schemas\Components\Section;
  16. use Filament\Forms\Components\Select;
  17. use Filament\Forms\Components\Toggle;
  18. use Filament\Forms\Components\TextInput;
  19. use Filament\Schemas\Components\Utilities\Get;
  20. use Filament\Schemas\Components\Utilities\Set;
  21. use Filament\Resources\Resource;
  22. use Filament\Schemas\Schema;
  23. use Filament\Tables;
  24. use Filament\Tables\Table;
  25. use Illuminate\Database\Eloquent\Builder;
  26. use Illuminate\Database\Eloquent\Model;
  27. use Illuminate\Support\Facades\Storage;
  28. use Illuminate\Support\Facades\DB;
  29. use UnitEnum;
  30. use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
  31. use App\Support\TextEncoding;
  32. use App\Rules\MarkdownFileExtension;
  33. use Filament\Tables\Columns\TextColumn;
  34. use Filament\Tables\Enums\FiltersLayout;
  35. class MarkdownImportResource extends Resource
  36. {
  37. protected static ?string $model = MarkdownImport::class;
  38. protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-arrow-up-tray';
  39. protected static ?string $navigationLabel = 'Markdown 导入';
  40. protected static ?string $modelLabel = 'Markdown 导入';
  41. protected static ?string $pluralModelLabel = 'Markdown 导入';
  42. protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
  43. protected static ?int $navigationSort = 1;
  44. protected static ?string $title = 'Markdown 试卷导入管理';
  45. protected static ?string $description = '导入 Markdown 格式的数学试卷,AI 智能识别题目,人工校对后入库';
  46. public static function mutateFormDataBeforeCreate(array $data): array
  47. {
  48. // 支持上传 markdown 文件:读取内容写入 original_markdown
  49. if (!empty($data['markdown_file']) && empty($data['original_markdown'])) {
  50. $path = $data['markdown_file'];
  51. if (is_string($path) && Storage::disk('local')->exists($path)) {
  52. $data['original_markdown'] = TextEncoding::toUtf8(Storage::disk('local')->get($path));
  53. }
  54. }
  55. // 文件名默认取上传文件名(优先原始文件名,其次取存储路径 basename)
  56. if (empty($data['file_name']) && !empty($data['markdown_file'])) {
  57. $storedNames = $data['uploaded_file_names'] ?? null;
  58. if (is_array($storedNames) && !empty($storedNames)) {
  59. $data['file_name'] = (string) array_values($storedNames)[0];
  60. } else {
  61. $path = is_array($data['markdown_file']) ? ($data['markdown_file'][0] ?? '') : (string) $data['markdown_file'];
  62. $data['file_name'] = $path !== '' ? basename($path) : null;
  63. }
  64. }
  65. // 文件名作为来源名称
  66. if (!empty($data['file_name'])) {
  67. $data['source_name'] = $data['file_name'];
  68. $data['source_type'] = 'other';
  69. }
  70. unset($data['markdown_file']);
  71. unset($data['uploaded_file_names']);
  72. return $data;
  73. }
  74. /**
  75. * 允许创建新的 Markdown 导入记录
  76. */
  77. public static function canCreate(): bool
  78. {
  79. return true;
  80. }
  81. public static function form(Schema $schema): Schema
  82. {
  83. return $schema
  84. ->schema([
  85. Section::make('上传与来源信息')
  86. ->schema([
  87. \Filament\Forms\Components\TextInput::make('file_name')
  88. ->label('文件名(来源名称)')
  89. ->required(fn (Get $get): bool => empty($get('markdown_file')))
  90. ->maxLength(255),
  91. FileUpload::make('markdown_file')
  92. ->label('Markdown 文件(可选)')
  93. ->disk('local')
  94. ->directory('imports/markdown')
  95. ->helperText('仅支持 .md / .markdown / .txt;上传后会自动读取内容并填充编辑器')
  96. ->maxSize(10 * 1024)
  97. ->storeFileNamesIn('uploaded_file_names')
  98. ->dehydrated(true)
  99. ->preserveFilenames()
  100. ->rules([new MarkdownFileExtension()])
  101. ->afterStateUpdated(function ($state, Set $set, Get $get): void {
  102. // 在提交表单前,FileUpload 的 state 可能还是 TemporaryUploadedFile(尚未保存到 disk)
  103. $first = is_array($state) ? ($state[0] ?? null) : $state;
  104. if ($first instanceof TemporaryUploadedFile) {
  105. $set('original_markdown', TextEncoding::toUtf8((string) @file_get_contents($first->getRealPath())));
  106. if (empty($get('file_name'))) {
  107. $set('file_name', $first->getClientOriginalName());
  108. }
  109. return;
  110. }
  111. $paths = is_array($state) ? $state : (empty($state) ? [] : [$state]);
  112. $path = (string) ($paths[0] ?? '');
  113. if ($path === '') {
  114. return;
  115. }
  116. // 已保存到 disk 后:读取文件内容填充编辑器
  117. if (Storage::disk('local')->exists($path)) {
  118. $set('original_markdown', TextEncoding::toUtf8(Storage::disk('local')->get($path)));
  119. }
  120. // 上传后的真实文件名:BaseFileUpload 会在保存时 storeFileName($storedFile, originalName)
  121. $storedNames = $get('uploaded_file_names');
  122. if (is_string($storedNames) && $storedNames !== '') {
  123. $set('file_name', $storedNames);
  124. } elseif (empty($get('file_name'))) {
  125. $set('file_name', basename($path));
  126. }
  127. }),
  128. Hidden::make('uploaded_file_names')
  129. ->dehydrated(true),
  130. ])
  131. ->columns(2),
  132. Section::make('解析规则(可选)')
  133. ->schema([
  134. Select::make('parse_mode')
  135. ->label('解析模式')
  136. ->options([
  137. 'strict' => '严格模式',
  138. 'relaxed' => '宽松模式',
  139. ])
  140. ->default('strict')
  141. ->dehydrated(false),
  142. TextInput::make('split_marker')
  143. ->label('分题符号')
  144. ->placeholder('如:---')
  145. ->dehydrated(false),
  146. TextInput::make('type_marker')
  147. ->label('题型标记')
  148. ->placeholder('如:#选择题')
  149. ->dehydrated(false),
  150. Toggle::make('auto_detect_images')
  151. ->label('自动识别图片')
  152. ->default(true)
  153. ->dehydrated(false),
  154. ])
  155. ->columns(2)
  156. ->collapsed(),
  157. Section::make('Markdown 内容')
  158. ->schema([
  159. MarkdownEditor::make('original_markdown')
  160. ->label('Markdown 内容(编辑器)')
  161. ->required(fn (Get $get): bool => empty($get('markdown_file')))
  162. ->columnSpanFull()
  163. // 固定编辑器高度,避免内容过长把页面撑开
  164. ->minHeight('45vh')
  165. ->maxHeight('45vh')
  166. ->toolbarButtons([
  167. 'bold',
  168. 'italic',
  169. 'strike',
  170. 'blockquote',
  171. 'bulletList',
  172. 'orderedList',
  173. 'link',
  174. 'codeBlock',
  175. 'table',
  176. 'undo',
  177. 'redo',
  178. ]),
  179. ]),
  180. ]);
  181. }
  182. public static function table(Table $table): Table
  183. {
  184. return $table
  185. ->columns([
  186. TextColumn::make('file_name')
  187. ->label('文件名')
  188. ->searchable()
  189. ->sortable(),
  190. TextColumn::make('remote_url')
  191. ->label('源文件')
  192. ->getStateUsing(fn (?Model $record) => $record?->remote_url ? '查看' : '—')
  193. ->icon('heroicon-o-document-arrow-down')
  194. ->color('primary')
  195. ->url(fn (?Model $record) => $record?->remote_url)
  196. ->openUrlInNewTab()
  197. ->toggleable(),
  198. TextColumn::make('filename_parse_status')
  199. ->label('命名解析')
  200. ->badge()
  201. ->getStateUsing(function (?Model $record): string {
  202. if (!$record) {
  203. return '未知';
  204. }
  205. $parsed = $record->parseFilename();
  206. return empty($parsed) ? '不规范' : '正常';
  207. })
  208. ->color(function (?Model $record): string {
  209. if (!$record) {
  210. return 'gray';
  211. }
  212. return empty($record->parseFilename()) ? 'warning' : 'success';
  213. })
  214. ->tooltip(function (?Model $record): ?string {
  215. if (!$record) {
  216. return null;
  217. }
  218. $parsed = $record->parseFilename();
  219. if (empty($parsed)) {
  220. return '系列_年级_学期_学科_名称';
  221. }
  222. return sprintf(
  223. '系列:%s 年级:%s 学期:%s 学科:%s 名称:%s',
  224. $parsed['series'] ?? '-',
  225. $parsed['grade'] ?? '-',
  226. $parsed['term'] ?? '-',
  227. $parsed['subject'] ?? '-',
  228. $parsed['name'] ?? '-'
  229. );
  230. })
  231. ->url(fn (?Model $record): ?string => $record ? route('filament.admin.pages.markdown-import-workbench', [
  232. 'import_id' => $record->id,
  233. ]) : null)
  234. ->openUrlInNewTab(),
  235. TextColumn::make('source_name')
  236. ->label('来源')
  237. ->toggleable(isToggledHiddenByDefault: true),
  238. TextColumn::make('status')
  239. ->label('状态')
  240. ->badge()
  241. ->color(fn (string $state): string => match ($state) {
  242. 'pending' => 'gray',
  243. 'processing' => 'warning',
  244. 'parsed' => 'info',
  245. 'reviewed' => 'primary',
  246. 'completed' => 'success',
  247. 'failed' => 'danger',
  248. default => 'gray',
  249. })
  250. ->getStateUsing(function (?Model $record): string {
  251. if (!$record) {
  252. return '—';
  253. }
  254. return match ($record->status) {
  255. 'pending' => '待处理',
  256. 'processing' => $record->progress_label ?: '处理中',
  257. 'parsed' => '已解析(待校对)',
  258. 'reviewed' => '已校对(待入库)',
  259. 'completed' => '已完成(已入库)',
  260. 'failed' => '失败' . ($record->progress_message ? "({$record->progress_message})" : ''),
  261. default => (string) $record->status,
  262. };
  263. }),
  264. TextColumn::make('progress_message')
  265. ->label('当前步骤')
  266. ->getStateUsing(fn (?Model $record) => $record?->progress_message ?: '—')
  267. ->wrap()
  268. ->limit(60),
  269. TextColumn::make('progress_label')
  270. ->label('进度')
  271. ->getStateUsing(fn (?Model $record) => $record?->progress_label ?: '—')
  272. ->color('gray'),
  273. TextColumn::make('parsed_count')
  274. ->label('候选题数')
  275. ->getStateUsing(fn (?Model $record) => $record?->parsed_count ?? 0)
  276. ->sortable(),
  277. TextColumn::make('accepted_count')
  278. ->label('已接受')
  279. ->getStateUsing(fn (?Model $record) => $record?->accepted_count ?? 0)
  280. ->sortable(),
  281. TextColumn::make('created_at')
  282. ->label('导入时间')
  283. ->dateTime()
  284. ->sortable(),
  285. TextColumn::make('processing_started_at')
  286. ->label('开始')
  287. ->dateTime('m-d H:i')
  288. ->toggleable(isToggledHiddenByDefault: true),
  289. TextColumn::make('processing_finished_at')
  290. ->label('结束')
  291. ->dateTime('m-d H:i')
  292. ->toggleable(isToggledHiddenByDefault: true),
  293. TextColumn::make('error_message')
  294. ->label('错误')
  295. ->visible(fn (?Model $record): bool => $record?->status === 'failed')
  296. ->wrap()
  297. ->limit(80),
  298. ])
  299. ->filters([
  300. Tables\Filters\SelectFilter::make('status')
  301. ->label('状态')
  302. ->options([
  303. 'pending' => '待处理',
  304. 'processing' => '处理中',
  305. 'parsed' => '已解析',
  306. 'reviewed' => '已校对',
  307. 'completed' => '已完成',
  308. 'failed' => '处理失败',
  309. ]),
  310. Tables\Filters\SelectFilter::make('source_type')
  311. ->label('来源类型')
  312. ->options([
  313. 'textbook' => '教材',
  314. 'exam' => '考试',
  315. 'other' => '其他',
  316. ]),
  317. Tables\Filters\SelectFilter::make('filename_parse')
  318. ->label('命名规范')
  319. ->options([
  320. 'valid' => '正常',
  321. 'invalid' => '不规范',
  322. ])
  323. ->query(function (Builder $query, array $data) {
  324. $value = $data['value'] ?? null;
  325. $driver = DB::getDriverName();
  326. $regex = '^.+_[0-9]+_[0-2]_.+_.+$';
  327. if ($value === 'valid') {
  328. if ($driver === 'mysql') {
  329. $query->whereRaw('file_name REGEXP ?', [$regex]);
  330. } else {
  331. $query->where('file_name', 'like', '%_%_%_%_%');
  332. }
  333. }
  334. if ($value === 'invalid') {
  335. if ($driver === 'mysql') {
  336. $query->where(function ($q) use ($regex) {
  337. $q->whereNull('file_name')->orWhereRaw('file_name NOT REGEXP ?', [$regex]);
  338. });
  339. } else {
  340. $query->where(function ($q) {
  341. $q->whereNull('file_name')->orWhere('file_name', 'not like', '%_%_%_%_%');
  342. });
  343. }
  344. }
  345. }),
  346. ], layout: FiltersLayout::AboveContentCollapsible)
  347. ->actions([
  348. EditAction::make()
  349. ->label('编辑'),
  350. Action::make('workbench')
  351. ->label('导入工作台')
  352. ->icon('heroicon-o-rectangle-stack')
  353. ->color('primary')
  354. ->visible(fn (?Model $record): bool => !empty($record?->parseFilename()))
  355. ->url(fn (?Model $record): string => route('filament.admin.pages.markdown-import-workbench', [
  356. 'import_id' => $record?->id,
  357. ])),
  358. Action::make('run_pipeline')
  359. ->label('触发全流程')
  360. ->icon('heroicon-o-play-circle')
  361. ->color('success')
  362. ->requiresConfirmation()
  363. ->modalHeading('触发 Markdown 拆分 + AI 结构化')
  364. ->modalDescription('立即提交队列,按 source_file → source_paper → paper_part → candidate → AI 结构化 执行。')
  365. ->action(function (?Model $record) {
  366. if (!$record) {
  367. return;
  368. }
  369. dispatch(new \App\Jobs\ProcessMarkdownSplit($record->id));
  370. $record->update([
  371. 'status' => MarkdownImport::STATUS_PROCESSING,
  372. 'progress_stage' => MarkdownImport::STAGE_QUEUED,
  373. 'progress_message' => '已进入队列…',
  374. 'processing_started_at' => now(),
  375. 'processing_finished_at' => null,
  376. 'error_message' => null,
  377. ]);
  378. Notification::make()
  379. ->title('已提交解析队列')
  380. ->success()
  381. ->send();
  382. }),
  383. Action::make('parse')
  384. ->label('解析 Markdown')
  385. ->icon('heroicon-o-cog-6-tooth')
  386. ->color('info')
  387. ->visible(fn (?Model $record): bool => in_array($record?->status, ['pending', 'failed']))
  388. ->requiresConfirmation()
  389. ->modalHeading('解析 Markdown')
  390. ->modalDescription('将解析 Markdown 中的题目候选,并使用 AI 进行初步筛选。')
  391. ->action(function (?Model $record) {
  392. if ($record) {
  393. static::parseMarkdown($record);
  394. }
  395. }),
  396. Action::make('review')
  397. ->label('进入校对')
  398. ->icon('heroicon-o-clipboard-document-list')
  399. ->color('success')
  400. ->visible(fn (?Model $record): bool => in_array($record?->status, ['parsed', 'reviewed', 'completed']) && !empty($record?->parseFilename()))
  401. ->url(function (?Model $record): string {
  402. // 根据状态跳转到不同页面
  403. $importId = $record?->id;
  404. $status = $record?->status;
  405. // 兼容 PHP 7.4 的写法
  406. if ($status === 'parsed') {
  407. return route('filament.admin.resources.pre-question-candidates.index', [
  408. 'import_id' => $importId
  409. ]);
  410. } elseif (in_array($status, ['reviewed', 'completed'])) {
  411. return route('filament.admin.resources.pre-question-candidates.index', [
  412. 'import_id' => $importId,
  413. 'tab' => 'reviewed' // 显示已校对标签页
  414. ]);
  415. }
  416. return route('filament.admin.resources.pre-question-candidates.index', [
  417. 'import_id' => $importId
  418. ]);
  419. }),
  420. Action::make('delete')
  421. ->label('删除')
  422. ->icon('heroicon-o-trash')
  423. ->color('danger')
  424. ->requiresConfirmation()
  425. ->modalHeading('删除导入记录')
  426. ->modalDescription('确定要删除这条导入记录吗?此操作不可撤销。')
  427. ->action(function (?Model $record) {
  428. if ($record) {
  429. $record->delete();
  430. Notification::make()
  431. ->title('删除成功')
  432. ->success()
  433. ->send();
  434. }
  435. }),
  436. ])
  437. ->bulkActions([
  438. BulkActionGroup::make([
  439. DeleteBulkAction::make(),
  440. ]),
  441. ])
  442. ->recordClasses(fn (Model $record) => $record->status === 'failed' ? 'bg-rose-50/60' : null)
  443. ->defaultSort('created_at', 'desc')
  444. ->paginated([10, 25, 50, 100]);
  445. }
  446. public static function getEloquentQuery(): Builder
  447. {
  448. // 让 parsed_count / accepted_count 成为可排序的 SQL 字段(避免 order by accessor 报错)
  449. return parent::getEloquentQuery()
  450. ->withCount([
  451. 'candidates as parsed_count' => fn (Builder $query) => $query->where('status', '!=', 'superseded'),
  452. 'candidates as accepted_count' => fn (Builder $query) => $query
  453. ->where('status', '!=', 'superseded')
  454. ->where('is_question_candidate', true),
  455. ]);
  456. }
  457. public static function getPages(): array
  458. {
  459. return [
  460. 'index' => Pages\ListMarkdownImports::route('/'),
  461. 'create' => Pages\CreateMarkdownImport::route('/create'),
  462. 'edit' => Pages\EditMarkdownImport::route('/{record}/edit'),
  463. ];
  464. }
  465. /**
  466. * 解析 Markdown
  467. */
  468. public static function parseMarkdown(Model $record): void
  469. {
  470. try {
  471. // 验证状态
  472. if (!in_array($record->status, ['pending', 'failed'], true)) {
  473. Notification::make()
  474. ->title('只能解析待处理或失败状态的记录')
  475. ->warning()
  476. ->send();
  477. return;
  478. }
  479. // 验证 markdown 内容
  480. if (empty($record->original_markdown)) {
  481. Notification::make()
  482. ->title('Markdown 内容不能为空')
  483. ->warning()
  484. ->send();
  485. return;
  486. }
  487. // 失败状态重试:清空错误信息并重新进入待处理
  488. if ($record->status === 'failed') {
  489. $record->update([
  490. 'status' => 'pending',
  491. 'error_message' => null,
  492. ]);
  493. }
  494. // 先更新状态,确保列表页可见变化(避免“点了没反应”的体验)
  495. $record->update([
  496. 'status' => 'processing',
  497. 'progress_stage' => \App\Models\MarkdownImport::STAGE_QUEUED,
  498. 'progress_message' => '已提交解析任务,等待处理…',
  499. 'progress_current' => 0,
  500. 'progress_total' => 0,
  501. 'progress_updated_at' => now(),
  502. 'processing_started_at' => now(),
  503. 'processing_finished_at' => null,
  504. 'error_message' => null,
  505. ]);
  506. \Log::info('Markdown import parse queued', [
  507. 'import_id' => $record->id,
  508. 'status' => $record->status,
  509. 'stage' => $record->progress_stage,
  510. ]);
  511. // 派发异步任务
  512. \App\Jobs\ProcessMarkdownSplit::dispatch($record->id);
  513. Notification::make()
  514. ->title('已提交解析任务,正在后台处理...')
  515. ->body('列表页将自动刷新显示进度;若长期无进度,请确认 queue worker 正在运行。')
  516. ->success()
  517. ->send();
  518. } catch (\Exception $e) {
  519. Notification::make()
  520. ->title('解析失败:' . $e->getMessage())
  521. ->danger()
  522. ->send();
  523. }
  524. }
  525. }