| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738 |
- <?php
- namespace App\Filament\Pages;
- use App\Models\MarkdownImport;
- use App\Models\SourcePaper;
- use App\Models\Textbook;
- use App\Models\TextbookCatalog;
- use Filament\Pages\Page;
- use Illuminate\Support\Arr;
- 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';
- public ?int $importId = null;
- public ?int $selectedPaperId = null;
- public array $selectedIds = [];
- public string $search = '';
- public string $groupBy = 'bundle';
- 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,
- '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() ?? [];
- }
- }
|