| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145 |
- <?php
- namespace App\Filament\Pages;
- use App\Models\MarkdownImport;
- use App\Models\SourcePaper;
- use App\Models\Textbook;
- use App\Models\TextbookCatalog;
- use App\Services\ImportInferenceService;
- use App\Models\TextbookSeries;
- use Filament\Pages\Page;
- use Illuminate\Support\Arr;
- use Illuminate\Support\Facades\DB;
- use Filament\Notifications\Notification;
- use Illuminate\Support\Facades\Log;
- class MarkdownImportWorkbench extends Page
- {
- protected static ?string $navigationLabel = '导入工作台';
- protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
- protected static string|\UnitEnum|null $navigationGroup = '卷子导入流程';
- protected static ?int $navigationSort = 2;
- protected string $view = 'filament.pages.markdown-import-workbench';
- protected ?ImportInferenceService $inferenceService = null;
- protected function inferenceService(): ImportInferenceService
- {
- return $this->inferenceService ??= app(ImportInferenceService::class);
- }
- public ?int $importId = null;
- public ?int $selectedPaperId = null;
- public array $selectedIds = [];
- public string $search = '';
- public string $groupBy = 'paper';
- public bool $dense = false;
- public bool $filenameValid = true;
- public array $filenameParsed = [];
- public ?string $filenameWarning = null;
- public array $form = [
- 'title' => null,
- 'edition' => null,
- 'grade' => null,
- 'term' => null,
- 'chapter' => null,
- 'source_type' => null,
- 'source_year' => null,
- 'textbook_id' => null,
- 'textbook_series' => null,
- 'textbook_series_id' => null,
- 'source_name' => null,
- 'source_page' => null,
- 'subject' => '数学', // 默认学科
- 'tags' => '',
- 'bundle_key' => null,
- 'expected_count' => null,
- 'catalog_node_ids' => [],
- ];
- public array $batch = [
- 'edition' => null,
- 'grade' => null,
- 'term' => null,
- 'chapter' => null,
- 'source_type' => null,
- 'source_year' => null,
- 'textbook_id' => null,
- 'textbook_series' => null,
- 'source_name' => null,
- 'source_page' => null,
- 'tags' => '',
- 'bundle_key' => null,
- 'expected_count' => null,
- 'catalog_node_ids' => [],
- ];
- private array $catalogNodeCache = [];
- public function mount(): void
- {
- $this->importId = request()->integer('import_id');
- $parsed = $this->parseImportFilename();
- if (empty($parsed)) {
- $this->filenameValid = false;
- $this->filenameWarning = '文件名不符合规范:系列_年级_学期_学科_名称(例:北师大版_7_1_数学_...)';
- } else {
- $this->filenameParsed = $parsed;
- $this->applyFilenameDefaults();
- }
- $first = $this->papers()->first();
- if ($first) {
- $this->selectPaper($first->id);
- }
- }
- public function importRecord(): ?MarkdownImport
- {
- return $this->importId ? MarkdownImport::query()->find($this->importId) : null;
- }
- public function papers()
- {
- if (!$this->importId) {
- return collect();
- }
- $query = SourcePaper::query()
- ->whereHas('candidates', fn ($q) => $q->where('import_id', $this->importId))
- ->withCount(['candidates', 'parts'])
- ->orderBy('order');
- if ($this->search !== '') {
- $query->where(function ($q) {
- $q->where('title', 'like', '%' . $this->search . '%')
- ->orWhere('full_title', 'like', '%' . $this->search . '%')
- ->orWhere('paper_code', 'like', '%' . $this->search . '%');
- });
- }
- return $query->get();
- }
- public function groupedPapers(): array
- {
- $papers = $this->papers();
- if ($this->groupBy === 'paper') {
- return $papers->groupBy(fn ($paper) => $paper->title ?: $paper->full_title ?: '未命名卷子')->toArray();
- }
- if ($this->groupBy === 'grade') {
- return $papers->groupBy(fn ($paper) => $paper->grade ? $paper->grade . '年级' : '未标注年级')->toArray();
- }
- return $papers->groupBy(fn ($paper) => Arr::get($paper->meta ?? [], 'bundle_key', '未归并套卷'))->toArray();
- }
- public function selectedPaper(): ?SourcePaper
- {
- return $this->selectedPaperId
- ? SourcePaper::query()->withCount(['candidates', 'parts'])->find($this->selectedPaperId)
- : null;
- }
- public function selectPaper(int $paperId): void
- {
- $paper = SourcePaper::query()->find($paperId);
- if (!$paper) {
- return;
- }
- Log::info('MarkdownImportWorkbench select paper', [
- 'import_id' => $this->importId,
- 'paper_id' => $paperId,
- 'paper_title' => $paper->title,
- 'selected_ids' => $this->selectedIds,
- ]);
- $parsed = $this->parseImportFilename();
- $this->selectedPaperId = $paperId;
- $meta = $paper->meta ?? [];
- // 核心修正:直接基于物理 series_id 初始化,如果没有则尝试从文件名或标题解析
- $finalSeriesId = $paper->series_id;
- $finalSeriesName = null;
- if (!$finalSeriesId) {
- // 兜底1:从文件名解析
- if (!empty($parsed['series'])) {
- $formalSeries = $this->inferenceService()->resolveSeries($parsed['series']);
- $finalSeriesId = $formalSeries?->id;
- $finalSeriesName = $formalSeries ? $formalSeries->name : $parsed['series'];
- }
-
- // 兜底2:从标题模糊推断 (应对数据库数据未对齐的情况)
- if (!$finalSeriesId) {
- $title = $paper->full_title ?: $paper->title;
- $formalSeries = $this->inferenceService()->resolveSeries($title);
- $finalSeriesId = $formalSeries?->id;
- $finalSeriesName = $formalSeries?->name;
- }
- }
- if ($finalSeriesId && !$finalSeriesName) {
- $finalSeriesName = TextbookSeries::find($finalSeriesId)?->name;
- }
- $resolvedTextbook = null;
- if (!$paper->textbook_id && $finalSeriesId) {
- $resolvedTextbook = $this->inferenceService()->findBestTextbook([
- 'series_id' => $finalSeriesId,
- 'grade' => $paper->grade ?: ($parsed['grade'] ?? null),
- 'term' => $paper->term ?: ($parsed['term'] ?? null),
- ]);
- }
- $initialCatalogIds = (array) (Arr::get($meta, 'catalog_node_ids') ?: (Arr::get($meta, 'catalog_node_id') ? [Arr::get($meta, 'catalog_node_id')] : []));
- $filteredCatalogIds = $this->filterCatalogNodeIdsForTextbook($paper->textbook_id ?: ($resolvedTextbook?->id ?? null), $initialCatalogIds);
- if ($initialCatalogIds !== $filteredCatalogIds && !empty($initialCatalogIds)) {
- Log::warning('MarkdownImportWorkbench catalog nodes not in textbook', [
- 'paper_id' => $paper->id,
- 'paper_title' => $paper->title,
- 'textbook_id' => $paper->textbook_id,
- 'original_catalog_node_ids' => $initialCatalogIds,
- 'filtered_catalog_node_ids' => $filteredCatalogIds,
- ]);
- }
- $this->form = [
- 'title' => $paper->title,
- 'edition' => $paper->edition,
- 'grade' => $paper->grade ?: ($parsed['grade'] ?? null),
- 'term' => $paper->term ?: ($parsed['term'] ?? null),
- 'chapter' => $paper->chapter,
- 'source_type' => $paper->source_type,
- 'source_year' => $paper->source_year,
- 'textbook_id' => $paper->textbook_id ?: ($resolvedTextbook?->id ?? null),
- 'textbook_series' => $finalSeriesName,
- 'textbook_series_id' => $finalSeriesId,
- 'source_name' => Arr::get($meta, 'source_name') ?: ($parsed['name'] ?? null),
- 'source_page' => Arr::get($meta, 'source_page'),
- 'tags' => implode(',', Arr::get($meta, 'tags', [])),
- 'bundle_key' => Arr::get($meta, 'bundle_key'),
- 'expected_count' => Arr::get($meta, 'expected_count'),
- 'catalog_node_ids' => $filteredCatalogIds,
- ];
- }
- public function updatedSelectedIds(): void
- {
- if (empty($this->selectedIds) || $this->selectedPaperId === null) {
- return;
- }
- if ($this->isBatchEmpty()) {
- $this->seedBatchFromCurrent();
- }
- }
- public function updated($name, $value): void
- {
- if (!$this->selectedPaperId) {
- return;
- }
- }
- /**
- * 教材属性联动:当系列、年级、学期改变时,自动匹配最合适的教材
- */
- public function updatedFormGrade(): void { $this->reInferTextbook(); }
- public function updatedFormTerm(): void { $this->reInferTextbook(); }
- public function updatedFormTextbookSeriesId(): void
- {
- // 核心联动:系列 ID 变动,清空教材并重推
- $this->form['textbook_id'] = null;
- $this->form['catalog_node_ids'] = [];
-
- // 同时同步一下显示名称 (虽然逻辑以 ID 为准,但保留名称用于前端显示或保存)
- $series = TextbookSeries::find($this->form['textbook_series_id']);
- $this->form['textbook_series'] = $series?->name;
-
- $this->reInferTextbook();
- }
- protected function reInferTextbook(): void
- {
- if (!empty($this->form['textbook_id'])) {
- return;
- }
- $best = $this->inferenceService()->findBestTextbook($this->form);
- if ($best) {
- // 无论 ID 是否改变,都强制执行一次属性校准,确保“一环扣一环”
- $this->syncTextbookAttributes($best);
- }
- }
- protected function syncTextbookAttributes(Textbook $textbook): void
- {
- $this->form['textbook_id'] = $textbook->id;
- $this->form['grade'] = (string)$textbook->grade;
- $this->form['term'] = $this->semesterToTerm($textbook->semester);
- $this->form['textbook_series_id'] = $textbook->series_id;
-
- $series = $textbook->getRelation('series') ?: $textbook->series()->first();
- $seriesName = $textbook->track ?: ($series?->name ?? null);
- if ($seriesName) {
- $this->form['textbook_series'] = $seriesName;
- }
-
- $this->savePaper();
- }
- public function updatedFormTextbookId($value): void
- {
- if (!$this->selectedPaperId || !$value) {
- return;
- }
- // 权威源:从数据库获取该教材的所有官方属性
- $textbook = Textbook::query()->with('series')->find($value);
- if ($textbook) {
- $this->syncTextbookAttributes($textbook);
- }
- }
- protected function semesterToTerm(?int $semester): ?string
- {
- return match ($semester) {
- 1 => '上册',
- 2 => '下册',
- default => null,
- };
- }
- public function seedBatchFromCurrent(): void
- {
- $this->batch = [
- 'edition' => $this->form['edition'] ?? null,
- 'grade' => $this->form['grade'] ?? null,
- 'term' => $this->form['term'] ?? null,
- 'chapter' => $this->form['chapter'] ?? null,
- 'source_type' => $this->form['source_type'] ?? null,
- 'source_year' => $this->form['source_year'] ?? null,
- 'textbook_id' => $this->form['textbook_id'] ?? null,
- 'textbook_series_id' => $this->form['textbook_series_id'] ?? null,
- 'textbook_series' => $this->form['textbook_series'] ?? null,
- 'source_name' => $this->form['source_name'] ?? null,
- 'source_page' => $this->form['source_page'] ?? null,
- 'tags' => $this->form['tags'] ?? '',
- 'bundle_key' => $this->form['bundle_key'] ?? null,
- 'expected_count' => $this->form['expected_count'] ?? null,
- 'catalog_node_ids' => $this->form['catalog_node_ids'] ?? [],
- ];
- }
- public function savePaper(bool $silent = false): void
- {
- $paper = $this->selectedPaper();
- if (!$paper) {
- Log::warning('MarkdownImportWorkbench save failed: no paper selected', [
- 'import_id' => $this->importId,
- 'selected_paper_id' => $this->selectedPaperId,
- ]);
- return;
- }
- $selectedIds = array_values(array_filter(array_unique(array_map('intval', $this->selectedIds))));
- $meta = $paper->meta ?? [];
- $meta['source_name'] = $this->form['source_name'] ?? null;
- $meta['source_page'] = $this->form['source_page'] ?? null;
- $meta['tags'] = $this->explodeTags($this->form['tags'] ?? '');
- $meta['bundle_key'] = $this->form['bundle_key'] ?? null;
- $meta['expected_count'] = $this->form['expected_count'] ?? null;
-
- // 关键修正:确保目录 ID 是干净的整型数组,解决保存失效
- $catalogNodeIds = $this->normalizeCatalogNodeIds($this->form['catalog_node_ids'] ?? []);
- $catalogNodeIds = $this->filterCatalogNodeIdsForTextbook($updates['textbook_id'] ?? null, $catalogNodeIds);
- $meta['catalog_node_ids'] = $catalogNodeIds;
-
- // 同时保留旧字段供向后兼容
- $meta['catalog_node_id'] = !empty($meta['catalog_node_ids']) ? $meta['catalog_node_ids'][0] : null;
- // 核心同步:保存时根据 series_id 确保名称同步
- if (!empty($this->form['textbook_series_id'])) {
- $series = TextbookSeries::find($this->form['textbook_series_id']);
- if ($series) {
- $this->form['textbook_series'] = $series->name;
- }
- }
- $fields = [
- 'title', 'edition', 'grade', 'term', 'chapter', 'source_type',
- 'source_year', 'textbook_id'
- ];
-
- $updates = [];
- foreach ($fields as $field) {
- $value = $this->form[$field] ?? null;
- $updates[$field] = ($value === '' ? null : $value);
- }
- $updates['series_id'] = $this->form['textbook_series_id'] ?? null;
- $updates['meta'] = $meta;
- Log::info('MarkdownImportWorkbench saving paper', [
- 'import_id' => $this->importId,
- 'paper_id' => $paper->id,
- 'paper_title' => $paper->title,
- 'catalog_node_ids' => $catalogNodeIds,
- 'textbook_id' => $updates['textbook_id'] ?? null,
- 'series_id' => $updates['series_id'] ?? null,
- ]);
- $paper->update($updates);
- Log::info('MarkdownImportWorkbench saved paper', [
- 'import_id' => $this->importId,
- 'paper_id' => $paper->id,
- 'meta_catalog_node_ids' => $meta['catalog_node_ids'] ?? [],
- ]);
- if (count($selectedIds) > 1) {
- $otherIds = array_values(array_diff($selectedIds, [$paper->id]));
- if (!empty($otherIds)) {
- $this->seedBatchFromCurrent();
- $this->applyBatchToIds($otherIds);
- Log::info('MarkdownImportWorkbench batch saved papers', [
- 'import_id' => $this->importId,
- 'paper_ids' => $otherIds,
- 'catalog_node_ids' => $this->normalizeCatalogNodeIds($this->batch['catalog_node_ids'] ?? []),
- ]);
- }
- }
- $this->selectedIds = [];
- if (!$silent) {
- Notification::make()
- ->title('保存成功')
- ->success()
- ->send();
- }
- }
- public function applyBatch(): void
- {
- if (empty($this->selectedIds)) {
- return;
- }
- $this->applyBatchToIds($this->selectedIds);
- }
- public function autoInfer(): void
- {
- $paper = $this->selectedPaper();
- if (!$paper) {
- return;
- }
- $parsed = $this->parseImportFilename();
- if (!empty($parsed)) {
- // 关键:推断出的系列必须经过正式化 ID 锁定,否则无法触发联动
- if (empty($this->form['textbook_series_id']) && !empty($parsed['series'])) {
- $formal = $this->inferenceService()->resolveSeries($parsed['series']);
- if ($formal) {
- $this->form['textbook_series_id'] = $formal->id;
- $this->form['textbook_series'] = $formal->name;
- } else {
- $this->form['textbook_series'] = $parsed['series'];
- }
- }
-
- $this->form['grade'] = $this->form['grade'] ?: $parsed['grade'];
- $this->form['term'] = $this->form['term'] ?: $parsed['term'];
- $this->form['source_name'] = $this->form['source_name'] ?: $parsed['name'];
- }
- $title = (string) ($paper->title ?? $paper->full_title ?? '');
- $raw = (string) ($paper->raw_markdown ?? '');
- $context = $title . ' ' . $raw;
- $this->form['term'] = $this->inferenceService()->inferTerm($context) ?? $this->form['term'];
- $this->form['grade'] = $this->inferenceService()->inferGrade($context) ?? $this->form['grade'];
- $this->form['chapter'] = $this->inferenceService()->inferChapter($context) ?? $this->form['chapter'];
- $this->form['source_type'] = $this->inferenceService()->inferSourceType($context) ?? $this->form['source_type'];
- if (empty($this->form['catalog_node_ids'])) {
- $matchedCatalog = $this->inferenceService()->matchCatalogNodeId($context, $this->form['textbook_id']);
- if ($matchedCatalog) {
- $this->form['catalog_node_ids'] = [$matchedCatalog];
- }
- }
- // 执行推断后,立即触发教材重新关联和保存,确保“一环扣一环”
- $this->reInferTextbook();
- $this->savePaper();
- }
- public function autoBundleKey(): void
- {
- $paper = $this->selectedPaper();
- if (!$paper) {
- return;
- }
- $this->form['bundle_key'] = $this->buildBundleKey($paper);
- }
- public function autoBundleKeySelected(): void
- {
- if (empty($this->selectedIds)) {
- return;
- }
- foreach (SourcePaper::query()->whereIn('id', $this->selectedIds)->get() as $paper) {
- $meta = $paper->meta ?? [];
- $meta['bundle_key'] = $this->buildBundleKey($paper);
- $paper->update(['meta' => $meta]);
- }
- }
- public function autoInferSelected(): void
- {
- if (empty($this->selectedIds)) {
- return;
- }
- $parsed = $this->parseImportFilename();
- foreach (SourcePaper::query()->whereIn('id', $this->selectedIds)->get() as $paper) {
- $context = (string) ($paper->title ?? $paper->full_title ?? '') . ' ' . (string) ($paper->raw_markdown ?? '');
- $updates = array_filter([
- 'term' => $this->inferenceService()->inferTerm($context),
- 'grade' => $this->inferenceService()->inferGrade($context),
- 'chapter' => $this->inferenceService()->inferChapter($context),
- ], fn ($value) => $value !== null && $value !== '');
- if (!empty($parsed)) {
- if (empty($paper->textbook_series) && !empty($parsed['series'])) {
- $updates['textbook_series'] = $parsed['series'];
- }
- if (empty($paper->grade) && !empty($parsed['grade'])) {
- $updates['grade'] = $parsed['grade'];
- }
- if (empty($paper->term) && !empty($parsed['term'])) {
- $updates['term'] = $parsed['term'];
- }
- }
- $meta = $paper->meta ?? [];
- if (!empty($parsed['name']) && empty($meta['source_name'])) {
- $meta['source_name'] = $parsed['name'];
- }
- if (empty($meta['catalog_node_ids'])) {
- $candidateTextbookId = $updates['textbook_id'] ?? $paper->textbook_id ?? null;
-
- // 如果没有教材 ID,实时推断一个
- if (!$candidateTextbookId) {
- $best = $this->inferenceService()->findBestTextbook([
- 'series_id' => $updates['textbook_series_id'] ?? null,
- 'grade' => $updates['grade'] ?? $paper->grade,
- 'term' => $updates['term'] ?? $paper->term,
- ]);
- if ($best) {
- $candidateTextbookId = $best->id;
- $updates['textbook_id'] = $best->id;
- }
- }
- $matchedCatalog = $this->inferenceService()->matchCatalogNodeId($context, $candidateTextbookId);
- if ($matchedCatalog) {
- $meta['catalog_node_ids'] = [$matchedCatalog];
- $meta['catalog_node_id'] = $matchedCatalog;
- }
- }
- if (!empty($updates)) {
- $updates['meta'] = $meta;
- $paper->update($updates);
- } elseif (!empty($meta)) {
- $paper->update(['meta' => $meta]);
- }
- }
- }
-
- public function mergeSelectedPapers(): void
- {
- if (count($this->selectedIds) < 2) {
- $this->dispatch('notify', ['type' => 'warning', 'message' => '请至少选择两套卷子进行合并']);
- return;
- }
- $papers = SourcePaper::query()
- ->whereIn('id', $this->selectedIds)
- ->orderBy('order')
- ->get();
- $target = $papers->shift(); // 第一套作为目标
-
- DB::transaction(function () use ($target, $papers) {
- foreach ($papers as $source) {
- // 1. 移动所有候选题目
- DB::table('pre_question_candidates')
- ->where('source_paper_id', $source->id)
- ->update(['source_paper_id' => $target->id]);
- // 2. 移动所有 PaperPart (如果需要)
- DB::table('paper_parts')
- ->where('source_paper_id', $source->id)
- ->update(['source_paper_id' => $target->id]);
- // 3. 追加 Markdown 内容 (可选,但有助于保持记录完整)
- $target->raw_markdown .= "\n\n" . $source->raw_markdown;
-
- // 4. 删除原卷子
- $source->delete();
- }
-
- $target->save();
- });
- $this->selectedIds = [];
- $this->selectPaper($target->id);
-
- Notification::make()
- ->title('卷子合并成功')
- ->success()
- ->send();
- }
- public function selectAllVisible(): void
- {
- $this->selectedIds = $this->papers()->pluck('id')->toArray();
- }
- public function clearSelection(): void
- {
- $this->selectedIds = [];
- }
- public function gradeOptions(): array
- {
- return collect(range(1, 12))->mapWithKeys(fn ($grade) => [$grade => $grade . '年级'])->toArray();
- }
- public function termOptions(): array
- {
- return [
- '上册' => '上册',
- '下册' => '下册',
- '上学期' => '上学期',
- '下学期' => '下学期',
- ];
- }
- public function sourceTypeOptions(): array
- {
- return [
- '期中' => '期中卷',
- '期末' => '期末卷',
- '单元卷' => '单元卷',
- '专项卷' => '专项卷',
- '教材' => '教材',
- '其他' => '其他',
- ];
- }
- public function textbookOptions(): array
- {
- $query = Textbook::query();
-
- // 核心联动:基于 series_id 进行物理过滤
- $seriesId = $this->form['textbook_series_id'] ?? null;
- if ($seriesId) {
- $query->where('series_id', $seriesId);
- }
- return $query->orderBy('id')
- ->get(['id', 'official_title'])
- ->mapWithKeys(function ($textbook) {
- $title = $textbook->official_title ?: '未命名教材';
- return [$textbook->id => $title];
- })
- ->toArray();
- }
- public function seriesOptions(): array
- {
- return \App\Models\TextbookSeries::query()
- ->orderBy('sort_order')
- ->pluck('name', 'id')
- ->toArray();
- }
- public function catalogOptions(?int $textbookId = null): array
- {
- $textbookId ??= $this->form['textbook_id'] ?? null;
-
- if (empty($textbookId)) {
- return [];
- }
- $nodes = TextbookCatalog::query()
- ->where('textbook_id', $textbookId)
- ->orderBy('sort_order')
- ->get(['id', 'title', 'parent_id']);
- $grouped = $nodes->groupBy('parent_id');
- $walk = function ($parent, int $depth) use (&$walk, $grouped): array {
- $items = [];
- foreach ($grouped->get($parent, collect()) as $node) {
- $title = $node->title ?: '未命名目录';
- $indent = str_repeat('—', $depth);
- $items[$node->id] = trim($indent . ' ' . $title);
- $items += $walk($node->id, $depth + 1);
- }
- return $items;
- };
- return $walk(null, 0);
- }
- public function catalogCoverageSummary(): array
- {
- $textbookId = $this->selectedTextbookId();
- if (!$textbookId) {
- return [];
- }
- $nodes = TextbookCatalog::query()
- ->where('textbook_id', $textbookId)
- ->whereDoesntHave('children')
- ->get(['id']);
- $coverage = [];
- SourcePaper::query()
- ->where('textbook_id', $textbookId)
- ->get(['meta'])
- ->each(function ($paper) use (&$coverage) {
- $ids = $paper->meta['catalog_node_ids'] ?? (isset($paper->meta['catalog_node_id']) ? [$paper->meta['catalog_node_id']] : []);
- foreach ((array) $ids as $id) {
- if ($id) {
- $coverage[$id] = ($coverage[$id] ?? 0) + 1;
- }
- }
- });
- $linked = count($coverage);
- $missing = max(0, $nodes->count() - count($coverage));
- return [
- 'total' => $nodes->count(),
- 'linked' => $linked,
- 'missing' => $missing,
- ];
- }
- public function missingCatalogNodes(): array
- {
- $textbookId = $this->selectedTextbookId();
- if (!$textbookId) {
- return [];
- }
- $nodes = TextbookCatalog::query()
- ->where('textbook_id', $textbookId)
- ->whereDoesntHave('children')
- ->orderBy('sort_order')
- ->get(['id', 'title']);
- $coverage = [];
- SourcePaper::query()
- ->where('textbook_id', $textbookId)
- ->get(['meta'])
- ->each(function ($paper) use (&$coverage) {
- $ids = $paper->meta['catalog_node_ids'] ?? (isset($paper->meta['catalog_node_id']) ? [$paper->meta['catalog_node_id']] : []);
- foreach ((array) $ids as $id) {
- if ($id) {
- $coverage[$id] = ($coverage[$id] ?? 0) + 1;
- }
- }
- });
- $missing = [];
- foreach ($nodes as $node) {
- if (!isset($coverage[$node->id])) {
- $missing[] = [
- 'id' => $node->id,
- 'title' => $node->title,
- ];
- }
- }
- return array_slice($missing, 0, 8);
- }
- public function catalogTitlesForPaper(SourcePaper|array $paper): array
- {
- $meta = $paper instanceof SourcePaper ? ($paper->meta ?? []) : ($paper['meta'] ?? []);
- $textbookId = $paper instanceof SourcePaper ? $paper->textbook_id : ($paper['textbook_id'] ?? null);
- if (empty($textbookId)) {
- return [];
- }
- $ids = $this->normalizeCatalogNodeIds($meta['catalog_node_ids'] ?? ($meta['catalog_node_id'] ?? []));
- if (empty($ids)) {
- return [];
- }
- $missing = array_values(array_diff($ids, array_keys($this->catalogNodeCache)));
- if (!empty($missing)) {
- TextbookCatalog::query()
- ->whereIn('id', $missing)
- ->get(['id', 'title'])
- ->each(function ($node) {
- $this->catalogNodeCache[(int) $node->id] = $node->title ?: ('目录 #' . $node->id);
- });
- }
- return array_values(array_filter(array_map(fn ($id) => $this->catalogNodeCache[$id] ?? null, $ids)));
- }
- public function textbookSuggestions(): array
- {
- $paper = $this->selectedPaper();
- if (!$paper) {
- return [];
- }
- $title = (string) ($paper->title ?? $paper->full_title ?? '');
- $context = mb_strtolower($title);
- $parsed = $this->parseImportFilename();
- $grade = $paper->grade ? (int) $paper->grade : ($parsed['grade'] ?? null);
- $semester = $this->termToSemester($paper->term) ?? $this->termToSemester($parsed['term'] ?? null);
- $seriesHint = $paper->textbook_series ?: ($parsed['series'] ?? null);
- $subjectHint = $parsed['subject'] ?? null;
- return $this->inferenceService()->getTextbookSuggestions($paper, $parsed);
- }
- public function catalogSuggestions(): array
- {
- $paper = $this->selectedPaper();
- $textbookId = $paper?->textbook_id ?? ($this->form['textbook_id'] ?? null);
- if (!$paper || !$textbookId) {
- return [];
- }
- $needle = trim((string) ($paper->chapter ?? $paper->title ?? ''));
- if ($needle === '') {
- return [];
- }
- $nodes = TextbookCatalog::query()
- ->where('textbook_id', $textbookId)
- ->orderBy('sort_order')
- ->get(['id', 'title']);
- $matches = [];
- foreach ($nodes as $node) {
- $title = (string) $node->title;
- if ($title !== '' && str_contains($needle, $title)) {
- $matches[] = ['id' => $node->id, 'title' => $title];
- }
- }
- return array_slice($matches, 0, 5);
- }
- public function applyTextbookSuggestion(int $textbookId): void
- {
- $textbook = Textbook::query()->with('series')->find($textbookId);
- if (!$textbook) {
- return;
- }
- $this->form['textbook_id'] = $textbook->id;
- $this->form['textbook_series'] = $textbook->series?->name ?? $this->form['textbook_series'];
- }
- public function applyCatalogSuggestion(int $catalogId): void
- {
- $ids = $this->form['catalog_node_ids'] ?? [];
- if (!in_array($catalogId, $ids)) {
- $ids[] = $catalogId;
- $this->form['catalog_node_ids'] = $ids;
- $this->savePaper();
- }
- }
- public function candidateCountFor(SourcePaper $paper): int
- {
- return (int) ($paper->candidates_count ?? 0);
- }
- public function checkCompleteness(): array
- {
- $paper = $this->selectedPaper();
- if (!$paper) {
- return [];
- }
- $candidates = $paper->candidates()
- ->where('is_question_candidate', true)
- ->get();
- $service = app(QuestionCandidateToQuestionService::class);
- $total = $candidates->count();
- $invalid = 0;
- $issueStats = [];
- foreach ($candidates as $candidate) {
- $errors = $service->validateCandidate($candidate);
- if (!empty($errors)) {
- $invalid++;
- foreach ($errors as $error) {
- $issueStats[$error] = ($issueStats[$error] ?? 0) + 1;
- }
- }
- }
- return [
- 'total' => $total,
- 'valid' => $total - $invalid,
- 'invalid' => $invalid,
- 'issues' => $issueStats,
- 'is_ready' => $total > 0 && $invalid === 0,
- ];
- }
- private function explodeTags(string $tags): array
- {
- return array_values(array_filter(array_map('trim', explode(',', $tags))));
- }
- private function termToSemester(?string $term): ?int
- {
- return $this->inferenceService()->termToSemester($term);
- }
- private function buildBundleKey(SourcePaper $paper): string
- {
- $import = $this->importRecord();
- $base = $import?->file_name
- ? pathinfo($import->file_name, PATHINFO_FILENAME)
- : ($paper->title ?: $paper->full_title ?: '卷子');
- $grade = $paper->grade ? $this->gradeToLabel((int) $paper->grade) : null;
- $term = $paper->term ? $paper->term : null;
- $sourceType = $paper->source_type ?: null;
- $parts = array_filter([$grade, $term, $sourceType, $base]);
- return implode('·', $parts);
- }
- private function gradeToLabel(int $grade): string
- {
- $map = [
- 7 => '七年级',
- 8 => '八年级',
- 9 => '九年级',
- 10 => '高一',
- 11 => '高二',
- 12 => '高三',
- ];
- return $map[$grade] ?? $grade . '年级';
- }
- private function selectedTextbookId(): ?int
- {
- return $this->form['textbook_id'] ?: $this->batch['textbook_id'];
- }
- private function applyFilenameDefaults(): void
- {
- if (!$this->importId || empty($this->filenameParsed)) {
- return;
- }
- $parsed = $this->filenameParsed;
- $resolvedTextbook = $this->inferenceService()->resolveTextbookFromFilename($parsed);
- $papers = SourcePaper::query()
- ->whereHas('candidates', fn ($q) => $q->where('import_id', $this->importId))
- ->get();
- foreach ($papers as $paper) {
- $meta = $paper->meta ?? [];
- $hasNumericGrade = is_numeric((string) $paper->grade);
- $hasAllDefaults = !empty($paper->textbook_id)
- && !empty($paper->textbook_series)
- && $hasNumericGrade
- && !empty($paper->term);
- if (!empty($meta['filename_defaults_applied']) && $hasAllDefaults) {
- continue;
- }
- $updates = [];
- if (empty($paper->textbook_series) && !empty($parsed['series'])) {
- $formal = $this->inferenceService()->resolveSeries($parsed['series']);
- $updates['textbook_series_id'] = $formal ? $formal->id : null;
- $updates['textbook_series'] = $formal ? $formal->name : $parsed['series'];
- }
-
- if (!empty($parsed['grade']) && empty($paper->grade)) {
- $updates['grade'] = $parsed['grade'];
- }
-
- if (!empty($parsed['term']) && empty($paper->term)) {
- $updates['term'] = $parsed['term'];
- }
- // 在应用文件名默认值时,也触发一次教材重定向
- if (empty($paper->textbook_id)) {
- $best = $this->inferenceService()->findBestTextbook([
- 'series_id' => $updates['textbook_series_id'] ?? null,
- 'textbook_series' => $updates['textbook_series'] ?? $paper->textbook_series,
- 'grade' => $updates['grade'] ?? $paper->grade,
- 'term' => $updates['term'] ?? $paper->term,
- ]);
- if ($best) {
- $updates['textbook_id'] = $best->id;
- }
- }
-
- if (empty($meta['source_name']) && !empty($parsed['name'])) {
- $meta['source_name'] = $parsed['name'];
- }
- if (!empty($updates)) {
- $meta['filename_defaults_applied'] = true;
- $updates['meta'] = $meta;
- $paper->update($updates);
- }
- }
- }
- private function parseImportFilename(): array
- {
- $import = $this->importRecord();
- return $import?->parseFilename() ?? [];
- }
- private function normalizeGradeForForm($grade, array $parsed): ?int
- {
- if (is_numeric($grade)) {
- return (int) $grade;
- }
- $map = [
- '七年级' => 7,
- '八年级' => 8,
- '九年级' => 9,
- '高一' => 10,
- '高二' => 11,
- '高三' => 12,
- ];
- $grade = is_string($grade) ? trim($grade) : '';
- if ($grade !== '' && isset($map[$grade])) {
- return $map[$grade];
- }
- $parsedGrade = $parsed['grade'] ?? null;
- return is_numeric($parsedGrade) ? (int) $parsedGrade : null;
- }
- private function normalizeCatalogNodeIds($value): array
- {
- $ids = is_array($value) ? $value : [$value];
- $ids = array_values(array_unique(array_map('intval', array_filter($ids))));
- return $ids;
- }
- private function filterCatalogNodeIdsForTextbook(?int $textbookId, array $ids): array
- {
- $ids = $this->normalizeCatalogNodeIds($ids);
- if (empty($textbookId) || empty($ids)) {
- return $ids;
- }
- $validIds = TextbookCatalog::query()
- ->where('textbook_id', $textbookId)
- ->whereIn('id', $ids)
- ->pluck('id')
- ->map(fn ($id) => (int) $id)
- ->toArray();
- return $validIds;
- }
- private function applyBatchToIds(array $ids): void
- {
- $targetIds = array_values(array_filter(array_unique(array_map('intval', $ids))));
- if (empty($targetIds)) {
- return;
- }
- $updates = array_filter([
- 'edition' => $this->batch['edition'] ?? null,
- 'grade' => $this->batch['grade'] ?? null,
- 'term' => $this->batch['term'] ?? null,
- 'chapter' => $this->batch['chapter'] ?? null,
- 'source_type' => $this->batch['source_type'] ?? null,
- 'source_year' => $this->batch['source_year'] ?? null,
- 'textbook_id' => $this->batch['textbook_id'] ?? null,
- 'series_id' => $this->batch['textbook_series_id'] ?? null,
- ], fn ($value) => $value !== null && $value !== '');
- foreach (SourcePaper::query()->whereIn('id', $targetIds)->get() as $paper) {
- $meta = $paper->meta ?? [];
- if (!empty($this->batch['source_name'])) {
- $meta['source_name'] = $this->batch['source_name'];
- }
- if (!empty($this->batch['source_page'])) {
- $meta['source_page'] = $this->batch['source_page'];
- }
- if (!empty($this->batch['tags'])) {
- $meta['tags'] = $this->explodeTags($this->batch['tags']);
- }
- if (!empty($this->batch['bundle_key'])) {
- $meta['bundle_key'] = $this->batch['bundle_key'];
- }
- if (!empty($this->batch['expected_count'])) {
- $meta['expected_count'] = $this->batch['expected_count'];
- }
- if (!empty($this->batch['catalog_node_ids'])) {
- $catalogNodeIds = $this->normalizeCatalogNodeIds($this->batch['catalog_node_ids']);
- $catalogNodeIds = $this->filterCatalogNodeIdsForTextbook($updates['textbook_id'] ?? $paper->textbook_id, $catalogNodeIds);
- $meta['catalog_node_ids'] = $catalogNodeIds;
- $meta['catalog_node_id'] = $catalogNodeIds[0] ?? null;
- }
- $paper->update(array_merge($updates, ['meta' => $meta]));
- }
- }
- private function isBatchEmpty(): bool
- {
- $fields = [
- 'edition', 'grade', 'term', 'chapter', 'source_type',
- 'source_year', 'textbook_id', 'textbook_series_id',
- 'source_name', 'source_page', 'tags', 'bundle_key',
- 'expected_count',
- ];
- foreach ($fields as $field) {
- if (!empty($this->batch[$field])) {
- return false;
- }
- }
- return empty($this->batch['catalog_node_ids']);
- }
- }
|