MarkdownImportWorkbench.php 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145
  1. <?php
  2. namespace App\Filament\Pages;
  3. use App\Models\MarkdownImport;
  4. use App\Models\SourcePaper;
  5. use App\Models\Textbook;
  6. use App\Models\TextbookCatalog;
  7. use App\Services\ImportInferenceService;
  8. use App\Models\TextbookSeries;
  9. use Filament\Pages\Page;
  10. use Illuminate\Support\Arr;
  11. use Illuminate\Support\Facades\DB;
  12. use Filament\Notifications\Notification;
  13. use Illuminate\Support\Facades\Log;
  14. class MarkdownImportWorkbench extends Page
  15. {
  16. protected static ?string $navigationLabel = '导入工作台';
  17. protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
  18. protected static string|\UnitEnum|null $navigationGroup = '卷子导入流程';
  19. protected static ?int $navigationSort = 2;
  20. protected string $view = 'filament.pages.markdown-import-workbench';
  21. protected ?ImportInferenceService $inferenceService = null;
  22. protected function inferenceService(): ImportInferenceService
  23. {
  24. return $this->inferenceService ??= app(ImportInferenceService::class);
  25. }
  26. public ?int $importId = null;
  27. public ?int $selectedPaperId = null;
  28. public array $selectedIds = [];
  29. public string $search = '';
  30. public string $groupBy = 'paper';
  31. public bool $dense = false;
  32. public bool $filenameValid = true;
  33. public array $filenameParsed = [];
  34. public ?string $filenameWarning = null;
  35. public array $form = [
  36. 'title' => null,
  37. 'edition' => null,
  38. 'grade' => null,
  39. 'term' => null,
  40. 'chapter' => null,
  41. 'source_type' => null,
  42. 'source_year' => null,
  43. 'textbook_id' => null,
  44. 'textbook_series' => null,
  45. 'textbook_series_id' => null,
  46. 'source_name' => null,
  47. 'source_page' => null,
  48. 'subject' => '数学', // 默认学科
  49. 'tags' => '',
  50. 'bundle_key' => null,
  51. 'expected_count' => null,
  52. 'catalog_node_ids' => [],
  53. ];
  54. public array $batch = [
  55. 'edition' => null,
  56. 'grade' => null,
  57. 'term' => null,
  58. 'chapter' => null,
  59. 'source_type' => null,
  60. 'source_year' => null,
  61. 'textbook_id' => null,
  62. 'textbook_series' => null,
  63. 'source_name' => null,
  64. 'source_page' => null,
  65. 'tags' => '',
  66. 'bundle_key' => null,
  67. 'expected_count' => null,
  68. 'catalog_node_ids' => [],
  69. ];
  70. private array $catalogNodeCache = [];
  71. public function mount(): void
  72. {
  73. $this->importId = request()->integer('import_id');
  74. $parsed = $this->parseImportFilename();
  75. if (empty($parsed)) {
  76. $this->filenameValid = false;
  77. $this->filenameWarning = '文件名不符合规范:系列_年级_学期_学科_名称(例:北师大版_7_1_数学_...)';
  78. } else {
  79. $this->filenameParsed = $parsed;
  80. $this->applyFilenameDefaults();
  81. }
  82. $first = $this->papers()->first();
  83. if ($first) {
  84. $this->selectPaper($first->id);
  85. }
  86. }
  87. public function importRecord(): ?MarkdownImport
  88. {
  89. return $this->importId ? MarkdownImport::query()->find($this->importId) : null;
  90. }
  91. public function papers()
  92. {
  93. if (!$this->importId) {
  94. return collect();
  95. }
  96. $query = SourcePaper::query()
  97. ->whereHas('candidates', fn ($q) => $q->where('import_id', $this->importId))
  98. ->withCount(['candidates', 'parts'])
  99. ->orderBy('order');
  100. if ($this->search !== '') {
  101. $query->where(function ($q) {
  102. $q->where('title', 'like', '%' . $this->search . '%')
  103. ->orWhere('full_title', 'like', '%' . $this->search . '%')
  104. ->orWhere('paper_code', 'like', '%' . $this->search . '%');
  105. });
  106. }
  107. return $query->get();
  108. }
  109. public function groupedPapers(): array
  110. {
  111. $papers = $this->papers();
  112. if ($this->groupBy === 'paper') {
  113. return $papers->groupBy(fn ($paper) => $paper->title ?: $paper->full_title ?: '未命名卷子')->toArray();
  114. }
  115. if ($this->groupBy === 'grade') {
  116. return $papers->groupBy(fn ($paper) => $paper->grade ? $paper->grade . '年级' : '未标注年级')->toArray();
  117. }
  118. return $papers->groupBy(fn ($paper) => Arr::get($paper->meta ?? [], 'bundle_key', '未归并套卷'))->toArray();
  119. }
  120. public function selectedPaper(): ?SourcePaper
  121. {
  122. return $this->selectedPaperId
  123. ? SourcePaper::query()->withCount(['candidates', 'parts'])->find($this->selectedPaperId)
  124. : null;
  125. }
  126. public function selectPaper(int $paperId): void
  127. {
  128. $paper = SourcePaper::query()->find($paperId);
  129. if (!$paper) {
  130. return;
  131. }
  132. Log::info('MarkdownImportWorkbench select paper', [
  133. 'import_id' => $this->importId,
  134. 'paper_id' => $paperId,
  135. 'paper_title' => $paper->title,
  136. 'selected_ids' => $this->selectedIds,
  137. ]);
  138. $parsed = $this->parseImportFilename();
  139. $this->selectedPaperId = $paperId;
  140. $meta = $paper->meta ?? [];
  141. // 核心修正:直接基于物理 series_id 初始化,如果没有则尝试从文件名或标题解析
  142. $finalSeriesId = $paper->series_id;
  143. $finalSeriesName = null;
  144. if (!$finalSeriesId) {
  145. // 兜底1:从文件名解析
  146. if (!empty($parsed['series'])) {
  147. $formalSeries = $this->inferenceService()->resolveSeries($parsed['series']);
  148. $finalSeriesId = $formalSeries?->id;
  149. $finalSeriesName = $formalSeries ? $formalSeries->name : $parsed['series'];
  150. }
  151. // 兜底2:从标题模糊推断 (应对数据库数据未对齐的情况)
  152. if (!$finalSeriesId) {
  153. $title = $paper->full_title ?: $paper->title;
  154. $formalSeries = $this->inferenceService()->resolveSeries($title);
  155. $finalSeriesId = $formalSeries?->id;
  156. $finalSeriesName = $formalSeries?->name;
  157. }
  158. }
  159. if ($finalSeriesId && !$finalSeriesName) {
  160. $finalSeriesName = TextbookSeries::find($finalSeriesId)?->name;
  161. }
  162. $resolvedTextbook = null;
  163. if (!$paper->textbook_id && $finalSeriesId) {
  164. $resolvedTextbook = $this->inferenceService()->findBestTextbook([
  165. 'series_id' => $finalSeriesId,
  166. 'grade' => $paper->grade ?: ($parsed['grade'] ?? null),
  167. 'term' => $paper->term ?: ($parsed['term'] ?? null),
  168. ]);
  169. }
  170. $initialCatalogIds = (array) (Arr::get($meta, 'catalog_node_ids') ?: (Arr::get($meta, 'catalog_node_id') ? [Arr::get($meta, 'catalog_node_id')] : []));
  171. $filteredCatalogIds = $this->filterCatalogNodeIdsForTextbook($paper->textbook_id ?: ($resolvedTextbook?->id ?? null), $initialCatalogIds);
  172. if ($initialCatalogIds !== $filteredCatalogIds && !empty($initialCatalogIds)) {
  173. Log::warning('MarkdownImportWorkbench catalog nodes not in textbook', [
  174. 'paper_id' => $paper->id,
  175. 'paper_title' => $paper->title,
  176. 'textbook_id' => $paper->textbook_id,
  177. 'original_catalog_node_ids' => $initialCatalogIds,
  178. 'filtered_catalog_node_ids' => $filteredCatalogIds,
  179. ]);
  180. }
  181. $this->form = [
  182. 'title' => $paper->title,
  183. 'edition' => $paper->edition,
  184. 'grade' => $paper->grade ?: ($parsed['grade'] ?? null),
  185. 'term' => $paper->term ?: ($parsed['term'] ?? null),
  186. 'chapter' => $paper->chapter,
  187. 'source_type' => $paper->source_type,
  188. 'source_year' => $paper->source_year,
  189. 'textbook_id' => $paper->textbook_id ?: ($resolvedTextbook?->id ?? null),
  190. 'textbook_series' => $finalSeriesName,
  191. 'textbook_series_id' => $finalSeriesId,
  192. 'source_name' => Arr::get($meta, 'source_name') ?: ($parsed['name'] ?? null),
  193. 'source_page' => Arr::get($meta, 'source_page'),
  194. 'tags' => implode(',', Arr::get($meta, 'tags', [])),
  195. 'bundle_key' => Arr::get($meta, 'bundle_key'),
  196. 'expected_count' => Arr::get($meta, 'expected_count'),
  197. 'catalog_node_ids' => $filteredCatalogIds,
  198. ];
  199. }
  200. public function updatedSelectedIds(): void
  201. {
  202. if (empty($this->selectedIds) || $this->selectedPaperId === null) {
  203. return;
  204. }
  205. if ($this->isBatchEmpty()) {
  206. $this->seedBatchFromCurrent();
  207. }
  208. }
  209. public function updated($name, $value): void
  210. {
  211. if (!$this->selectedPaperId) {
  212. return;
  213. }
  214. }
  215. /**
  216. * 教材属性联动:当系列、年级、学期改变时,自动匹配最合适的教材
  217. */
  218. public function updatedFormGrade(): void { $this->reInferTextbook(); }
  219. public function updatedFormTerm(): void { $this->reInferTextbook(); }
  220. public function updatedFormTextbookSeriesId(): void
  221. {
  222. // 核心联动:系列 ID 变动,清空教材并重推
  223. $this->form['textbook_id'] = null;
  224. $this->form['catalog_node_ids'] = [];
  225. // 同时同步一下显示名称 (虽然逻辑以 ID 为准,但保留名称用于前端显示或保存)
  226. $series = TextbookSeries::find($this->form['textbook_series_id']);
  227. $this->form['textbook_series'] = $series?->name;
  228. $this->reInferTextbook();
  229. }
  230. protected function reInferTextbook(): void
  231. {
  232. if (!empty($this->form['textbook_id'])) {
  233. return;
  234. }
  235. $best = $this->inferenceService()->findBestTextbook($this->form);
  236. if ($best) {
  237. // 无论 ID 是否改变,都强制执行一次属性校准,确保“一环扣一环”
  238. $this->syncTextbookAttributes($best);
  239. }
  240. }
  241. protected function syncTextbookAttributes(Textbook $textbook): void
  242. {
  243. $this->form['textbook_id'] = $textbook->id;
  244. $this->form['grade'] = (string)$textbook->grade;
  245. $this->form['term'] = $this->semesterToTerm($textbook->semester);
  246. $this->form['textbook_series_id'] = $textbook->series_id;
  247. $series = $textbook->getRelation('series') ?: $textbook->series()->first();
  248. $seriesName = $textbook->track ?: ($series?->name ?? null);
  249. if ($seriesName) {
  250. $this->form['textbook_series'] = $seriesName;
  251. }
  252. $this->savePaper();
  253. }
  254. public function updatedFormTextbookId($value): void
  255. {
  256. if (!$this->selectedPaperId || !$value) {
  257. return;
  258. }
  259. // 权威源:从数据库获取该教材的所有官方属性
  260. $textbook = Textbook::query()->with('series')->find($value);
  261. if ($textbook) {
  262. $this->syncTextbookAttributes($textbook);
  263. }
  264. }
  265. protected function semesterToTerm(?int $semester): ?string
  266. {
  267. return match ($semester) {
  268. 1 => '上册',
  269. 2 => '下册',
  270. default => null,
  271. };
  272. }
  273. public function seedBatchFromCurrent(): void
  274. {
  275. $this->batch = [
  276. 'edition' => $this->form['edition'] ?? null,
  277. 'grade' => $this->form['grade'] ?? null,
  278. 'term' => $this->form['term'] ?? null,
  279. 'chapter' => $this->form['chapter'] ?? null,
  280. 'source_type' => $this->form['source_type'] ?? null,
  281. 'source_year' => $this->form['source_year'] ?? null,
  282. 'textbook_id' => $this->form['textbook_id'] ?? null,
  283. 'textbook_series_id' => $this->form['textbook_series_id'] ?? null,
  284. 'textbook_series' => $this->form['textbook_series'] ?? null,
  285. 'source_name' => $this->form['source_name'] ?? null,
  286. 'source_page' => $this->form['source_page'] ?? null,
  287. 'tags' => $this->form['tags'] ?? '',
  288. 'bundle_key' => $this->form['bundle_key'] ?? null,
  289. 'expected_count' => $this->form['expected_count'] ?? null,
  290. 'catalog_node_ids' => $this->form['catalog_node_ids'] ?? [],
  291. ];
  292. }
  293. public function savePaper(bool $silent = false): void
  294. {
  295. $paper = $this->selectedPaper();
  296. if (!$paper) {
  297. Log::warning('MarkdownImportWorkbench save failed: no paper selected', [
  298. 'import_id' => $this->importId,
  299. 'selected_paper_id' => $this->selectedPaperId,
  300. ]);
  301. return;
  302. }
  303. $selectedIds = array_values(array_filter(array_unique(array_map('intval', $this->selectedIds))));
  304. $meta = $paper->meta ?? [];
  305. $meta['source_name'] = $this->form['source_name'] ?? null;
  306. $meta['source_page'] = $this->form['source_page'] ?? null;
  307. $meta['tags'] = $this->explodeTags($this->form['tags'] ?? '');
  308. $meta['bundle_key'] = $this->form['bundle_key'] ?? null;
  309. $meta['expected_count'] = $this->form['expected_count'] ?? null;
  310. // 关键修正:确保目录 ID 是干净的整型数组,解决保存失效
  311. $catalogNodeIds = $this->normalizeCatalogNodeIds($this->form['catalog_node_ids'] ?? []);
  312. $catalogNodeIds = $this->filterCatalogNodeIdsForTextbook($updates['textbook_id'] ?? null, $catalogNodeIds);
  313. $meta['catalog_node_ids'] = $catalogNodeIds;
  314. // 同时保留旧字段供向后兼容
  315. $meta['catalog_node_id'] = !empty($meta['catalog_node_ids']) ? $meta['catalog_node_ids'][0] : null;
  316. // 核心同步:保存时根据 series_id 确保名称同步
  317. if (!empty($this->form['textbook_series_id'])) {
  318. $series = TextbookSeries::find($this->form['textbook_series_id']);
  319. if ($series) {
  320. $this->form['textbook_series'] = $series->name;
  321. }
  322. }
  323. $fields = [
  324. 'title', 'edition', 'grade', 'term', 'chapter', 'source_type',
  325. 'source_year', 'textbook_id'
  326. ];
  327. $updates = [];
  328. foreach ($fields as $field) {
  329. $value = $this->form[$field] ?? null;
  330. $updates[$field] = ($value === '' ? null : $value);
  331. }
  332. $updates['series_id'] = $this->form['textbook_series_id'] ?? null;
  333. $updates['meta'] = $meta;
  334. Log::info('MarkdownImportWorkbench saving paper', [
  335. 'import_id' => $this->importId,
  336. 'paper_id' => $paper->id,
  337. 'paper_title' => $paper->title,
  338. 'catalog_node_ids' => $catalogNodeIds,
  339. 'textbook_id' => $updates['textbook_id'] ?? null,
  340. 'series_id' => $updates['series_id'] ?? null,
  341. ]);
  342. $paper->update($updates);
  343. Log::info('MarkdownImportWorkbench saved paper', [
  344. 'import_id' => $this->importId,
  345. 'paper_id' => $paper->id,
  346. 'meta_catalog_node_ids' => $meta['catalog_node_ids'] ?? [],
  347. ]);
  348. if (count($selectedIds) > 1) {
  349. $otherIds = array_values(array_diff($selectedIds, [$paper->id]));
  350. if (!empty($otherIds)) {
  351. $this->seedBatchFromCurrent();
  352. $this->applyBatchToIds($otherIds);
  353. Log::info('MarkdownImportWorkbench batch saved papers', [
  354. 'import_id' => $this->importId,
  355. 'paper_ids' => $otherIds,
  356. 'catalog_node_ids' => $this->normalizeCatalogNodeIds($this->batch['catalog_node_ids'] ?? []),
  357. ]);
  358. }
  359. }
  360. $this->selectedIds = [];
  361. if (!$silent) {
  362. Notification::make()
  363. ->title('保存成功')
  364. ->success()
  365. ->send();
  366. }
  367. }
  368. public function applyBatch(): void
  369. {
  370. if (empty($this->selectedIds)) {
  371. return;
  372. }
  373. $this->applyBatchToIds($this->selectedIds);
  374. }
  375. public function autoInfer(): void
  376. {
  377. $paper = $this->selectedPaper();
  378. if (!$paper) {
  379. return;
  380. }
  381. $parsed = $this->parseImportFilename();
  382. if (!empty($parsed)) {
  383. // 关键:推断出的系列必须经过正式化 ID 锁定,否则无法触发联动
  384. if (empty($this->form['textbook_series_id']) && !empty($parsed['series'])) {
  385. $formal = $this->inferenceService()->resolveSeries($parsed['series']);
  386. if ($formal) {
  387. $this->form['textbook_series_id'] = $formal->id;
  388. $this->form['textbook_series'] = $formal->name;
  389. } else {
  390. $this->form['textbook_series'] = $parsed['series'];
  391. }
  392. }
  393. $this->form['grade'] = $this->form['grade'] ?: $parsed['grade'];
  394. $this->form['term'] = $this->form['term'] ?: $parsed['term'];
  395. $this->form['source_name'] = $this->form['source_name'] ?: $parsed['name'];
  396. }
  397. $title = (string) ($paper->title ?? $paper->full_title ?? '');
  398. $raw = (string) ($paper->raw_markdown ?? '');
  399. $context = $title . ' ' . $raw;
  400. $this->form['term'] = $this->inferenceService()->inferTerm($context) ?? $this->form['term'];
  401. $this->form['grade'] = $this->inferenceService()->inferGrade($context) ?? $this->form['grade'];
  402. $this->form['chapter'] = $this->inferenceService()->inferChapter($context) ?? $this->form['chapter'];
  403. $this->form['source_type'] = $this->inferenceService()->inferSourceType($context) ?? $this->form['source_type'];
  404. if (empty($this->form['catalog_node_ids'])) {
  405. $matchedCatalog = $this->inferenceService()->matchCatalogNodeId($context, $this->form['textbook_id']);
  406. if ($matchedCatalog) {
  407. $this->form['catalog_node_ids'] = [$matchedCatalog];
  408. }
  409. }
  410. // 执行推断后,立即触发教材重新关联和保存,确保“一环扣一环”
  411. $this->reInferTextbook();
  412. $this->savePaper();
  413. }
  414. public function autoBundleKey(): void
  415. {
  416. $paper = $this->selectedPaper();
  417. if (!$paper) {
  418. return;
  419. }
  420. $this->form['bundle_key'] = $this->buildBundleKey($paper);
  421. }
  422. public function autoBundleKeySelected(): void
  423. {
  424. if (empty($this->selectedIds)) {
  425. return;
  426. }
  427. foreach (SourcePaper::query()->whereIn('id', $this->selectedIds)->get() as $paper) {
  428. $meta = $paper->meta ?? [];
  429. $meta['bundle_key'] = $this->buildBundleKey($paper);
  430. $paper->update(['meta' => $meta]);
  431. }
  432. }
  433. public function autoInferSelected(): void
  434. {
  435. if (empty($this->selectedIds)) {
  436. return;
  437. }
  438. $parsed = $this->parseImportFilename();
  439. foreach (SourcePaper::query()->whereIn('id', $this->selectedIds)->get() as $paper) {
  440. $context = (string) ($paper->title ?? $paper->full_title ?? '') . ' ' . (string) ($paper->raw_markdown ?? '');
  441. $updates = array_filter([
  442. 'term' => $this->inferenceService()->inferTerm($context),
  443. 'grade' => $this->inferenceService()->inferGrade($context),
  444. 'chapter' => $this->inferenceService()->inferChapter($context),
  445. ], fn ($value) => $value !== null && $value !== '');
  446. if (!empty($parsed)) {
  447. if (empty($paper->textbook_series) && !empty($parsed['series'])) {
  448. $updates['textbook_series'] = $parsed['series'];
  449. }
  450. if (empty($paper->grade) && !empty($parsed['grade'])) {
  451. $updates['grade'] = $parsed['grade'];
  452. }
  453. if (empty($paper->term) && !empty($parsed['term'])) {
  454. $updates['term'] = $parsed['term'];
  455. }
  456. }
  457. $meta = $paper->meta ?? [];
  458. if (!empty($parsed['name']) && empty($meta['source_name'])) {
  459. $meta['source_name'] = $parsed['name'];
  460. }
  461. if (empty($meta['catalog_node_ids'])) {
  462. $candidateTextbookId = $updates['textbook_id'] ?? $paper->textbook_id ?? null;
  463. // 如果没有教材 ID,实时推断一个
  464. if (!$candidateTextbookId) {
  465. $best = $this->inferenceService()->findBestTextbook([
  466. 'series_id' => $updates['textbook_series_id'] ?? null,
  467. 'grade' => $updates['grade'] ?? $paper->grade,
  468. 'term' => $updates['term'] ?? $paper->term,
  469. ]);
  470. if ($best) {
  471. $candidateTextbookId = $best->id;
  472. $updates['textbook_id'] = $best->id;
  473. }
  474. }
  475. $matchedCatalog = $this->inferenceService()->matchCatalogNodeId($context, $candidateTextbookId);
  476. if ($matchedCatalog) {
  477. $meta['catalog_node_ids'] = [$matchedCatalog];
  478. $meta['catalog_node_id'] = $matchedCatalog;
  479. }
  480. }
  481. if (!empty($updates)) {
  482. $updates['meta'] = $meta;
  483. $paper->update($updates);
  484. } elseif (!empty($meta)) {
  485. $paper->update(['meta' => $meta]);
  486. }
  487. }
  488. }
  489. public function mergeSelectedPapers(): void
  490. {
  491. if (count($this->selectedIds) < 2) {
  492. $this->dispatch('notify', ['type' => 'warning', 'message' => '请至少选择两套卷子进行合并']);
  493. return;
  494. }
  495. $papers = SourcePaper::query()
  496. ->whereIn('id', $this->selectedIds)
  497. ->orderBy('order')
  498. ->get();
  499. $target = $papers->shift(); // 第一套作为目标
  500. DB::transaction(function () use ($target, $papers) {
  501. foreach ($papers as $source) {
  502. // 1. 移动所有候选题目
  503. DB::table('pre_question_candidates')
  504. ->where('source_paper_id', $source->id)
  505. ->update(['source_paper_id' => $target->id]);
  506. // 2. 移动所有 PaperPart (如果需要)
  507. DB::table('paper_parts')
  508. ->where('source_paper_id', $source->id)
  509. ->update(['source_paper_id' => $target->id]);
  510. // 3. 追加 Markdown 内容 (可选,但有助于保持记录完整)
  511. $target->raw_markdown .= "\n\n" . $source->raw_markdown;
  512. // 4. 删除原卷子
  513. $source->delete();
  514. }
  515. $target->save();
  516. });
  517. $this->selectedIds = [];
  518. $this->selectPaper($target->id);
  519. Notification::make()
  520. ->title('卷子合并成功')
  521. ->success()
  522. ->send();
  523. }
  524. public function selectAllVisible(): void
  525. {
  526. $this->selectedIds = $this->papers()->pluck('id')->toArray();
  527. }
  528. public function clearSelection(): void
  529. {
  530. $this->selectedIds = [];
  531. }
  532. public function gradeOptions(): array
  533. {
  534. return collect(range(1, 12))->mapWithKeys(fn ($grade) => [$grade => $grade . '年级'])->toArray();
  535. }
  536. public function termOptions(): array
  537. {
  538. return [
  539. '上册' => '上册',
  540. '下册' => '下册',
  541. '上学期' => '上学期',
  542. '下学期' => '下学期',
  543. ];
  544. }
  545. public function sourceTypeOptions(): array
  546. {
  547. return [
  548. '期中' => '期中卷',
  549. '期末' => '期末卷',
  550. '单元卷' => '单元卷',
  551. '专项卷' => '专项卷',
  552. '教材' => '教材',
  553. '其他' => '其他',
  554. ];
  555. }
  556. public function textbookOptions(): array
  557. {
  558. $query = Textbook::query();
  559. // 核心联动:基于 series_id 进行物理过滤
  560. $seriesId = $this->form['textbook_series_id'] ?? null;
  561. if ($seriesId) {
  562. $query->where('series_id', $seriesId);
  563. }
  564. return $query->orderBy('id')
  565. ->get(['id', 'official_title'])
  566. ->mapWithKeys(function ($textbook) {
  567. $title = $textbook->official_title ?: '未命名教材';
  568. return [$textbook->id => $title];
  569. })
  570. ->toArray();
  571. }
  572. public function seriesOptions(): array
  573. {
  574. return \App\Models\TextbookSeries::query()
  575. ->orderBy('sort_order')
  576. ->pluck('name', 'id')
  577. ->toArray();
  578. }
  579. public function catalogOptions(?int $textbookId = null): array
  580. {
  581. $textbookId ??= $this->form['textbook_id'] ?? null;
  582. if (empty($textbookId)) {
  583. return [];
  584. }
  585. $nodes = TextbookCatalog::query()
  586. ->where('textbook_id', $textbookId)
  587. ->orderBy('sort_order')
  588. ->get(['id', 'title', 'parent_id']);
  589. $grouped = $nodes->groupBy('parent_id');
  590. $walk = function ($parent, int $depth) use (&$walk, $grouped): array {
  591. $items = [];
  592. foreach ($grouped->get($parent, collect()) as $node) {
  593. $title = $node->title ?: '未命名目录';
  594. $indent = str_repeat('—', $depth);
  595. $items[$node->id] = trim($indent . ' ' . $title);
  596. $items += $walk($node->id, $depth + 1);
  597. }
  598. return $items;
  599. };
  600. return $walk(null, 0);
  601. }
  602. public function catalogCoverageSummary(): array
  603. {
  604. $textbookId = $this->selectedTextbookId();
  605. if (!$textbookId) {
  606. return [];
  607. }
  608. $nodes = TextbookCatalog::query()
  609. ->where('textbook_id', $textbookId)
  610. ->whereDoesntHave('children')
  611. ->get(['id']);
  612. $coverage = [];
  613. SourcePaper::query()
  614. ->where('textbook_id', $textbookId)
  615. ->get(['meta'])
  616. ->each(function ($paper) use (&$coverage) {
  617. $ids = $paper->meta['catalog_node_ids'] ?? (isset($paper->meta['catalog_node_id']) ? [$paper->meta['catalog_node_id']] : []);
  618. foreach ((array) $ids as $id) {
  619. if ($id) {
  620. $coverage[$id] = ($coverage[$id] ?? 0) + 1;
  621. }
  622. }
  623. });
  624. $linked = count($coverage);
  625. $missing = max(0, $nodes->count() - count($coverage));
  626. return [
  627. 'total' => $nodes->count(),
  628. 'linked' => $linked,
  629. 'missing' => $missing,
  630. ];
  631. }
  632. public function missingCatalogNodes(): array
  633. {
  634. $textbookId = $this->selectedTextbookId();
  635. if (!$textbookId) {
  636. return [];
  637. }
  638. $nodes = TextbookCatalog::query()
  639. ->where('textbook_id', $textbookId)
  640. ->whereDoesntHave('children')
  641. ->orderBy('sort_order')
  642. ->get(['id', 'title']);
  643. $coverage = [];
  644. SourcePaper::query()
  645. ->where('textbook_id', $textbookId)
  646. ->get(['meta'])
  647. ->each(function ($paper) use (&$coverage) {
  648. $ids = $paper->meta['catalog_node_ids'] ?? (isset($paper->meta['catalog_node_id']) ? [$paper->meta['catalog_node_id']] : []);
  649. foreach ((array) $ids as $id) {
  650. if ($id) {
  651. $coverage[$id] = ($coverage[$id] ?? 0) + 1;
  652. }
  653. }
  654. });
  655. $missing = [];
  656. foreach ($nodes as $node) {
  657. if (!isset($coverage[$node->id])) {
  658. $missing[] = [
  659. 'id' => $node->id,
  660. 'title' => $node->title,
  661. ];
  662. }
  663. }
  664. return array_slice($missing, 0, 8);
  665. }
  666. public function catalogTitlesForPaper(SourcePaper|array $paper): array
  667. {
  668. $meta = $paper instanceof SourcePaper ? ($paper->meta ?? []) : ($paper['meta'] ?? []);
  669. $textbookId = $paper instanceof SourcePaper ? $paper->textbook_id : ($paper['textbook_id'] ?? null);
  670. if (empty($textbookId)) {
  671. return [];
  672. }
  673. $ids = $this->normalizeCatalogNodeIds($meta['catalog_node_ids'] ?? ($meta['catalog_node_id'] ?? []));
  674. if (empty($ids)) {
  675. return [];
  676. }
  677. $missing = array_values(array_diff($ids, array_keys($this->catalogNodeCache)));
  678. if (!empty($missing)) {
  679. TextbookCatalog::query()
  680. ->whereIn('id', $missing)
  681. ->get(['id', 'title'])
  682. ->each(function ($node) {
  683. $this->catalogNodeCache[(int) $node->id] = $node->title ?: ('目录 #' . $node->id);
  684. });
  685. }
  686. return array_values(array_filter(array_map(fn ($id) => $this->catalogNodeCache[$id] ?? null, $ids)));
  687. }
  688. public function textbookSuggestions(): array
  689. {
  690. $paper = $this->selectedPaper();
  691. if (!$paper) {
  692. return [];
  693. }
  694. $title = (string) ($paper->title ?? $paper->full_title ?? '');
  695. $context = mb_strtolower($title);
  696. $parsed = $this->parseImportFilename();
  697. $grade = $paper->grade ? (int) $paper->grade : ($parsed['grade'] ?? null);
  698. $semester = $this->termToSemester($paper->term) ?? $this->termToSemester($parsed['term'] ?? null);
  699. $seriesHint = $paper->textbook_series ?: ($parsed['series'] ?? null);
  700. $subjectHint = $parsed['subject'] ?? null;
  701. return $this->inferenceService()->getTextbookSuggestions($paper, $parsed);
  702. }
  703. public function catalogSuggestions(): array
  704. {
  705. $paper = $this->selectedPaper();
  706. $textbookId = $paper?->textbook_id ?? ($this->form['textbook_id'] ?? null);
  707. if (!$paper || !$textbookId) {
  708. return [];
  709. }
  710. $needle = trim((string) ($paper->chapter ?? $paper->title ?? ''));
  711. if ($needle === '') {
  712. return [];
  713. }
  714. $nodes = TextbookCatalog::query()
  715. ->where('textbook_id', $textbookId)
  716. ->orderBy('sort_order')
  717. ->get(['id', 'title']);
  718. $matches = [];
  719. foreach ($nodes as $node) {
  720. $title = (string) $node->title;
  721. if ($title !== '' && str_contains($needle, $title)) {
  722. $matches[] = ['id' => $node->id, 'title' => $title];
  723. }
  724. }
  725. return array_slice($matches, 0, 5);
  726. }
  727. public function applyTextbookSuggestion(int $textbookId): void
  728. {
  729. $textbook = Textbook::query()->with('series')->find($textbookId);
  730. if (!$textbook) {
  731. return;
  732. }
  733. $this->form['textbook_id'] = $textbook->id;
  734. $this->form['textbook_series'] = $textbook->series?->name ?? $this->form['textbook_series'];
  735. }
  736. public function applyCatalogSuggestion(int $catalogId): void
  737. {
  738. $ids = $this->form['catalog_node_ids'] ?? [];
  739. if (!in_array($catalogId, $ids)) {
  740. $ids[] = $catalogId;
  741. $this->form['catalog_node_ids'] = $ids;
  742. $this->savePaper();
  743. }
  744. }
  745. public function candidateCountFor(SourcePaper $paper): int
  746. {
  747. return (int) ($paper->candidates_count ?? 0);
  748. }
  749. public function checkCompleteness(): array
  750. {
  751. $paper = $this->selectedPaper();
  752. if (!$paper) {
  753. return [];
  754. }
  755. $candidates = $paper->candidates()
  756. ->where('is_question_candidate', true)
  757. ->get();
  758. $service = app(QuestionCandidateToQuestionService::class);
  759. $total = $candidates->count();
  760. $invalid = 0;
  761. $issueStats = [];
  762. foreach ($candidates as $candidate) {
  763. $errors = $service->validateCandidate($candidate);
  764. if (!empty($errors)) {
  765. $invalid++;
  766. foreach ($errors as $error) {
  767. $issueStats[$error] = ($issueStats[$error] ?? 0) + 1;
  768. }
  769. }
  770. }
  771. return [
  772. 'total' => $total,
  773. 'valid' => $total - $invalid,
  774. 'invalid' => $invalid,
  775. 'issues' => $issueStats,
  776. 'is_ready' => $total > 0 && $invalid === 0,
  777. ];
  778. }
  779. private function explodeTags(string $tags): array
  780. {
  781. return array_values(array_filter(array_map('trim', explode(',', $tags))));
  782. }
  783. private function termToSemester(?string $term): ?int
  784. {
  785. return $this->inferenceService()->termToSemester($term);
  786. }
  787. private function buildBundleKey(SourcePaper $paper): string
  788. {
  789. $import = $this->importRecord();
  790. $base = $import?->file_name
  791. ? pathinfo($import->file_name, PATHINFO_FILENAME)
  792. : ($paper->title ?: $paper->full_title ?: '卷子');
  793. $grade = $paper->grade ? $this->gradeToLabel((int) $paper->grade) : null;
  794. $term = $paper->term ? $paper->term : null;
  795. $sourceType = $paper->source_type ?: null;
  796. $parts = array_filter([$grade, $term, $sourceType, $base]);
  797. return implode('·', $parts);
  798. }
  799. private function gradeToLabel(int $grade): string
  800. {
  801. $map = [
  802. 7 => '七年级',
  803. 8 => '八年级',
  804. 9 => '九年级',
  805. 10 => '高一',
  806. 11 => '高二',
  807. 12 => '高三',
  808. ];
  809. return $map[$grade] ?? $grade . '年级';
  810. }
  811. private function selectedTextbookId(): ?int
  812. {
  813. return $this->form['textbook_id'] ?: $this->batch['textbook_id'];
  814. }
  815. private function applyFilenameDefaults(): void
  816. {
  817. if (!$this->importId || empty($this->filenameParsed)) {
  818. return;
  819. }
  820. $parsed = $this->filenameParsed;
  821. $resolvedTextbook = $this->inferenceService()->resolveTextbookFromFilename($parsed);
  822. $papers = SourcePaper::query()
  823. ->whereHas('candidates', fn ($q) => $q->where('import_id', $this->importId))
  824. ->get();
  825. foreach ($papers as $paper) {
  826. $meta = $paper->meta ?? [];
  827. $hasNumericGrade = is_numeric((string) $paper->grade);
  828. $hasAllDefaults = !empty($paper->textbook_id)
  829. && !empty($paper->textbook_series)
  830. && $hasNumericGrade
  831. && !empty($paper->term);
  832. if (!empty($meta['filename_defaults_applied']) && $hasAllDefaults) {
  833. continue;
  834. }
  835. $updates = [];
  836. if (empty($paper->textbook_series) && !empty($parsed['series'])) {
  837. $formal = $this->inferenceService()->resolveSeries($parsed['series']);
  838. $updates['textbook_series_id'] = $formal ? $formal->id : null;
  839. $updates['textbook_series'] = $formal ? $formal->name : $parsed['series'];
  840. }
  841. if (!empty($parsed['grade']) && empty($paper->grade)) {
  842. $updates['grade'] = $parsed['grade'];
  843. }
  844. if (!empty($parsed['term']) && empty($paper->term)) {
  845. $updates['term'] = $parsed['term'];
  846. }
  847. // 在应用文件名默认值时,也触发一次教材重定向
  848. if (empty($paper->textbook_id)) {
  849. $best = $this->inferenceService()->findBestTextbook([
  850. 'series_id' => $updates['textbook_series_id'] ?? null,
  851. 'textbook_series' => $updates['textbook_series'] ?? $paper->textbook_series,
  852. 'grade' => $updates['grade'] ?? $paper->grade,
  853. 'term' => $updates['term'] ?? $paper->term,
  854. ]);
  855. if ($best) {
  856. $updates['textbook_id'] = $best->id;
  857. }
  858. }
  859. if (empty($meta['source_name']) && !empty($parsed['name'])) {
  860. $meta['source_name'] = $parsed['name'];
  861. }
  862. if (!empty($updates)) {
  863. $meta['filename_defaults_applied'] = true;
  864. $updates['meta'] = $meta;
  865. $paper->update($updates);
  866. }
  867. }
  868. }
  869. private function parseImportFilename(): array
  870. {
  871. $import = $this->importRecord();
  872. return $import?->parseFilename() ?? [];
  873. }
  874. private function normalizeGradeForForm($grade, array $parsed): ?int
  875. {
  876. if (is_numeric($grade)) {
  877. return (int) $grade;
  878. }
  879. $map = [
  880. '七年级' => 7,
  881. '八年级' => 8,
  882. '九年级' => 9,
  883. '高一' => 10,
  884. '高二' => 11,
  885. '高三' => 12,
  886. ];
  887. $grade = is_string($grade) ? trim($grade) : '';
  888. if ($grade !== '' && isset($map[$grade])) {
  889. return $map[$grade];
  890. }
  891. $parsedGrade = $parsed['grade'] ?? null;
  892. return is_numeric($parsedGrade) ? (int) $parsedGrade : null;
  893. }
  894. private function normalizeCatalogNodeIds($value): array
  895. {
  896. $ids = is_array($value) ? $value : [$value];
  897. $ids = array_values(array_unique(array_map('intval', array_filter($ids))));
  898. return $ids;
  899. }
  900. private function filterCatalogNodeIdsForTextbook(?int $textbookId, array $ids): array
  901. {
  902. $ids = $this->normalizeCatalogNodeIds($ids);
  903. if (empty($textbookId) || empty($ids)) {
  904. return $ids;
  905. }
  906. $validIds = TextbookCatalog::query()
  907. ->where('textbook_id', $textbookId)
  908. ->whereIn('id', $ids)
  909. ->pluck('id')
  910. ->map(fn ($id) => (int) $id)
  911. ->toArray();
  912. return $validIds;
  913. }
  914. private function applyBatchToIds(array $ids): void
  915. {
  916. $targetIds = array_values(array_filter(array_unique(array_map('intval', $ids))));
  917. if (empty($targetIds)) {
  918. return;
  919. }
  920. $updates = array_filter([
  921. 'edition' => $this->batch['edition'] ?? null,
  922. 'grade' => $this->batch['grade'] ?? null,
  923. 'term' => $this->batch['term'] ?? null,
  924. 'chapter' => $this->batch['chapter'] ?? null,
  925. 'source_type' => $this->batch['source_type'] ?? null,
  926. 'source_year' => $this->batch['source_year'] ?? null,
  927. 'textbook_id' => $this->batch['textbook_id'] ?? null,
  928. 'series_id' => $this->batch['textbook_series_id'] ?? null,
  929. ], fn ($value) => $value !== null && $value !== '');
  930. foreach (SourcePaper::query()->whereIn('id', $targetIds)->get() as $paper) {
  931. $meta = $paper->meta ?? [];
  932. if (!empty($this->batch['source_name'])) {
  933. $meta['source_name'] = $this->batch['source_name'];
  934. }
  935. if (!empty($this->batch['source_page'])) {
  936. $meta['source_page'] = $this->batch['source_page'];
  937. }
  938. if (!empty($this->batch['tags'])) {
  939. $meta['tags'] = $this->explodeTags($this->batch['tags']);
  940. }
  941. if (!empty($this->batch['bundle_key'])) {
  942. $meta['bundle_key'] = $this->batch['bundle_key'];
  943. }
  944. if (!empty($this->batch['expected_count'])) {
  945. $meta['expected_count'] = $this->batch['expected_count'];
  946. }
  947. if (!empty($this->batch['catalog_node_ids'])) {
  948. $catalogNodeIds = $this->normalizeCatalogNodeIds($this->batch['catalog_node_ids']);
  949. $catalogNodeIds = $this->filterCatalogNodeIdsForTextbook($updates['textbook_id'] ?? $paper->textbook_id, $catalogNodeIds);
  950. $meta['catalog_node_ids'] = $catalogNodeIds;
  951. $meta['catalog_node_id'] = $catalogNodeIds[0] ?? null;
  952. }
  953. $paper->update(array_merge($updates, ['meta' => $meta]));
  954. }
  955. }
  956. private function isBatchEmpty(): bool
  957. {
  958. $fields = [
  959. 'edition', 'grade', 'term', 'chapter', 'source_type',
  960. 'source_year', 'textbook_id', 'textbook_series_id',
  961. 'source_name', 'source_page', 'tags', 'bundle_key',
  962. 'expected_count',
  963. ];
  964. foreach ($fields as $field) {
  965. if (!empty($this->batch[$field])) {
  966. return false;
  967. }
  968. }
  969. return empty($this->batch['catalog_node_ids']);
  970. }
  971. }