null, '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_id' => null, ]; 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_id' => null, ]; 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; } $meta = $paper->meta ?? []; $this->selectedPaperId = $paperId; $this->form = [ 'title' => $paper->title, 'edition' => $paper->edition, 'grade' => $paper->grade, 'term' => $paper->term, 'chapter' => $paper->chapter, 'source_type' => $paper->source_type, 'source_year' => $paper->source_year, 'textbook_id' => $paper->textbook_id, 'textbook_series' => $paper->textbook_series, 'source_name' => Arr::get($meta, 'source_name'), '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_id' => Arr::get($meta, 'catalog_node_id'), ]; } 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' => $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_id' => $this->form['catalog_node_id'] ?? null, ]; } public function savePaper(): void { $paper = $this->selectedPaper(); if (!$paper) { return; } $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; $meta['catalog_node_id'] = $this->form['catalog_node_id'] ?? null; $paper->update([ 'title' => $this->form['title'] ?? null, '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' => $this->form['textbook_series'] ?? null, 'meta' => $meta, ]); } public function applyBatch(): void { if (empty($this->selectedIds)) { 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, 'textbook_series' => $this->batch['textbook_series'] ?? null, ], fn ($value) => $value !== null && $value !== ''); foreach (SourcePaper::query()->whereIn('id', $this->selectedIds)->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_id'])) { $meta['catalog_node_id'] = $this->batch['catalog_node_id']; } $paper->update(array_merge($updates, ['meta' => $meta])); } } public function autoInfer(): void { $paper = $this->selectedPaper(); if (!$paper) { return; } $parsed = $this->parseImportFilename(); if (!empty($parsed)) { $this->form['textbook_series'] = $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->inferTerm($context) ?? $this->form['term']; $this->form['grade'] = $this->inferGrade($context) ?? $this->form['grade']; $this->form['chapter'] = $this->inferChapter($context) ?? $this->form['chapter']; } 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->inferTerm($context), 'grade' => $this->inferGrade($context), 'chapter' => $this->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($updates)) { $updates['meta'] = $meta; $paper->update($updates); } elseif (!empty($meta)) { $paper->update(['meta' => $meta]); } } } 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 { return Textbook::query() ->orderBy('id') ->get(['id', 'official_title']) ->mapWithKeys(function ($textbook) { $title = $textbook->official_title ?: '未命名教材'; return [$textbook->id => $title]; }) ->toArray(); } public function catalogOptions(): array { if (empty($this->form['textbook_id']) && empty($this->batch['textbook_id'])) { return []; } $textbookId = $this->form['textbook_id'] ?: $this->batch['textbook_id']; $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) { $catalogId = $paper->meta['catalog_node_id'] ?? null; if ($catalogId) { $coverage[$catalogId] = ($coverage[$catalogId] ?? 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) { $catalogId = $paper->meta['catalog_node_id'] ?? null; if ($catalogId) { $coverage[$catalogId] = ($coverage[$catalogId] ?? 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 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; $suggestions = []; $textbooks = Textbook::query()->with('series')->get(); foreach ($textbooks as $textbook) { $score = 0; if ($grade && (int) $textbook->grade === $grade) { $score += 3; } if ($semester && (int) $textbook->semester === $semester) { $score += 3; } $official = mb_strtolower((string) $textbook->official_title); if ($official !== '' && str_contains($context, $official)) { $score += 4; } $aliases = $textbook->aliases ?? []; foreach ($aliases as $alias) { $alias = mb_strtolower((string) $alias); if ($alias !== '' && str_contains($context, $alias)) { $score += 2; } } $seriesName = $textbook->series?->name ?? null; if ($seriesHint && $seriesName && str_contains((string) $seriesHint, (string) $seriesName)) { $score += 5; } if ($subjectHint) { $subjectHint = mb_strtolower((string) $subjectHint); $official = mb_strtolower((string) $textbook->official_title); if ($official !== '' && str_contains($official, $subjectHint)) { $score += 1; } } if ($score > 0) { $suggestions[] = [ 'id' => $textbook->id, 'title' => $textbook->official_title, 'series' => $textbook->series?->name ?? '未归类系列', 'grade' => $textbook->grade, 'semester' => $textbook->semester, 'score' => $score, ]; } } usort($suggestions, fn ($a, $b) => $b['score'] <=> $a['score']); return array_slice($suggestions, 0, 5); } public function catalogSuggestions(): array { $paper = $this->selectedPaper(); $textbookId = $paper?->textbook_id ?? $this->form['textbook_id']; 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 { $this->form['catalog_node_id'] = $catalogId; } public function candidateCountFor(SourcePaper $paper): int { return (int) ($paper->candidates_count ?? 0); } private function inferTerm(string $context): ?string { if (str_contains($context, '上册') || str_contains($context, '上学期')) { return '上册'; } if (str_contains($context, '下册') || str_contains($context, '下学期')) { return '下册'; } return null; } private function inferGrade(string $context): ?string { foreach (['七年级' => '7', '八年级' => '8', '九年级' => '9', '高一' => '10', '高二' => '11', '高三' => '12'] as $label => $value) { if (str_contains($context, $label)) { return $value; } } return null; } private function inferChapter(string $context): ?string { if (preg_match('/第[一二三四五六七八九十]+章[^\\n]*/u', $context, $match)) { return $match[0]; } return null; } private function explodeTags(string $tags): array { return array_values(array_filter(array_map('trim', explode(',', $tags)))); } private function termToSemester(?string $term): ?int { if (!$term) { return null; } if (str_contains($term, '上')) { return 1; } if (str_contains($term, '下')) { return 2; } return null; } 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; $papers = SourcePaper::query() ->whereHas('candidates', fn ($q) => $q->where('import_id', $this->importId)) ->get(); foreach ($papers as $paper) { $meta = $paper->meta ?? []; if (!empty($meta['filename_defaults_applied'])) { continue; } $updates = []; 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']; } 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); } elseif (!empty($meta)) { $meta['filename_defaults_applied'] = true; $paper->update(['meta' => $meta]); } } } private function parseImportFilename(): array { $import = $this->importRecord(); return $import?->parseFilename() ?? []; } }