MarkdownImportResource.php 19 KB

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