Bladeren bron

题库相关

yemeishu 1 week geleden
bovenliggende
commit
fff9632539
39 gewijzigde bestanden met toevoegingen van 1907 en 854 verwijderingen
  1. 112 74
      app/Filament/Resources/MarkdownImportResource.php
  2. 1 0
      app/Filament/Resources/MarkdownImportResource/Pages/CreateMarkdownImport.php
  3. 1 0
      app/Filament/Resources/MarkdownImportResource/Pages/ListMarkdownImports.php
  4. 1 1
      app/Filament/Resources/PaperPartResource.php
  5. 21 4
      app/Filament/Resources/PreQuestionCandidateResource.php
  6. 43 0
      app/Filament/Resources/PreQuestionCandidateResource/Pages/ListPreQuestionCandidates.php
  7. 64 3
      app/Filament/Resources/SourcePaperResource.php
  8. 1 0
      app/Filament/Resources/SourcePaperResource/Pages/ListSourcePapers.php
  9. 23 0
      app/Filament/Resources/SourcePaperResource/Pages/ViewSourcePaper.php
  10. 2 131
      app/Filament/Resources/TextbookResource.php
  11. 54 27
      app/Filament/Resources/TextbookResource/Pages/EditTextbook.php
  12. 15 4
      app/Filament/Resources/TextbookResource/Pages/ManageTextbooks.php
  13. 44 0
      app/Filament/Resources/TextbookResource/Pages/ViewTextbook.php
  14. 158 156
      app/Filament/Resources/TextbookResource/Schemas/TextbookFormSchema.php
  15. 107 61
      app/Filament/Resources/TextbookResource/Tables/TextbookTable.php
  16. 50 0
      app/Http/Controllers/ImportStreamController.php
  17. 34 0
      app/Providers/AppServiceProvider.php
  18. 122 0
      app/Services/PaperIdGenerator.php
  19. 3 4
      app/Services/QuestionBankService.php
  20. 1 0
      composer.json
  21. 64 1
      composer.lock
  22. 24 2
      config/database.php
  23. 99 0
      resources/css/app.css
  24. 10 0
      resources/views/filament/custom/body-start.blade.php
  25. 165 197
      resources/views/filament/pages/exam-history-simple.blade.php
  26. 18 0
      resources/views/filament/partials/catalog-tree.blade.php
  27. 7 0
      resources/views/filament/partials/density-toggle.blade.php
  28. 16 0
      resources/views/filament/partials/empty-state.blade.php
  29. 9 0
      resources/views/filament/partials/loading-overlay.blade.php
  30. 21 0
      resources/views/filament/partials/page-header.blade.php
  31. 48 0
      resources/views/filament/resources/markdown-import-resource/pages/create-markdown-import.blade.php
  32. 125 0
      resources/views/filament/resources/markdown-import-resource/pages/list-markdown-imports.blade.php
  33. 134 0
      resources/views/filament/resources/pre-question-candidate-resource/pages/list-pre-question-candidates.blade.php
  34. 50 0
      resources/views/filament/resources/source-paper-resource/pages/list-source-papers.blade.php
  35. 84 0
      resources/views/filament/resources/source-paper-resource/pages/view-source-paper.blade.php
  36. 19 83
      resources/views/filament/resources/textbook-resource/edit.blade.php
  37. 41 106
      resources/views/filament/resources/textbook-resource/index-record.blade.php
  38. 112 0
      resources/views/filament/resources/textbook-resource/view.blade.php
  39. 4 0
      routes/web.php

+ 112 - 74
app/Filament/Resources/MarkdownImportResource.php

@@ -14,6 +14,10 @@ use Filament\Notifications\Notification;
 use Filament\Forms\Components\FileUpload;
 use Filament\Forms\Components\Hidden;
 use Filament\Forms\Components\MarkdownEditor;
+use Filament\Forms\Components\Section;
+use Filament\Forms\Components\Select;
+use Filament\Forms\Components\Toggle;
+use Filament\Forms\Components\TextInput;
 use Filament\Schemas\Components\Utilities\Get;
 use Filament\Schemas\Components\Utilities\Set;
 use Filament\Resources\Resource;
@@ -28,6 +32,7 @@ use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
 use App\Support\TextEncoding;
 use App\Rules\MarkdownFileExtension;
 use Filament\Tables\Columns\TextColumn;
+use Filament\Tables\Enums\FiltersLayout;
 
 class MarkdownImportResource extends Resource
 {
@@ -94,77 +99,110 @@ class MarkdownImportResource extends Resource
     {
         return $schema
             ->schema([
-                \Filament\Forms\Components\TextInput::make('file_name')
-                    ->label('文件名(来源名称)')
-                    ->required(fn (Get $get): bool => empty($get('markdown_file')))
-                    ->maxLength(255),
-
-                FileUpload::make('markdown_file')
-                    ->label('Markdown 文件(可选)')
-                    ->disk('local')
-                    ->directory('imports/markdown')
-                    ->helperText('仅支持 .md / .markdown / .txt;上传后会自动读取内容并填充编辑器')
-                    ->maxSize(10 * 1024)
-                    ->storeFileNamesIn('uploaded_file_names')
-                    ->dehydrated(true)
-                    ->preserveFilenames()
-                    ->rules([new MarkdownFileExtension()])
-                    ->afterStateUpdated(function ($state, Set $set, Get $get): void {
-                        // 在提交表单前,FileUpload 的 state 可能还是 TemporaryUploadedFile(尚未保存到 disk)
-                        $first = is_array($state) ? ($state[0] ?? null) : $state;
-
-                        if ($first instanceof TemporaryUploadedFile) {
-                            $set('original_markdown', TextEncoding::toUtf8((string) @file_get_contents($first->getRealPath())));
-
-                            if (empty($get('file_name'))) {
-                                $set('file_name', $first->getClientOriginalName());
-                            }
-
-                            return;
-                        }
-
-                        $paths = is_array($state) ? $state : (empty($state) ? [] : [$state]);
-                        $path = (string) ($paths[0] ?? '');
-                        if ($path === '') {
-                            return;
-                        }
-
-                        // 已保存到 disk 后:读取文件内容填充编辑器
-                        if (Storage::disk('local')->exists($path)) {
-                            $set('original_markdown', TextEncoding::toUtf8(Storage::disk('local')->get($path)));
-                        }
-
-                        // 上传后的真实文件名:BaseFileUpload 会在保存时 storeFileName($storedFile, originalName)
-                        $storedNames = $get('uploaded_file_names');
-                        if (is_string($storedNames) && $storedNames !== '') {
-                            $set('file_name', $storedNames);
-                        } elseif (empty($get('file_name'))) {
-                            $set('file_name', basename($path));
-                        }
-                    }),
-
-                Hidden::make('uploaded_file_names')
-                    ->dehydrated(true),
-
-                MarkdownEditor::make('original_markdown')
-                    ->label('Markdown 内容(编辑器)')
-                    ->required(fn (Get $get): bool => empty($get('markdown_file')))
-                    ->columnSpanFull()
-                    // 固定编辑器高度,避免内容过长把页面撑开
-                    ->minHeight('45vh')
-                    ->maxHeight('45vh')
-                    ->toolbarButtons([
-                        'bold',
-                        'italic',
-                        'strike',
-                        'blockquote',
-                        'bulletList',
-                        'orderedList',
-                        'link',
-                        'codeBlock',
-                        'table',
-                        'undo',
-                        'redo',
+                Section::make('上传与来源信息')
+                    ->schema([
+                        \Filament\Forms\Components\TextInput::make('file_name')
+                            ->label('文件名(来源名称)')
+                            ->required(fn (Get $get): bool => empty($get('markdown_file')))
+                            ->maxLength(255),
+
+                        FileUpload::make('markdown_file')
+                            ->label('Markdown 文件(可选)')
+                            ->disk('local')
+                            ->directory('imports/markdown')
+                            ->helperText('仅支持 .md / .markdown / .txt;上传后会自动读取内容并填充编辑器')
+                            ->maxSize(10 * 1024)
+                            ->storeFileNamesIn('uploaded_file_names')
+                            ->dehydrated(true)
+                            ->preserveFilenames()
+                            ->rules([new MarkdownFileExtension()])
+                            ->afterStateUpdated(function ($state, Set $set, Get $get): void {
+                                // 在提交表单前,FileUpload 的 state 可能还是 TemporaryUploadedFile(尚未保存到 disk)
+                                $first = is_array($state) ? ($state[0] ?? null) : $state;
+
+                                if ($first instanceof TemporaryUploadedFile) {
+                                    $set('original_markdown', TextEncoding::toUtf8((string) @file_get_contents($first->getRealPath())));
+
+                                    if (empty($get('file_name'))) {
+                                        $set('file_name', $first->getClientOriginalName());
+                                    }
+
+                                    return;
+                                }
+
+                                $paths = is_array($state) ? $state : (empty($state) ? [] : [$state]);
+                                $path = (string) ($paths[0] ?? '');
+                                if ($path === '') {
+                                    return;
+                                }
+
+                                // 已保存到 disk 后:读取文件内容填充编辑器
+                                if (Storage::disk('local')->exists($path)) {
+                                    $set('original_markdown', TextEncoding::toUtf8(Storage::disk('local')->get($path)));
+                                }
+
+                                // 上传后的真实文件名:BaseFileUpload 会在保存时 storeFileName($storedFile, originalName)
+                                $storedNames = $get('uploaded_file_names');
+                                if (is_string($storedNames) && $storedNames !== '') {
+                                    $set('file_name', $storedNames);
+                                } elseif (empty($get('file_name'))) {
+                                    $set('file_name', basename($path));
+                                }
+                            }),
+
+                        Hidden::make('uploaded_file_names')
+                            ->dehydrated(true),
+                    ])
+                    ->columns(2),
+
+                Section::make('解析规则(可选)')
+                    ->schema([
+                        Select::make('parse_mode')
+                            ->label('解析模式')
+                            ->options([
+                                'strict' => '严格模式',
+                                'relaxed' => '宽松模式',
+                            ])
+                            ->default('strict')
+                            ->dehydrated(false),
+                        TextInput::make('split_marker')
+                            ->label('分题符号')
+                            ->placeholder('如:---')
+                            ->dehydrated(false),
+                        TextInput::make('type_marker')
+                            ->label('题型标记')
+                            ->placeholder('如:#选择题')
+                            ->dehydrated(false),
+                        Toggle::make('auto_detect_images')
+                            ->label('自动识别图片')
+                            ->default(true)
+                            ->dehydrated(false),
+                    ])
+                    ->columns(2)
+                    ->collapsed(),
+
+                Section::make('Markdown 内容')
+                    ->schema([
+                        MarkdownEditor::make('original_markdown')
+                            ->label('Markdown 内容(编辑器)')
+                            ->required(fn (Get $get): bool => empty($get('markdown_file')))
+                            ->columnSpanFull()
+                            // 固定编辑器高度,避免内容过长把页面撑开
+                            ->minHeight('45vh')
+                            ->maxHeight('45vh')
+                            ->toolbarButtons([
+                                'bold',
+                                'italic',
+                                'strike',
+                                'blockquote',
+                                'bulletList',
+                                'orderedList',
+                                'link',
+                                'codeBlock',
+                                'table',
+                                'undo',
+                                'redo',
+                            ]),
                     ]),
             ]);
     }
@@ -271,7 +309,7 @@ class MarkdownImportResource extends Resource
                         'exam' => '考试',
                         'other' => '其他',
                     ]),
-            ])
+            ], layout: FiltersLayout::AboveContentCollapsible)
             ->actions([
                 EditAction::make()
                     ->label('编辑'),
@@ -366,9 +404,9 @@ class MarkdownImportResource extends Resource
                     DeleteBulkAction::make(),
                 ]),
             ])
+            ->recordClasses(fn (Model $record) => $record->status === 'failed' ? 'bg-rose-50/60' : null)
             ->defaultSort('created_at', 'desc')
-            ->paginated([10, 25, 50, 100])
-            ->poll('10s');
+            ->paginated([10, 25, 50, 100]);
     }
 
     public static function getEloquentQuery(): Builder

+ 1 - 0
app/Filament/Resources/MarkdownImportResource/Pages/CreateMarkdownImport.php

@@ -8,4 +8,5 @@ use Filament\Resources\Pages\CreateRecord;
 class CreateMarkdownImport extends CreateRecord
 {
     protected static string $resource = MarkdownImportResource::class;
+    protected string $view = 'filament.resources.markdown-import-resource.pages.create-markdown-import';
 }

+ 1 - 0
app/Filament/Resources/MarkdownImportResource/Pages/ListMarkdownImports.php

@@ -10,6 +10,7 @@ use Filament\Resources\Pages\ListRecords;
 class ListMarkdownImports extends ListRecords
 {
     protected static string $resource = MarkdownImportResource::class;
+    protected string $view = 'filament.resources.markdown-import-resource.pages.list-markdown-imports';
 
     protected function getHeaderActions(): array
     {

+ 1 - 1
app/Filament/Resources/PaperPartResource.php

@@ -21,7 +21,7 @@ class PaperPartResource extends Resource
 
     protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list';
 
-    protected static UnitEnum|string|null $navigationGroup = 'Markdown 解析';
+    protected static UnitEnum|string|null $navigationGroup = '卷子管理';
 
     protected static ?string $navigationLabel = '题型区块';
 

+ 21 - 4
app/Filament/Resources/PreQuestionCandidateResource.php

@@ -18,6 +18,7 @@ use Filament\Tables\Columns\TextColumn;
 use Filament\Tables\Filters\TernaryFilter;
 use Illuminate\Database\Eloquent\Model;
 use UnitEnum;
+use Filament\Tables\Enums\FiltersLayout;
 
 class PreQuestionCandidateResource extends Resource
 {
@@ -97,8 +98,24 @@ class PreQuestionCandidateResource extends Resource
 
                 TextColumn::make('raw_markdown')
                     ->label('题目预览')
+                    ->html()
+                    ->formatStateUsing(function (?string $state, Model $record): string {
+                        $stem = $record->stem ? e(\Illuminate\Support\Str::limit($record->stem, 120)) : e(\Illuminate\Support\Str::limit((string) $state, 120));
+                        $hasIssue = $record->is_valid_question === false || $record->status === 'pending';
+                        $issueTag = $hasIssue ? '<span class="ui-tag text-rose-600 border-rose-200 bg-rose-50">需修正</span>' : '';
+
+                        return <<<HTML
+<div class="space-y-2">
+    <div class="text-sm text-slate-800">{$stem}</div>
+    <div class="flex flex-wrap gap-2 text-xs text-slate-500">
+        <span class="ui-tag">区块:{$record->part?->title}</span>
+        <span class="ui-tag">卷子:{$record->sourcePaper?->title}</span>
+        {$issueTag}
+    </div>
+</div>
+HTML;
+                    })
                     ->wrap()
-                    ->limit(140)
                     ->toggleable(),
 
                 Tables\Columns\ImageColumn::make('first_image')
@@ -158,7 +175,7 @@ class PreQuestionCandidateResource extends Resource
                         'rejected' => '已拒绝',
                         'superseded' => '已被新解析覆盖',
                     ]),
-            ])
+            ], layout: FiltersLayout::AboveContentCollapsible)
             ->actions([
                 Action::make('review_edit')
                     ->label('校对/编辑')
@@ -253,9 +270,9 @@ class PreQuestionCandidateResource extends Resource
                         ->label('入库到筛选库'),
                 ]),
             ])
+            ->recordClasses(fn (Model $record) => $record->is_valid_question === false ? 'bg-rose-50/60' : null)
             ->defaultSort('sequence', 'asc')
-            ->paginated([20, 50, 100])
-            ->poll('10s');
+            ->paginated([20, 50, 100]);
     }
 
     public static function getPages(): array

+ 43 - 0
app/Filament/Resources/PreQuestionCandidateResource/Pages/ListPreQuestionCandidates.php

@@ -10,6 +10,11 @@ use Illuminate\Database\Eloquent\Builder;
 class ListPreQuestionCandidates extends ListRecords
 {
     protected static string $resource = PreQuestionCandidateResource::class;
+    protected string $view = 'filament.resources.pre-question-candidate-resource.pages.list-pre-question-candidates';
+
+    public array $paperTree = [];
+
+    public array $summaryStats = [];
 
     protected function canCreate(): bool
     {
@@ -38,4 +43,42 @@ class ListPreQuestionCandidates extends ListRecords
 
         return $query;
     }
+
+    public function mount(): void
+    {
+        parent::mount();
+
+        $importId = request()->input('import_id');
+        $query = PreQuestionCandidate::query()
+            ->with(['sourcePaper', 'part'])
+            ->when($importId, fn ($q) => $q->where('import_id', (int) $importId));
+
+        $records = $query->get();
+
+        $this->summaryStats = [
+            'total' => $records->count(),
+            'accepted' => $records->where('status', PreQuestionCandidate::STATUS_ACCEPTED)->count(),
+            'reviewed' => $records->where('status', PreQuestionCandidate::STATUS_REVIEWED)->count(),
+            'pending' => $records->where('status', PreQuestionCandidate::STATUS_PENDING)->count(),
+            'rejected' => $records->where('status', PreQuestionCandidate::STATUS_REJECTED)->count(),
+        ];
+
+        $tree = [];
+        foreach ($records as $record) {
+            $paperId = $record->sourcePaper?->id ?? 0;
+            $partId = $record->part?->id ?? 0;
+            $paperTitle = $record->sourcePaper?->title ?? '未命名卷子';
+            $partTitle = $record->part?->title ?? '未标注区块';
+
+            $tree[$paperId]['title'] = $paperTitle;
+            $tree[$paperId]['parts'][$partId]['title'] = $partTitle;
+            $tree[$paperId]['parts'][$partId]['count'] = ($tree[$paperId]['parts'][$partId]['count'] ?? 0) + 1;
+        }
+
+        $this->paperTree = array_values(array_map(function ($paper) {
+            $parts = $paper['parts'] ?? [];
+            $paper['parts'] = array_values($parts);
+            return $paper;
+        }, $tree));
+    }
 }

+ 64 - 3
app/Filament/Resources/SourcePaperResource.php

@@ -14,6 +14,11 @@ use Filament\Actions\ViewAction;
 use Illuminate\Database\Eloquent\Model;
 use BackedEnum;
 use UnitEnum;
+use Filament\Tables\Enums\FiltersLayout;
+use Filament\Tables\Filters\SelectFilter;
+use Filament\Tables\Filters\Filter;
+use Filament\Forms\Components\TextInput;
+use Illuminate\Database\Eloquent\Builder;
 
 class SourcePaperResource extends Resource
 {
@@ -21,7 +26,7 @@ class SourcePaperResource extends Resource
 
     protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-document-text';
 
-    protected static UnitEnum|string|null $navigationGroup = 'Markdown 解析';
+    protected static UnitEnum|string|null $navigationGroup = '卷子管理';
 
     protected static ?string $navigationLabel = '源卷子';
 
@@ -58,13 +63,61 @@ class SourcePaperResource extends Resource
             ->columns([
                 Tables\Columns\TextColumn::make('order')->label('顺序')->sortable(),
                 Tables\Columns\TextColumn::make('title')->label('卷标题')->searchable(),
+                Tables\Columns\TextColumn::make('file.original_filename')->label('来源文件')->toggleable(),
                 Tables\Columns\TextColumn::make('grade')->label('年级'),
                 Tables\Columns\TextColumn::make('term')->label('学期'),
                 Tables\Columns\TextColumn::make('source_type')->label('类型'),
+                Tables\Columns\TextColumn::make('parts_sum_question_count')
+                    ->label('题量')
+                    ->getStateUsing(fn (Model $record) => $record->parts_sum_question_count ?? 0)
+                    ->sortable(),
+                Tables\Columns\TextColumn::make('parts_count')
+                    ->label('区块数')
+                    ->getStateUsing(fn (Model $record) => $record->parts_count ?? 0)
+                    ->sortable(),
             ])
+            ->filters([
+                SelectFilter::make('grade')
+                    ->label('年级')
+                    ->options(collect(range(1, 12))->mapWithKeys(fn ($grade) => [$grade => "{$grade}年级"])->all()),
+                SelectFilter::make('term')
+                    ->label('学期')
+                    ->options([
+                        '上学期' => '上学期',
+                        '下学期' => '下学期',
+                    ]),
+                SelectFilter::make('source_type')
+                    ->label('来源类型')
+                    ->options([
+                        'textbook' => '教材',
+                        'exam' => '考试',
+                        'other' => '其他',
+                    ]),
+                Filter::make('question_range')
+                    ->label('题量范围')
+                    ->form([
+                        TextInput::make('min')->placeholder('最小题量')->numeric(),
+                        TextInput::make('max')->placeholder('最大题量')->numeric(),
+                    ])
+                    ->query(function (Builder $query, array $data) {
+                        $min = $data['min'] ?? null;
+                        $max = $data['max'] ?? null;
+
+                        if ($min !== null && $min !== '') {
+                            $query->having('parts_sum_question_count', '>=', (int) $min);
+                        }
+                        if ($max !== null && $max !== '') {
+                            $query->having('parts_sum_question_count', '<=', (int) $max);
+                        }
+                    }),
+            ], layout: FiltersLayout::AboveContentCollapsible)
             ->actions([
-                ViewAction::make(),
-            ]);
+                ViewAction::make()
+                    ->icon('heroicon-o-eye')
+                    ->iconButton()
+                    ->tooltip('查看详情'),
+            ])
+            ->recordUrl(fn (Model $record): string => route('filament.admin.resources.source-papers.view', $record));
     }
 
     public static function getRelations(): array
@@ -81,4 +134,12 @@ class SourcePaperResource extends Resource
             'view' => Pages\ViewSourcePaper::route('/{record}'),
         ];
     }
+
+    public static function getEloquentQuery(): Builder
+    {
+        return parent::getEloquentQuery()
+            ->with(['file'])
+            ->withCount(['parts', 'candidates'])
+            ->withSum('parts', 'question_count');
+    }
 }

+ 1 - 0
app/Filament/Resources/SourcePaperResource/Pages/ListSourcePapers.php

@@ -8,4 +8,5 @@ use Filament\Resources\Pages\ListRecords;
 class ListSourcePapers extends ListRecords
 {
     protected static string $resource = SourcePaperResource::class;
+    protected string $view = 'filament.resources.source-paper-resource.pages.list-source-papers';
 }

+ 23 - 0
app/Filament/Resources/SourcePaperResource/Pages/ViewSourcePaper.php

@@ -8,4 +8,27 @@ use Filament\Resources\Pages\ViewRecord;
 class ViewSourcePaper extends ViewRecord
 {
     protected static string $resource = SourcePaperResource::class;
+    protected string $view = 'filament.resources.source-paper-resource.pages.view-source-paper';
+
+    public bool $expandAll = true;
+
+    public function getPaperPartsProperty(): array
+    {
+        $record = $this->record;
+
+        return $record->parts()
+            ->withCount('candidates')
+            ->orderBy('order')
+            ->get()
+            ->map(fn ($part) => [
+                'id' => $part->id,
+                'title' => $part->title ?: '未命名区块',
+                'type' => $part->type ?: '未标注题型',
+                'question_count' => $part->question_count ?? 0,
+                'candidate_count' => $part->candidates_count ?? 0,
+                'raw_markdown' => $part->raw_markdown,
+                'has_error' => $part->question_count === null,
+            ])
+            ->toArray();
+    }
 }

+ 2 - 131
app/Filament/Resources/TextbookResource.php

@@ -52,137 +52,7 @@ class TextbookResource extends Resource
 
     public static function table(Tables\Table $table): Tables\Table
     {
-        return $table
-            ->columns([
-                \Filament\Tables\Columns\TextColumn::make('id')
-                    ->label('ID')
-                    ->sortable(),
-
-                \Filament\Tables\Columns\TextColumn::make('series.name')
-                    ->label('系列')
-                    ->searchable(),
-
-                \Filament\Tables\Columns\TextColumn::make('official_title')
-                    ->label('官方书名')
-                    ->searchable()
-                    ->wrap(),
-
-                \Filament\Tables\Columns\TextColumn::make('stage')
-                    ->label('学段')
-                    ->formatStateUsing(function ($state): string {
-                        return match ($state) {
-                            'primary' => '小学',
-                            'junior' => '初中',
-                            'senior' => '高中',
-                            default => $state,
-                        };
-                    })
-                    ->badge()
-                    ->color('info'),
-
-                \Filament\Tables\Columns\TextColumn::make('grade')
-                    ->label('年级')
-                    ->formatStateUsing(function ($state): string {
-                        return $state ? "{$state}年级" : '-';
-                    }),
-
-                \Filament\Tables\Columns\TextColumn::make('semester')
-                    ->label('学期')
-                    ->formatStateUsing(function ($state): string {
-                        return match ($state) {
-                            1 => '上学期',
-                            2 => '下学期',
-                            default => '-',
-                        };
-                    })
-                    ->badge()
-                    ->color('success'),
-
-                \Filament\Tables\Columns\TextColumn::make('naming_scheme')
-                    ->label('体系')
-                    ->formatStateUsing(function ($state): string {
-                        return match ($state) {
-                            'new' => '新体系',
-                            'old' => '旧体系',
-                            default => $state,
-                        };
-                    })
-                    ->badge(),
-
-                \Filament\Tables\Columns\TextColumn::make('status')
-                    ->label('状态')
-                    ->formatStateUsing(function ($state): string {
-                        return match ($state) {
-                            'draft' => '草稿',
-                            'published' => '已发布',
-                            'archived' => '已归档',
-                            default => $state,
-                        };
-                    })
-                    ->badge()
-                    ->color(function ($state): string {
-                        return match ($state) {
-                            'draft' => 'gray',
-                            'published' => 'success',
-                            'archived' => 'danger',
-                            default => 'gray',
-                        };
-                    }),
-
-                \Filament\Tables\Columns\TextColumn::make('created_at')
-                    ->label('创建时间')
-                    ->dateTime()
-                    ->sortable()
-                    ->toggleable(isToggledHiddenByDefault: true),
-
-                \Filament\Tables\Columns\TextColumn::make('updated_at')
-                    ->label('更新时间')
-                    ->dateTime()
-                    ->sortable()
-                    ->toggleable(isToggledHiddenByDefault: true),
-            ])
-            ->filters([
-                \Filament\Tables\Filters\SelectFilter::make('stage')
-                    ->label('学段')
-                    ->options([
-                        'primary' => '小学',
-                        'junior' => '初中',
-                        'senior' => '高中',
-                    ]),
-
-                \Filament\Tables\Filters\SelectFilter::make('status')
-                    ->label('状态')
-                    ->options([
-                        'draft' => '草稿',
-                        'published' => '已发布',
-                        'archived' => '已归档',
-                    ]),
-            ])
-            ->actions([
-                \Filament\Actions\EditAction::make()
-                    ->label('编辑')
-                    ->url(fn($record): string =>
-                        route('filament.admin.resources.textbooks.edit', ['record' => $record->id])
-                    ),
-
-                DeleteTextbookAction::make()
-                    ->label('删除'),
-
-                \Filament\Actions\Action::make('view_catalog')
-                    ->label('查看目录')
-                    ->icon('heroicon-o-list-bullet')
-                    ->url(fn(Model $record): string =>
-                        route('filament.admin.resources.textbook-catalogs.index', ['tableFilters[textbook_id][value]' => $record->id])
-                    ),
-            ])
-            ->bulkActions([
-                \Filament\Actions\BulkActionGroup::make([
-                    \Filament\Actions\DeleteBulkAction::make()
-                        ->label('批量删除'),
-                ]),
-            ])
-            ->defaultSort('id')
-            ->paginated([10, 25, 50, 100]);
+        return TextbookTable::make($table);
     }
 
     public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder
@@ -230,6 +100,7 @@ class TextbookResource extends Resource
         return [
             'index' => Pages\ManageTextbooks::route('/'),
             'create' => Pages\CreateTextbook::route('/create'),
+            'view' => Pages\ViewTextbook::route('/{record}'),
             'edit' => Pages\EditTextbook::route('/{record}/edit'),
         ];
     }

+ 54 - 27
app/Filament/Resources/TextbookResource/Pages/EditTextbook.php

@@ -5,20 +5,23 @@ namespace App\Filament\Resources\TextbookResource\Pages;
 use App\Filament\Resources\TextbookResource;
 use App\Services\TextbookApiService;
 use App\Models\Textbook;
+use Filament\Forms;
 use Filament\Actions;
 use Filament\Resources\Pages\Page;
 use Illuminate\Http\Request;
+use Livewire\WithFileUploads;
 
-class EditTextbook extends Page
+class EditTextbook extends Page implements Forms\Contracts\HasForms
 {
+    use Forms\Concerns\InteractsWithForms;
+    use WithFileUploads;
+
     protected static string $resource = TextbookResource::class;
 
     public array $data = [];
 
     public ?int $recordId = null;
 
-    public ?\Filament\Forms\Form $form = null;
-
     public function mount(Request $request): void
     {
         // 从路由参数获取教材ID,避免Livewire的隐式绑定
@@ -38,6 +41,7 @@ class EditTextbook extends Page
 
         // 初始化表单数据
         $this->data = $textbookData;
+        $this->form->fill($this->data);
     }
 
     public function save(): void
@@ -108,9 +112,9 @@ class EditTextbook extends Page
     {
         return $form
             ->schema([
-                \Filament\Forms\Components\Section::make('基本信息')
+                Forms\Components\Section::make('基本信息')
                     ->schema([
-                        \Filament\Forms\Components\Select::make('data.series_id')
+                        Forms\Components\Select::make('data.series_id')
                             ->label('教材系列')
                             ->options(function () {
                                 $apiService = app(TextbookApiService::class);
@@ -124,7 +128,7 @@ class EditTextbook extends Page
                             ->required()
                             ->searchable(),
 
-                        \Filament\Forms\Components\Select::make('data.stage')
+                        Forms\Components\Select::make('data.stage')
                             ->label('学段')
                             ->options([
                                 'primary' => '小学',
@@ -134,12 +138,12 @@ class EditTextbook extends Page
                             ->required()
                             ->reactive(),
 
-                        \Filament\Forms\Components\TextInput::make('data.grade')
+                        Forms\Components\TextInput::make('data.grade')
                             ->label('年级')
                             ->numeric()
                             ->helperText('例如:7表示七年级'),
 
-                        \Filament\Forms\Components\Select::make('data.semester')
+                        Forms\Components\Select::make('data.semester')
                             ->label('学期')
                             ->options([
                                 1 => '上学期',
@@ -147,16 +151,16 @@ class EditTextbook extends Page
                             ])
                             ->helperText('选择学期'),
 
-                        \Filament\Forms\Components\TextInput::make('data.official_title')
+                        Forms\Components\TextInput::make('data.official_title')
                             ->label('教材名称')
                             ->required()
                             ->maxLength(255),
 
-                        \Filament\Forms\Components\TextInput::make('data.isbn')
+                        Forms\Components\TextInput::make('data.isbn')
                             ->label('ISBN')
                             ->maxLength(255),
 
-                        \Filament\Forms\Components\Select::make('data.status')
+                        Forms\Components\Select::make('data.status')
                             ->label('状态')
                             ->options([
                                 'draft' => '草稿',
@@ -166,23 +170,46 @@ class EditTextbook extends Page
                             ->required(),
                     ])
                     ->columns(2),
+                Forms\Components\Section::make('版本与系列')
+                    ->schema([
+                        Forms\Components\Select::make('data.naming_scheme')
+                            ->label('命名体系')
+                            ->options([
+                                'new' => '新体系',
+                                'old' => '旧体系',
+                            ]),
+                        Forms\Components\Select::make('data.track')
+                            ->label('方向')
+                            ->options([
+                                'science' => '理科',
+                                'liberal_arts' => '文科',
+                            ]),
+                        Forms\Components\Select::make('data.module_type')
+                            ->label('模块类型')
+                            ->options([
+                                'compulsory' => '必修',
+                                'selective_compulsory' => '选择性必修',
+                                'elective' => '选修',
+                            ]),
+                        Forms\Components\TextInput::make('data.volume_no')
+                            ->label('册次')
+                            ->numeric(),
+                        Forms\Components\TextInput::make('data.legacy_code')
+                            ->label('旧版代号'),
+                        Forms\Components\TextInput::make('data.edition_label')
+                            ->label('版本标识'),
+                    ])
+                    ->columns(2),
+                Forms\Components\Section::make('封面上传')
+                    ->schema([
+                        Forms\Components\FileUpload::make('data.cover_path')
+                            ->label('封面图片')
+                            ->image()
+                            ->directory('textbook-covers')
+                            ->helperText('建议尺寸 600x800,JPG/PNG'),
+                    ])
+                    ->extraAttributes(['id' => 'cover']),
             ])
             ->statePath('data');
     }
-
-    /**
-     * 获取表单实例供视图使用
-     */
-    public function getFormProperty(): string
-    {
-        // 直接渲染表单HTML
-        ob_start();
-        try {
-            $form = $this->form();
-            $form->render();
-        } catch (\Exception $e) {
-            // 忽略表单渲染错误
-        }
-        return ob_get_clean();
-    }
 }

+ 15 - 4
app/Filament/Resources/TextbookResource/Pages/ManageTextbooks.php

@@ -31,16 +31,27 @@ class ManageTextbooks extends ListRecords
         $apiService = app(TextbookApiService::class);
         $page = request()->get('page', 1);
         $perPage = $this->getTableRecordsPerPage();
+        $filters = request()->input('tableFilters', []);
+
+        $params = [
+            'page' => $page,
+            'per_page' => $perPage,
+            'stage' => $filters['stage']['value'] ?? null,
+            'grade' => $filters['grade']['value'] ?? null,
+            'semester' => $filters['semester']['value'] ?? null,
+            'naming_scheme' => $filters['naming_scheme']['value'] ?? null,
+            'status' => $filters['status']['value'] ?? null,
+            'keyword' => $filters['keyword']['value'] ?? null,
+        ];
+
+        $params = array_filter($params, fn ($value) => $value !== null && $value !== '');
 
         \Log::info('ManageTextbooks::getTableRecords called', [
             'page' => $page,
             'perPage' => $perPage
         ]);
 
-        $result = $apiService->getTextbooks([
-            'page' => $page,
-            'per_page' => $perPage,
-        ]);
+        $result = $apiService->getTextbooks($params);
 
         \Log::info('API result', [
             'total' => $result['meta']['total'] ?? 0,

+ 44 - 0
app/Filament/Resources/TextbookResource/Pages/ViewTextbook.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Filament\Resources\TextbookResource\Pages;
+
+use App\Filament\Resources\TextbookResource;
+use App\Models\SourcePaper;
+use App\Services\TextbookApiService;
+use Filament\Resources\Pages\ViewRecord;
+
+class ViewTextbook extends ViewRecord
+{
+    protected static string $resource = TextbookResource::class;
+
+    protected string $view = 'filament.resources.textbook-resource.view';
+
+    public array $catalogTree = [];
+
+    public array $linkedPapers = [];
+
+    public function mount(int|string $record): void
+    {
+        parent::mount($record);
+
+        $apiService = app(TextbookApiService::class);
+        $this->catalogTree = $apiService->getTextbookCatalog((int) $this->record->id, 'tree');
+
+        $seriesName = $this->record->series->name ?? null;
+        $this->linkedPapers = SourcePaper::query()
+            ->when($seriesName, fn ($query) => $query->where('textbook_series', $seriesName))
+            ->latest('updated_at')
+            ->take(8)
+            ->get()
+            ->map(fn ($paper) => [
+                'id' => $paper->id,
+                'title' => $paper->title ?: $paper->full_title ?: '未命名卷子',
+                'chapter' => $paper->chapter,
+                'grade' => $paper->grade,
+                'term' => $paper->term,
+                'source_type' => $paper->source_type,
+                'updated_at' => $paper->updated_at,
+            ])
+            ->toArray();
+    }
+}

+ 158 - 156
app/Filament/Resources/TextbookResource/Schemas/TextbookFormSchema.php

@@ -6,9 +6,9 @@ use App\Filament\Resources\TextbookResource;
 use App\Services\TextbookApiService;
 use Filament\Forms\Components\FileUpload;
 use Filament\Forms\Components\Select;
+use Filament\Forms\Components\Section;
 use Filament\Forms\Components\TextInput;
 use Filament\Forms\Components\Textarea;
-use Filament\Forms\Components\Toggle;
 use Filament\Schemas\Schema;
 
 class TextbookFormSchema
@@ -17,165 +17,167 @@ class TextbookFormSchema
     {
         return $schema
             ->schema([
-                Select::make('series_id')
-                    ->label('教材系列')
-                    ->options(function () {
-                        $series = app(TextbookApiService::class)->getTextbookSeries();
-                        $options = [];
-                        foreach ($series['data'] as $s) {
-                            $displayName = $s['name'];
-                            if (!empty($s['publisher'])) {
-                                $displayName .= ' (' . $s['publisher'] . ')';
-                            }
-                            $options[$s['id']] = $displayName;
-                        }
-                        return $options;
-                    })
-                    ->required()
-                    ->searchable()
-                    ->preload(),
-
-                Select::make('stage')
-                    ->label('学段')
-                    ->options([
-                        'primary' => '小学',
-                        'junior' => '初中',
-                        'senior' => '高中',
-                    ])
-                    ->default('junior')
-                    ->required()
-                    ->reactive(),
-
-                Select::make('schooling_system')
-                    ->label('学制')
-                    ->options([
-                        '63' => '六三学制',
-                        '54' => '五四学制',
-                    ])
-                    ->default('63')
-                    ->visible(fn ($get): bool => in_array($get('stage'), ['primary', 'junior'])),
-
-                TextInput::make('grade')
-                    ->label('年级')
-                    ->numeric()
-                    ->minValue(1)
-                    ->maxValue(12)
-                    ->helperText('数字1-12,例:1年级填1'),
-
-                Select::make('semester')
-                    ->label('学期')
-                    ->options([
-                        1 => '上学期',
-                        2 => '下学期',
+                Section::make('基本信息')
+                    ->schema([
+                        Select::make('series_id')
+                            ->label('教材系列')
+                            ->options(function () {
+                                $series = app(TextbookApiService::class)->getTextbookSeries();
+                                $options = [];
+                                foreach ($series['data'] as $s) {
+                                    $displayName = $s['name'];
+                                    if (!empty($s['publisher'])) {
+                                        $displayName .= ' (' . $s['publisher'] . ')';
+                                    }
+                                    $options[$s['id']] = $displayName;
+                                }
+                                return $options;
+                            })
+                            ->required()
+                            ->searchable()
+                            ->preload(),
+                        TextInput::make('official_title')
+                            ->label('教材名称')
+                            ->maxLength(512)
+                            ->helperText('教材名称(用户输入)'),
+                        TextInput::make('isbn')
+                            ->label('ISBN')
+                            ->maxLength(20),
+                        Textarea::make('aliases')
+                            ->label('别名')
+                            ->helperText('JSON 格式,如:["别名1", "别名2"]')
+                            ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
+                            ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
+                            ->columnSpanFull(),
                     ])
-                    ->required(),
-
-                Select::make('naming_scheme')
-                    ->label('命名体系')
-                    ->options([
-                        'new' => '新体系',
-                        'old' => '旧体系',
-                    ])
-                    ->default('new')
-                    ->required(),
-
-                Select::make('track')
-                    ->label('方向')
-                    ->options([
-                        'science' => '理科',
-                        'liberal_arts' => '文科',
+                    ->columns(2),
+
+                Section::make('学段/年级/学期')
+                    ->schema([
+                        Select::make('stage')
+                            ->label('学段')
+                            ->options([
+                                'primary' => '小学',
+                                'junior' => '初中',
+                                'senior' => '高中',
+                            ])
+                            ->default('junior')
+                            ->required()
+                            ->reactive(),
+                        Select::make('schooling_system')
+                            ->label('学制')
+                            ->options([
+                                '63' => '六三学制',
+                                '54' => '五四学制',
+                            ])
+                            ->default('63')
+                            ->visible(fn ($get): bool => in_array($get('stage'), ['primary', 'junior'])),
+                        TextInput::make('grade')
+                            ->label('年级')
+                            ->numeric()
+                            ->minValue(1)
+                            ->maxValue(12)
+                            ->helperText('数字1-12,例:1年级填1'),
+                        Select::make('semester')
+                            ->label('学期')
+                            ->options([
+                                1 => '上学期',
+                                2 => '下学期',
+                            ])
+                            ->required(),
                     ])
-                    ->visible(fn ($get): bool => $get('stage') === 'senior'),
-
-                Select::make('module_type')
-                    ->label('模块类型')
-                    ->options([
-                        'compulsory' => '必修',
-                        'selective_compulsory' => '选择性必修',
-                        'elective' => '选修',
+                    ->columns(2),
+
+                Section::make('版本与系列')
+                    ->schema([
+                        Select::make('naming_scheme')
+                            ->label('命名体系')
+                            ->options([
+                                'new' => '新体系',
+                                'old' => '旧体系',
+                            ])
+                            ->default('new')
+                            ->required(),
+                        Select::make('track')
+                            ->label('方向')
+                            ->options([
+                                'science' => '理科',
+                                'liberal_arts' => '文科',
+                            ])
+                            ->visible(fn ($get): bool => $get('stage') === 'senior'),
+                        Select::make('module_type')
+                            ->label('模块类型')
+                            ->options([
+                                'compulsory' => '必修',
+                                'selective_compulsory' => '选择性必修',
+                                'elective' => '选修',
+                            ])
+                            ->visible(fn ($get): bool => $get('stage') === 'senior'),
+                        TextInput::make('volume_no')
+                            ->label('册次')
+                            ->numeric()
+                            ->minValue(1)
+                            ->maxValue(3)
+                            ->helperText('数字1-3,例:第一册填1'),
+                        TextInput::make('legacy_code')
+                            ->label('旧版代号')
+                            ->helperText('旧教材编号,如:上册-1'),
+                        TextInput::make('curriculum_standard_year')
+                            ->label('课程标准年份')
+                            ->numeric()
+                            ->minValue(2000)
+                            ->maxValue(2099),
+                        TextInput::make('curriculum_revision_year')
+                            ->label('课程修订年份')
+                            ->numeric()
+                            ->minValue(2000)
+                            ->maxValue(2099),
+                        TextInput::make('approval_authority')
+                            ->label('审批机构')
+                            ->default('教育部'),
+                        TextInput::make('approval_year')
+                            ->label('审批年份')
+                            ->numeric()
+                            ->minValue(2000)
+                            ->maxValue(2099),
+                        TextInput::make('edition_label')
+                            ->label('版本标识')
+                            ->helperText('如:第一版、第二版'),
                     ])
-                    ->visible(fn ($get): bool => $get('stage') === 'senior'),
-
-                TextInput::make('volume_no')
-                    ->label('册次')
-                    ->numeric()
-                    ->minValue(1)
-                    ->maxValue(3)
-                    ->helperText('数字1-3,例:第一册填1'),
-
-                TextInput::make('legacy_code')
-                    ->label('旧版代号')
-                    ->helperText('旧教材编号,如:上册-1'),
-
-                TextInput::make('curriculum_standard_year')
-                    ->label('课程标准年份')
-                    ->numeric()
-                    ->minValue(2000)
-                    ->maxValue(2099),
-
-                TextInput::make('curriculum_revision_year')
-                    ->label('课程修订年份')
-                    ->numeric()
-                    ->minValue(2000)
-                    ->maxValue(2099),
-
-                TextInput::make('approval_authority')
-                    ->label('审批机构')
-                    ->default('教育部'),
-
-                TextInput::make('approval_year')
-                    ->label('审批年份')
-                    ->numeric()
-                    ->minValue(2000)
-                    ->maxValue(2099),
-
-                TextInput::make('edition_label')
-                    ->label('版本标识')
-                    ->helperText('如:第一版、第二版'),
-
-                TextInput::make('official_title')
-                    ->label('教材名称')
-                    ->maxLength(512)
-                    ->helperText('教材名称(用户输入)'),
-
-                Textarea::make('aliases')
-                    ->label('别名')
-                    ->helperText('JSON 格式,如:["别名1", "别名2"]')
-                    ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
-                    ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
-                    ->columnSpanFull(),
-
-                Select::make('status')
-                    ->label('状态')
-                    ->options([
-                        'draft' => '草稿',
-                        'published' => '已发布',
-                        'archived' => '已归档',
+                    ->columns(2),
+
+                Section::make('封面上传')
+                    ->schema([
+                        FileUpload::make('cover_path')
+                            ->label('封面图片')
+                            ->image()
+                            ->directory('textbook-covers'),
+                    ]),
+
+                Section::make('发布与排序')
+                    ->schema([
+                        Select::make('status')
+                            ->label('状态')
+                            ->options([
+                                'draft' => '草稿',
+                                'published' => '已发布',
+                                'archived' => '已归档',
+                            ])
+                            ->default('draft')
+                            ->required(),
+                        TextInput::make('sort_order')
+                            ->label('排序')
+                            ->numeric()
+                            ->default(0)
+                            ->helperText('数字越小排序越靠前'),
+                        Textarea::make('meta')
+                            ->label('扩展信息')
+                            ->helperText('JSON 格式')
+                            ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
+                            ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
+                            ->columnSpanFull(),
                     ])
-                    ->default('draft')
-                    ->required(),
-
-                TextInput::make('isbn')
-                    ->label('ISBN')
-                    ->maxLength(20),
-
-                FileUpload::make('cover_path')
-                    ->label('封面图片')
-                    ->image()
-                    ->directory('textbook-covers'),
-
-                TextInput::make('sort_order')
-                    ->label('排序')
-                    ->numeric()
-                    ->default(0)
-                    ->helperText('数字越小排序越靠前'),
-
-                Textarea::make('meta')
-                    ->label('扩展信息')
-                    ->helperText('JSON 格式')
-                    ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
-                    ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
-                    ->columnSpanFull(),
+                    ->columns(2),
             ])
             ->columns(2);
     }

+ 107 - 61
app/Filament/Resources/TextbookResource/Tables/TextbookTable.php

@@ -5,12 +5,17 @@ namespace App\Filament\Resources\TextbookResource\Tables;
 use App\Filament\Resources\TextbookResource;
 use Filament\Actions\EditAction;
 use Filament\Actions\Action;
+use Filament\Actions\BulkAction;
 use Filament\Tables;
-use Filament\Tables\Columns\BadgeColumn;
 use Filament\Tables\Columns\TextColumn;
 use Filament\Tables\Filters\SelectFilter;
 use Filament\Tables\Table;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use Filament\Tables\Enums\FiltersLayout;
+use Filament\Tables\Filters\Filter;
+use Filament\Forms\Components\TextInput;
 
 class TextbookTable
 {
@@ -19,80 +24,77 @@ class TextbookTable
         // 直接返回配置好的表格,不使用query()
         return $table
             ->columns([
-                TextColumn::make('id')
-                    ->label('ID')
-                    ->sortable(),
-
-                TextColumn::make('series.name')
-                    ->label('系列')
-                    ->searchable(),
-
                 TextColumn::make('official_title')
-                    ->label('官方书名')
-                    ->searchable()
-                    ->wrap(),
+                    ->label('教材信息')
+                    ->html()
+                    ->formatStateUsing(function ($state, Model $record): string {
+                        $cover = $record->cover_path ?? null;
+                        $coverUrl = null;
+                        if ($cover) {
+                            $coverUrl = Str::startsWith($cover, ['http://', 'https://', '/'])
+                                ? $cover
+                                : Storage::disk('public')->url($cover);
+                        }
 
-                TextColumn::make('stage')
-                    ->label('学段')
-                    ->formatStateUsing(function ($state): string {
-                        return match ($state) {
+                        $seriesName = $record->series->name ?? '未归类系列';
+                        $stage = match ($record->stage) {
                             'primary' => '小学',
                             'junior' => '初中',
                             'senior' => '高中',
-                            default => $state,
+                            default => $record->stage ?: '未标注',
                         };
-                    })
-                    ->badge()
-                    ->color('info'),
-
-                TextColumn::make('grade')
-                    ->label('年级')
-                    ->formatStateUsing(function ($state): string {
-                        return $state ? "{$state}年级" : '-';
-                    }),
-
-                TextColumn::make('semester')
-                    ->label('学期')
-                    ->formatStateUsing(function ($state): string {
-                        return match ($state) {
+                        $semester = match ($record->semester) {
                             1 => '上学期',
                             2 => '下学期',
-                            default => '-',
+                            default => '未标注',
                         };
-                    })
-                    ->badge()
-                    ->color('success'),
-
-                TextColumn::make('naming_scheme')
-                    ->label('体系')
-                    ->formatStateUsing(function ($state): string {
-                        return match ($state) {
+                        $naming = match ($record->naming_scheme) {
                             'new' => '新体系',
                             'old' => '旧体系',
-                            default => $state,
+                            default => $record->naming_scheme ?: '未标注',
                         };
-                    })
-                    ->badge(),
-
-                TextColumn::make('status')
-                    ->label('状态')
-                    ->formatStateUsing(function ($state): string {
-                        return match ($state) {
+                        $status = match ($record->status) {
                             'draft' => '草稿',
                             'published' => '已发布',
                             'archived' => '已归档',
-                            default => $state,
+                            default => $record->status ?: '未知',
                         };
-                    })
-                    ->badge()
-                    ->color(function ($state): string {
-                        return match ($state) {
-                            'draft' => 'gray',
-                            'published' => 'success',
-                            'archived' => 'danger',
-                            default => 'gray',
+
+                        $badgeTone = match ($record->status) {
+                            'published' => 'text-emerald-600 bg-emerald-50 border-emerald-100',
+                            'draft' => 'text-amber-600 bg-amber-50 border-amber-100',
+                            'archived' => 'text-slate-500 bg-slate-100 border-slate-200',
+                            default => 'text-slate-500 bg-slate-100 border-slate-200',
                         };
-                    }),
+
+                        $title = e($state ?: '未命名教材');
+
+                        $coverHtml = $coverUrl
+                            ? "<img src=\"{$coverUrl}\" alt=\"封面\" class=\"h-16 w-12 rounded-md border border-slate-200 object-cover\" />"
+                            : "<div class=\"flex h-16 w-12 items-center justify-center rounded-md border border-dashed border-slate-200 bg-slate-50 text-xs text-slate-400\">封面</div>";
+
+                        $gradeLabel = $record->grade ? "{$record->grade}年级" : '年级未标注';
+                        $isbnLabel = $record->isbn ?: '未填写';
+
+                        return <<<HTML
+<div class="flex items-start gap-4">
+    {$coverHtml}
+    <div class="min-w-0 flex-1">
+        <div class="flex flex-wrap items-center gap-2">
+            <div class="font-semibold text-slate-900">{$title}</div>
+            <span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs {$badgeTone}">{$status}</span>
+        </div>
+        <div class="mt-1 text-xs text-slate-500">{$seriesName} · {$stage} · {$gradeLabel} · {$semester}</div>
+        <div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">
+            <span class="ui-tag">体系:{$naming}</span>
+            <span class="ui-tag">ISBN:{$isbnLabel}</span>
+            <span class="ui-tag">ID:{$record->id}</span>
+        </div>
+    </div>
+</div>
+HTML;
+                    })
+                    ->wrap(),
 
                 TextColumn::make('created_at')
                     ->label('创建时间')
@@ -115,22 +117,52 @@ class TextbookTable
                         'senior' => '高中',
                     ]),
 
+                SelectFilter::make('grade')
+                    ->label('年级')
+                    ->options(collect(range(1, 12))->mapWithKeys(fn ($grade) => [$grade => "{$grade}年级"])->all()),
+
+                SelectFilter::make('semester')
+                    ->label('学期')
+                    ->options([
+                        1 => '上学期',
+                        2 => '下学期',
+                    ]),
+
+                SelectFilter::make('naming_scheme')
+                    ->label('教材体系')
+                    ->options([
+                        'new' => '新体系',
+                        'old' => '旧体系',
+                    ]),
+
                 SelectFilter::make('status')
-                    ->label('状态')
+                    ->label('发布状态')
                     ->options([
                         'draft' => '草稿',
                         'published' => '已发布',
                         'archived' => '已归档',
                     ]),
-            ])
+
+                Filter::make('keyword')
+                    ->label('关键词')
+                    ->form([
+                        TextInput::make('value')
+                            ->placeholder('教材名称 / ISBN / 系列'),
+                    ]),
+            ], layout: FiltersLayout::AboveContentCollapsible)
             ->actions([
                 EditAction::make()
-                    ->label('编辑'),
+                    ->label('编辑')
+                    ->icon('heroicon-o-pencil-square')
+                    ->iconButton()
+                    ->tooltip('编辑'),
 
                 Action::make('delete')
                     ->label('删除')
                     ->color('danger')
                     ->icon('heroicon-o-trash')
+                    ->iconButton()
+                    ->tooltip('删除')
                     ->requiresConfirmation()
                     ->modalHeading('删除教材')
                     ->modalDescription('确定要删除这个教材吗?此操作无法撤销。')
@@ -170,6 +202,8 @@ class TextbookTable
                 Action::make('view_catalog')
                     ->label('查看目录')
                     ->icon('heroicon-o-list-bullet')
+                    ->iconButton()
+                    ->tooltip('查看目录')
                     ->url(fn(Model $record): string =>
                         route('filament.admin.resources.textbook-catalogs.index', ['tableFilters[textbook_id][value]' => $record->id])
                     ),
@@ -178,8 +212,20 @@ class TextbookTable
                 \Filament\Actions\BulkActionGroup::make([
                     \Filament\Actions\DeleteBulkAction::make()
                         ->label('批量删除'),
+                    BulkAction::make('archive')
+                        ->label('批量归档')
+                        ->color('warning')
+                        ->icon('heroicon-o-archive-box')
+                        ->requiresConfirmation()
+                        ->action(function ($records): void {
+                            $apiService = app(\App\Services\TextbookApiService::class);
+                            foreach ($records as $record) {
+                                $apiService->updateTextbook((int) $record->id, ['status' => 'archived']);
+                            }
+                        }),
                 ]),
             ])
+            ->recordUrl(fn (Model $record): string => route('filament.admin.resources.textbooks.view', $record))
             ->defaultSort('sort_order')
             ->paginated([10, 25, 50, 100]);
     }

+ 50 - 0
app/Http/Controllers/ImportStreamController.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Filament\Facades\Filament;
+use Illuminate\Http\Request;
+use Illuminate\Http\StreamedResponse;
+use Illuminate\Support\Facades\Redis;
+
+class ImportStreamController extends Controller
+{
+    public function stream(Request $request): StreamedResponse
+    {
+        if (!Filament::auth()->check()) {
+            abort(403);
+        }
+
+        $type = $request->query('type', 'markdown-imports');
+        $importId = (int) $request->query('import_id', 0);
+        $channel = $type === 'pre-question-candidates' ? 'pre-question-candidates' : 'markdown-imports';
+
+        return response()->stream(function () use ($channel, $importId) {
+            echo "retry: 2000\n\n";
+            @ob_flush();
+            flush();
+
+            Redis::connection()->subscribe([$channel], function ($message) use ($importId) {
+                $payload = is_string($message) ? $message : json_encode($message, JSON_UNESCAPED_UNICODE);
+                $decoded = json_decode($payload, true);
+
+                if ($importId > 0 && is_array($decoded)) {
+                    $messageImportId = (int) ($decoded['import_id'] ?? 0);
+                    if ($messageImportId !== $importId) {
+                        return;
+                    }
+                }
+
+                echo "event: update\n";
+                echo 'data: ' . $payload . "\n\n";
+                @ob_flush();
+                flush();
+            });
+        }, 200, [
+            'Content-Type' => 'text/event-stream',
+            'Cache-Control' => 'no-cache',
+            'X-Accel-Buffering' => 'no',
+            'Connection' => 'keep-alive',
+        ]);
+    }
+}

+ 34 - 0
app/Providers/AppServiceProvider.php

@@ -4,6 +4,9 @@ namespace App\Providers;
 
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\Facades\URL;
+use Illuminate\Support\Facades\Redis;
+use App\Models\MarkdownImport;
+use App\Models\PreQuestionCandidate;
 
 class AppServiceProvider extends ServiceProvider
 {
@@ -33,5 +36,36 @@ class AppServiceProvider extends ServiceProvider
         if (config('app.env') === 'production') {
             URL::forceScheme('https');
         }
+
+        MarkdownImport::updated(function (MarkdownImport $import): void {
+            try {
+                Redis::publish('markdown-imports', json_encode([
+                    'type' => 'markdown-imports',
+                    'id' => $import->id,
+                    'status' => $import->status,
+                    'progress_stage' => $import->progress_stage,
+                    'progress_message' => $import->progress_message,
+                    'parsed_count' => $import->parsed_count ?? null,
+                    'accepted_count' => $import->accepted_count ?? null,
+                    'import_id' => $import->id,
+                ], JSON_UNESCAPED_UNICODE));
+            } catch (\Throwable $e) {
+                // SSE publish failed, ignore to avoid affecting business flow.
+            }
+        });
+
+        PreQuestionCandidate::updated(function (PreQuestionCandidate $candidate): void {
+            try {
+                Redis::publish('pre-question-candidates', json_encode([
+                    'type' => 'pre-question-candidates',
+                    'id' => $candidate->id,
+                    'import_id' => $candidate->import_id,
+                    'status' => $candidate->status,
+                    'is_question_candidate' => $candidate->is_question_candidate,
+                ], JSON_UNESCAPED_UNICODE));
+            } catch (\Throwable $e) {
+                // SSE publish failed, ignore to avoid affecting business flow.
+            }
+        });
     }
 }

+ 122 - 0
app/Services/PaperIdGenerator.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace App\Services;
+
+/**
+ * 试卷ID生成器
+ * 参考行业标准 Snowflake ID 思想
+ */
+class PaperIdGenerator
+{
+    // 基准时间:2020-01-01 00:00:00 (秒)
+    private const EPOCH = 1577836800;
+
+    // 12位数字的位分配(总计约2.8万亿个容量,足够使用200年)
+    // 格式:时间戳(8位) + 序列号(2位) + 随机数(2位) = 12位数字
+    private const TIMESTAMP_BITS = 35; // 时间戳(秒)- 可覆盖约34年
+    private const SEQUENCE_BITS = 11;  // 序列号 - 2048个值
+    private const RANDOM_BITS = 11;    // 随机数 - 2048个值
+
+    /**
+     * 生成12位数字ID
+     * 格式:TTTTTsssssR(时间戳+序列号+随机数)
+     *
+     * @return string 12位数字字符串
+     */
+    public static function generate(): string
+    {
+        // 使用时间戳(分钟)而不是秒,以减少位数
+        $timestamp = intdiv(time() - self::EPOCH, 60); // 从基准时间开始的分钟数
+        $timestamp &= (1 << self::TIMESTAMP_BITS) - 1; // 截取指定位数
+
+        // 同一分钟内使用序列号递增,避免并发重复
+        static $lastTimestamp = 0;
+        static $sequence = 0;
+
+        if ($timestamp == $lastTimestamp) {
+            $sequence = ($sequence + 1) & ((1 << self::SEQUENCE_BITS) - 1);
+            // 序列号用完,等待下一分钟
+            if ($sequence == 0) {
+                do {
+                    $timestamp = intdiv(time() - self::EPOCH, 60);
+                    $timestamp &= (1 << self::TIMESTAMP_BITS) - 1;
+                } while ($timestamp <= $lastTimestamp);
+            }
+        } else {
+            $sequence = 0;
+        }
+
+        $lastTimestamp = $timestamp;
+
+        // 添加随机数后缀避免猜测
+        $random = random_int(0, 99); // 2位随机数:00-99
+
+        // 组合:时间戳(8位) + 序列号(2位) + 随机数(2位) = 12位数字
+        // 时间戳占8位(不够前面补0)
+        $timestampStr = str_pad((string)$timestamp, 8, '0', STR_PAD_LEFT);
+        // 序列号占2位
+        $sequenceStr = str_pad((string)$sequence, 2, '0', STR_PAD_LEFT);
+        // 随机数占2位
+        $randomStr = str_pad((string)$random, 2, '0', STR_PAD_LEFT);
+
+        $id = $timestampStr . $sequenceStr . $randomStr;
+
+        // 确保第一位不为0
+        if ($id[0] === '0') {
+            $id[0] = '1';
+        }
+
+        return $id;
+    }
+
+    /**
+     * 验证12位数字ID格式
+     *
+     * @param string $id
+     * @return bool
+     */
+    public static function validate(string $id): bool
+    {
+        return preg_match('/^[1-9]\d{11}$/', $id) === 1;
+    }
+
+    /**
+     * 从ID提取时间戳
+     *
+     * @param string $id
+     * @return int|null
+     */
+    public static function extractTimestamp(string $id): ?int
+    {
+        if (!self::validate($id)) {
+            return null;
+        }
+
+        // 提取前8位时间戳(分钟),转换为秒
+        $timestampMinutes = (int)substr($id, 0, 8);
+        return $timestampMinutes * 60 + self::EPOCH;
+    }
+
+    /**
+     * 批量生成不重复的ID
+     *
+     * @param int $count
+     * @return array
+     */
+    public static function generateBatch(int $count): array
+    {
+        $ids = [];
+        $attempts = 0;
+        $maxAttempts = $count * 10;
+
+        while (count($ids) < $count && $attempts < $maxAttempts) {
+            $id = self::generate();
+            if (!in_array($id, $ids)) {
+                $ids[] = $id;
+            }
+            $attempts++;
+        }
+
+        return $ids;
+    }
+}

+ 3 - 4
app/Services/QuestionBankService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
+use App\Services\PaperIdGenerator;
 
 class QuestionBankService
 {
@@ -492,10 +493,8 @@ class QuestionBankService
         try {
             // 使用数据库事务确保数据一致性
             return \Illuminate\Support\Facades\DB::transaction(function () use ($examData) {
-                // 生成12位唯一数字ID(时间戳后8位 + 4位随机数)
-                $timestamp = substr((string)time(), -8); // 取时间戳后8位
-                $random = str_pad((string)random_int(0, 9999), 4, '0', STR_PAD_LEFT); // 4位随机数
-                $uniqueId = $timestamp . $random; // 12位唯一数字
+                // 使用行业标准的Snowflake ID生成12位唯一数字ID
+                $uniqueId = PaperIdGenerator::generate();
                 $paperId = 'paper_' . $uniqueId;
 
                 Log::info('开始保存试卷到数据库', [

+ 1 - 0
composer.json

@@ -16,6 +16,7 @@
         "laravel/tinker": "^2.10.1",
         "mpdf/mpdf": "*",
         "phpoffice/phpspreadsheet": "^5.3",
+        "predis/predis": "*",
         "thiagoalessio/tesseract_ocr": "^2.13"
     },
     "require-dev": {

+ 64 - 1
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "9673caf8b5ef6a9b35502ce4948a73e7",
+    "content-hash": "b95778c0d797fea3ef62efc4315fce10",
     "packages": [
         {
             "name": "adbario/php-dot-notation",
@@ -5947,6 +5947,69 @@
             },
             "time": "2025-09-19T23:02:26+00:00"
         },
+        {
+            "name": "predis/predis",
+            "version": "v3.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/predis/predis.git",
+                "reference": "153097374b39a2f737fe700ebcd725642526cdec"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/predis/predis/zipball/153097374b39a2f737fe700ebcd725642526cdec",
+                "reference": "153097374b39a2f737fe700ebcd725642526cdec",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0",
+                "psr/http-message": "^1.0|^2.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "^3.3",
+                "phpstan/phpstan": "^1.9",
+                "phpunit/phpcov": "^6.0 || ^8.0",
+                "phpunit/phpunit": "^8.0 || ~9.4.4"
+            },
+            "suggest": {
+                "ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Predis\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Till Krüss",
+                    "homepage": "https://till.im",
+                    "role": "Maintainer"
+                }
+            ],
+            "description": "A flexible and feature-complete Redis/Valkey client for PHP.",
+            "homepage": "http://github.com/predis/predis",
+            "keywords": [
+                "nosql",
+                "predis",
+                "redis"
+            ],
+            "support": {
+                "issues": "https://github.com/predis/predis/issues",
+                "source": "https://github.com/predis/predis/tree/v3.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sponsors/tillkruss",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-11-24T17:48:50+00:00"
+        },
         {
             "name": "psr/cache",
             "version": "3.0.0",

+ 24 - 2
config/database.php

@@ -60,7 +60,7 @@ return [
             'engine' => null,
             'options' => extension_loaded('pdo_mysql') ? array_filter([
                 PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
-                PDO::MYSQL_ATTR_INIT_COMMAND => "SET sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION'",
+                PDO::MYSQL_ATTR_INIT_COMMAND => "SET sql_mode='" . env('MYSQL_SQL_MODE', 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION') . "'",
                 PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
                 PDO::ATTR_TIMEOUT => env('DB_QUERY_TIMEOUT', 30),
                 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
@@ -87,7 +87,7 @@ return [
             'engine' => null,
             'options' => extension_loaded('pdo_mysql') ? array_filter([
                 PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
-                PDO::MYSQL_ATTR_INIT_COMMAND => "SET sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION'",
+                PDO::MYSQL_ATTR_INIT_COMMAND => "SET sql_mode='" . env('MYSQL_SQL_MODE', 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION') . "'",
                 PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
                 PDO::ATTR_TIMEOUT => env('DB_QUERY_TIMEOUT', 30),
                 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
@@ -131,6 +131,20 @@ return [
             'sslmode' => 'prefer',
         ],
 
+        'question_bank' => [
+            'driver' => 'pgsql',
+            'host' => env('QUESTION_BANK_DB_HOST', 'question_bank_pg'),
+            'port' => env('QUESTION_BANK_DB_PORT', '5432'),
+            'database' => env('QUESTION_BANK_DB_DATABASE', 'question_bank'),
+            'username' => env('QUESTION_BANK_DB_USERNAME', 'user'),
+            'password' => env('QUESTION_BANK_DB_PASSWORD', 'pass'),
+            'charset' => 'utf8',
+            'prefix' => '',
+            'prefix_indexes' => true,
+            'search_path' => 'public',
+            'sslmode' => 'prefer',
+        ],
+
         'sqlsrv' => [
             'driver' => 'sqlsrv',
             'url' => env('DB_URL'),
@@ -214,6 +228,14 @@ return [
             'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
         ],
 
+        // 虚拟连接 - 用于 API-only 模型,不执行任何 SQL
+        'virtual' => [
+            'driver' => 'sqlite',
+            'database' => ':memory:',
+            'prefix' => '',
+            'foreign_key_constraints' => false,
+        ],
+
     ],
 
 ];

+ 99 - 0
resources/css/app.css

@@ -37,6 +37,105 @@
 }
 
 @layer components {
+    /* UI baseline */
+    .ui-page {
+        @apply min-h-screen bg-slate-50;
+    }
+
+    .ui-header {
+        @apply flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white px-6 py-5 shadow-sm;
+    }
+
+    .ui-title {
+        @apply text-2xl font-semibold text-slate-900;
+    }
+
+    .ui-subtitle {
+        @apply text-sm text-slate-500;
+    }
+
+    .ui-section-title {
+        @apply text-lg font-semibold text-slate-900;
+    }
+
+    .ui-kicker {
+        @apply text-xs font-medium uppercase tracking-widest text-slate-400;
+    }
+
+    .ui-card {
+        @apply rounded-2xl border border-slate-200 bg-white shadow-sm;
+    }
+
+    .ui-card-header {
+        @apply flex items-center justify-between border-b border-slate-200 px-5 py-4;
+    }
+
+    .ui-card-body {
+        @apply px-5 py-4;
+    }
+
+    .ui-stat {
+        @apply rounded-xl border border-slate-200 bg-white px-4 py-4 shadow-sm;
+    }
+
+    .ui-stat-label {
+        @apply text-xs font-medium text-slate-500;
+    }
+
+    .ui-stat-value {
+        @apply mt-1 text-2xl font-semibold text-slate-900;
+    }
+
+    .ui-badge-muted {
+        @apply inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600;
+    }
+
+    .ui-action-group {
+        @apply inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white p-1;
+    }
+
+    .ui-action-icon {
+        @apply btn btn-ghost btn-sm h-8 w-8 min-h-0 p-0;
+    }
+
+    .ui-filter-bar {
+        @apply rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm;
+    }
+
+    .ui-empty {
+        @apply flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-10 text-center;
+    }
+
+    .ui-empty-title {
+        @apply text-base font-semibold text-slate-900;
+    }
+
+    .ui-empty-desc {
+        @apply text-sm text-slate-500;
+    }
+
+    /* Table density */
+    body[data-density="compact"] .table :where(th, td),
+    body[data-density="compact"] .fi-ta-table :where(th, td) {
+        padding-top: 0.4rem;
+        padding-bottom: 0.4rem;
+    }
+
+    body[data-density="comfortable"] .table :where(th, td),
+    body[data-density="comfortable"] .fi-ta-table :where(th, td) {
+        padding-top: 0.75rem;
+        padding-bottom: 0.75rem;
+    }
+
+    /* Standard table header */
+    .ui-table-head th {
+        @apply text-xs font-semibold uppercase tracking-wide text-slate-500;
+    }
+
+    /* Status tags */
+    .ui-tag {
+        @apply inline-flex items-center gap-1 rounded-full border border-slate-200 px-2.5 py-0.5 text-xs text-slate-600;
+    }
 
     /* 教材管理页面美化 */
     .textbook-page {

+ 10 - 0
resources/views/filament/custom/body-start.blade.php

@@ -1,6 +1,16 @@
 <!-- 自定义页面启动脚本 -->
 <script>
 document.addEventListener('DOMContentLoaded', function() {
+    const densityKey = 'filament_density';
+    const savedDensity = localStorage.getItem(densityKey) || 'comfortable';
+    document.body.dataset.density = savedDensity;
+
+    window.setTableDensity = function(nextDensity) {
+        const density = nextDensity === 'compact' ? 'compact' : 'comfortable';
+        document.body.dataset.density = density;
+        localStorage.setItem(densityKey, density);
+    };
+
     // 添加淡入动画
     const elements = document.querySelectorAll('.fi-main, .fi-topbar, .fi-sidebar');
     elements.forEach((el, index) => {

+ 165 - 197
resources/views/filament/pages/exam-history-simple.blade.php

@@ -1,226 +1,194 @@
-<div>
-    <div class="space-y-6">
-        <!-- 页面标题 -->
-        <div class="flex justify-between items-center">
-            <div>
-                <h2 class="text-2xl font-bold text-gray-900">卷子历史记录</h2>
-                <p class="mt-1 text-sm text-gray-500">
-                    查看所有历史生成的试卷,支持导出、复制和删除操作
-                </p>
+<div class="ui-page">
+    <div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-8">
+        @include('filament.partials.page-header', [
+            'kicker' => '卷子管理',
+            'title' => '卷子历史记录',
+            'subtitle' => '查看生成卷子、导出与编辑配置',
+            'actions' => new \Illuminate\Support\HtmlString(
+                '<a class="btn btn-primary" href="' . url('/admin/intelligent-exam-generation') . '">新建卷子</a>'
+                . view('filament.partials.density-toggle')->render()
+            ),
+        ])
+
+        @php
+            $total = \App\Models\Paper::count();
+            $draft = \App\Models\Paper::where('status', 'draft')->count();
+            $completed = \App\Models\Paper::where('status', 'completed')->count();
+            $graded = \App\Models\Paper::where('status', 'graded')->count();
+        @endphp
+
+        <div class="grid grid-cols-1 gap-4 md:grid-cols-4">
+            <div class="ui-stat">
+                <div class="ui-stat-label">总卷数</div>
+                <div class="ui-stat-value">{{ $total }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">草稿</div>
+                <div class="ui-stat-value text-amber-600">{{ $draft }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已完成</div>
+                <div class="ui-stat-value text-emerald-600">{{ $completed }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已评分</div>
+                <div class="ui-stat-value text-blue-600">{{ $graded }}</div>
             </div>
         </div>
 
-        <!-- 筛选器 - 使用 DaisyUI -->
-        <div class="card bg-base-100 shadow-xl">
-            <div class="card-body">
-                <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
-                    <input
-                        type="text"
-                        wire:model.live="search"
-                        placeholder="搜索试卷名称..."
-                        class="input input-bordered input-primary w-full"
-                    />
+        <div class="ui-filter-bar">
+            <div class="grid grid-cols-1 gap-4 md:grid-cols-4">
+                <input
+                    type="text"
+                    wire:model.live="search"
+                    placeholder="搜索试卷名称..."
+                    class="input input-bordered w-full"
+                />
 
-                    <select wire:model.live="statusFilter" class="select select-bordered select-primary w-full">
-                        <option value="">-- 全部状态 --</option>
-                        <option value="draft">草稿</option>
-                        <option value="completed">已完成</option>
-                        <option value="graded">已评分</option>
-                    </select>
+                <select wire:model.live="statusFilter" class="select select-bordered w-full">
+                    <option value="">-- 全部状态 --</option>
+                    <option value="draft">草稿</option>
+                    <option value="completed">已完成</option>
+                    <option value="graded">已评分</option>
+                </select>
 
-                    <select wire:model.live="difficultyFilter" class="select select-bordered select-primary w-full">
-                        <option value="">-- 全部难度 --</option>
-                        <option value="基础">基础</option>
-                        <option value="进阶">进阶</option>
-                        <option value="竞赛">竞赛</option>
-                    </select>
+                <select wire:model.live="difficultyFilter" class="select select-bordered w-full">
+                    <option value="">-- 全部难度 --</option>
+                    <option value="基础">基础</option>
+                    <option value="进阶">进阶</option>
+                    <option value="竞赛">竞赛</option>
+                </select>
 
-                    <button
-                        wire:click="$refresh"
-                        type="button"
-                        class="btn btn-outline btn-secondary">
-                        重置
-                    </button>
-                </div>
+                <button wire:click="$refresh" type="button" class="btn btn-secondary">重置</button>
             </div>
         </div>
 
-        <!-- 试卷列表 - 全宽表格视图 -->
-        <div class="w-full">
-            <div class="card bg-base-100 shadow-xl overflow-hidden">
-                <div class="overflow-x-auto">
-                    <table class="table table-zebra w-full">
-                        <thead>
+        <div class="ui-card">
+            <div class="ui-card-header">
+                <div>
+                    <div class="ui-section-title">卷子列表</div>
+                    <div class="ui-subtitle">卷名、状态、题量与操作入口</div>
+                </div>
+                <div class="ui-badge-muted">支持批量查看</div>
+            </div>
+            <div class="ui-card-body overflow-x-auto">
+                <table class="table w-full">
+                    <thead class="ui-table-head">
+                        <tr>
+                            <th>试卷名称</th>
+                            <th>状态</th>
+                            <th>难度</th>
+                            <th>题目/总分</th>
+                            <th>创建时间</th>
+                            <th class="text-right">操作</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        @forelse($this->exams()['data'] as $exam)
+                            <tr class="hover">
+                                <td>
+                                    <div class="font-semibold text-slate-900">{{ $exam['paper_name'] }}</div>
+                                    <div class="text-xs text-slate-400">{{ $exam['id'] }}</div>
+                                </td>
+                                <td>
+                                    <span class="badge badge-{{ $this->getStatusColor($exam['status']) }} badge-sm">
+                                        {{ $this->getStatusLabel($exam['status']) }}
+                                    </span>
+                                </td>
+                                <td>
+                                    <span class="badge badge-{{ $this->getDifficultyColor($exam['difficulty_category']) }} badge-sm">
+                                        {{ $exam['difficulty_category'] }}
+                                    </span>
+                                </td>
+                                <td>
+                                    <div class="text-sm">{{ $exam['question_count'] }} 题</div>
+                                    <div class="text-xs text-slate-400">{{ $exam['total_score'] }} 分</div>
+                                </td>
+                                <td class="text-sm">
+                                    {{ \Carbon\Carbon::parse($exam['created_at'])->format('Y-m-d H:i') }}
+                                </td>
+                                <td class="text-right">
+                                    <div class="inline-flex items-center gap-2">
+                                        <a href="{{ url('/admin/exam-detail?paperId=' . $exam['id']) }}" class="btn btn-ghost btn-xs">查看</a>
+                                        <button wire:click.stop="exportPdf('{{ $exam['id'] }}')" class="btn btn-outline btn-xs">导出</button>
+                                        <div class="dropdown dropdown-end">
+                                            <label tabindex="0" class="btn btn-ghost btn-xs">更多</label>
+                                            <ul tabindex="0" class="dropdown-content z-[1] menu rounded-box w-32 bg-base-100 p-2 shadow">
+                                                <li><button wire:click.stop="duplicateExam({{ json_encode($exam) }})">复制配置</button></li>
+                                                <li><button wire:click.stop="startEditExam('{{ $exam['id'] }}')">编辑</button></li>
+                                                <li><button class="text-error" wire:click.stop="deleteExam('{{ $exam['id'] }}')" wire:confirm="确定要删除这份试卷吗?此操作不可恢复!">删除</button></li>
+                                            </ul>
+                                        </div>
+                                    </div>
+                                </td>
+                            </tr>
+                        @empty
                             <tr>
-                                <th>试卷名称</th>
-                                <th>状态</th>
-                                <th>难度</th>
-                                <th>题目/总分</th>
-                                <th>创建时间</th>
-                                <th>操作</th>
+                                <td colspan="6" class="py-10">
+                                    @include('filament.partials.empty-state', [
+                                        'title' => '暂无试卷记录',
+                                        'description' => '请先生成卷子以便管理。',
+                                        'action' => new \Illuminate\Support\HtmlString('<a class="btn btn-primary btn-sm" href="' . url('/admin/intelligent-exam-generation') . '">去出卷</a>'),
+                                    ])
+                                </td>
                             </tr>
-                        </thead>
-                        <tbody>
-                            @forelse($this->exams()['data'] as $exam)
-                                <tr class="hover">
-                                    <td>
-                                        <div class="font-bold">{{ $exam['paper_name'] }}</div>
-                                        <div class="text-xs opacity-50">{{ $exam['id'] }}</div>
-                                    </td>
-                                    <td>
-                                        <span class="badge badge-{{ $this->getStatusColor($exam['status']) }} badge-sm">
-                                            {{ $this->getStatusLabel($exam['status']) }}
-                                        </span>
-                                    </td>
-                                    <td>
-                                        <span class="badge badge-{{ $this->getDifficultyColor($exam['difficulty_category']) }} badge-sm">
-                                            {{ $exam['difficulty_category'] }}
-                                        </span>
-                                    </td>
-                                    <td>
-                                        <div class="text-sm">{{ $exam['question_count'] }} 题</div>
-                                        <div class="text-xs opacity-50">{{ $exam['total_score'] }} 分</div>
-                                    </td>
-                                    <td class="text-sm">
-                                        {{ \Carbon\Carbon::parse($exam['created_at'])->format('Y-m-d H:i') }}
-                                    </td>
-                                    <td>
-                                        <div class="flex gap-2">
-                                            <a href="{{ url('/admin/exam-detail?paperId=' . $exam['id']) }}"
-                                               class="btn btn-ghost btn-xs tooltip"
-                                               data-tip="查看详情">
-                                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
-                                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
-                                                </svg>
-                                            </a>
-                                            <button
-                                                wire:click.stop="exportPdf('{{ $exam['id'] }}')"
-                                                class="btn btn-ghost btn-xs tooltip"
-                                                data-tip="导出PDF">
-                                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
-                                            </button>
-                                            <button
-                                                wire:click.stop="duplicateExam({{ json_encode($exam) }})"
-                                                class="btn btn-ghost btn-xs tooltip"
-                                                data-tip="复制配置">
-                                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
-                                            </button>
-                                            <button
-                                                wire:click.stop="startEditExam('{{ $exam['id'] }}')"
-                                                class="btn btn-ghost btn-xs tooltip"
-                                                data-tip="编辑试卷">
-                                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
-                                            </button>
-                                            <button
-                                                wire:click.stop="deleteExam('{{ $exam['id'] }}')"
-                                                wire:confirm="确定要删除这份试卷吗?此操作不可恢复!"
-                                                class="btn btn-ghost btn-xs tooltip text-error"
-                                                data-tip="删除试卷">
-                                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
-                                            </button>
-                                        </div>
-                                    </td>
-                                </tr>
-                            @empty
-                                <tr>
-                                    <td colspan="6" class="text-center py-8">
-                                        <div class="flex flex-col items-center justify-center text-gray-500">
-                                            <svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
-                                            </svg>
-                                            <p>暂无试卷记录</p>
-                                            <a href="{{ url('/admin/intelligent-exam-generation') }}" class="btn btn-primary btn-sm mt-2">
-                                                去出卷
-                                            </a>
-                                        </div>
-                                    </td>
-                                </tr>
-                            @endforelse
-                        </tbody>
-                    </table>
-                </div>
-
-                <!-- 分页 -->
-                <div class="p-4 border-t">
-                    <div class="flex justify-between items-center">
-                        <div class="text-sm text-gray-500">
-                            共 {{ $this->meta()['total'] }} 条记录
-                        </div>
-                        <div class="join">
-                            <button class="join-item btn btn-sm" wire:click="$set('currentPage', {{ max(1, $this->currentPage - 1) }})" {{ $this->currentPage <= 1 ? 'disabled' : '' }}>«</button>
-                            <button class="join-item btn btn-sm">第 {{ $this->currentPage }} 页</button>
-                            <button class="join-item btn btn-sm" wire:click="$set('currentPage', {{ $this->currentPage + 1 }})" {{ $this->currentPage >= $this->meta()['total_pages'] ? 'disabled' : '' }}>»</button>
-                        </div>
+                        @endforelse
+                    </tbody>
+                </table>
+            </div>
+            <div class="border-t border-slate-200 px-4 py-3">
+                <div class="flex items-center justify-between">
+                    <div class="text-sm text-slate-500">共 {{ $this->meta()['total'] }} 条记录</div>
+                    <div class="join">
+                        <button class="join-item btn btn-sm" wire:click="$set('currentPage', {{ max(1, $this->currentPage - 1) }})" {{ $this->currentPage <= 1 ? 'disabled' : '' }}>«</button>
+                        <button class="join-item btn btn-sm">第 {{ $this->currentPage }} 页</button>
+                        <button class="join-item btn btn-sm" wire:click="$set('currentPage', {{ $this->currentPage + 1 }})" {{ $this->currentPage >= $this->meta()['total_pages'] ? 'disabled' : '' }}>»</button>
                     </div>
                 </div>
             </div>
         </div>
     </div>
 
-    {{-- 编辑试卷模态框 --}}
     @if($editingExamId)
-    <div class="modal modal-open">
-        <div class="modal-box">
-            <h3 class="font-bold text-lg mb-4">编辑试卷</h3>
-            
-            <div class="space-y-4">
-                <div class="form-control">
-                    <label class="label">
-                        <span class="label-text">试卷名称</span>
-                    </label>
-                    <input type="text" wire:model="editForm.paper_name" 
-                           class="input input-bordered input-primary" 
-                           placeholder="请输入试卷名称" />
-                    @error('editForm.paper_name')
+        <div class="modal modal-open">
+            <div class="modal-box">
+                <h3 class="text-lg font-semibold text-slate-900">编辑试卷</h3>
+                <div class="mt-4 space-y-4">
+                    <div class="form-control">
                         <label class="label">
-                            <span class="label-text-alt text-error">{{ $message }}</span>
+                            <span class="label-text">试卷名称</span>
                         </label>
-                    @enderror
-                </div>
-                
-                <div class="form-control">
-                    <label class="label">
-                        <span class="label-text">难度分类</span>
-                    </label>
-                    <select wire:model="editForm.difficulty_category" 
-                            class="select select-bordered select-primary">
-                        <option value="">-- 请选择难度 --</option>
-                        <option value="基础">基础</option>
-                        <option value="进阶">进阶</option>
-                        <option value="竞赛">竞赛</option>
-                    </select>
-                    @error('editForm.difficulty_category')
+                        <input type="text" wire:model="editForm.paper_name" class="input input-bordered" placeholder="请输入试卷名称" />
+                    </div>
+                    <div class="form-control">
                         <label class="label">
-                            <span class="label-text-alt text-error">{{ $message }}</span>
+                            <span class="label-text">难度分类</span>
                         </label>
-                    @enderror
-                </div>
-                
-                <div class="form-control">
-                    <label class="label">
-                        <span class="label-text">状态</span>
-                    </label>
-                    <select wire:model="editForm.status" 
-                            class="select select-bordered select-primary">
-                        <option value="">-- 请选择状态 --</option>
-                        <option value="draft">草稿</option>
-                        <option value="completed">已完成</option>
-                        <option value="graded">已评分</option>
-                    </select>
-                    @error('editForm.status')
+                        <select wire:model="editForm.difficulty_category" class="select select-bordered">
+                            <option value="">-- 请选择难度 --</option>
+                            <option value="基础">基础</option>
+                            <option value="进阶">进阶</option>
+                            <option value="竞赛">竞赛</option>
+                        </select>
+                    </div>
+                    <div class="form-control">
                         <label class="label">
-                            <span class="label-text-alt text-error">{{ $message }}</span>
+                            <span class="label-text">状态</span>
                         </label>
-                    @enderror
+                        <select wire:model="editForm.status" class="select select-bordered">
+                            <option value="">-- 请选择状态 --</option>
+                            <option value="draft">草稿</option>
+                            <option value="completed">已完成</option>
+                            <option value="graded">已评分</option>
+                        </select>
+                    </div>
+                </div>
+                <div class="modal-action">
+                    <button wire:click="cancelEdit" class="btn btn-ghost">取消</button>
+                    <button wire:click="saveExamEdit" class="btn btn-primary">保存</button>
                 </div>
-            </div>
-            
-            <div class="modal-action">
-                <button wire:click="cancelEdit" class="btn btn-ghost">取消</button>
-                <button wire:click="saveExamEdit" class="btn btn-primary">保存</button>
             </div>
         </div>
-    </div>
     @endif
+    @include('filament.partials.loading-overlay')
 </div>

+ 18 - 0
resources/views/filament/partials/catalog-tree.blade.php

@@ -0,0 +1,18 @@
+@php
+    $nodes = $nodes ?? [];
+@endphp
+<ul class="space-y-2">
+    @foreach($nodes as $node)
+        <li>
+            <div class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
+                <span class="ui-badge-muted">{{ $node['level'] ?? '' }}</span>
+                <span class="font-medium">{{ $node['title'] ?? '未命名章节' }}</span>
+            </div>
+            @if(!empty($node['children']))
+                <div class="ml-4 mt-2 border-l border-slate-200 pl-4">
+                    @include('filament.partials.catalog-tree', ['nodes' => $node['children']])
+                </div>
+            @endif
+        </li>
+    @endforeach
+</ul>

+ 7 - 0
resources/views/filament/partials/density-toggle.blade.php

@@ -0,0 +1,7 @@
+<div class="inline-flex items-center gap-2">
+    <span class="text-xs text-slate-500">密度</span>
+    <div class="join">
+        <button type="button" class="join-item btn btn-xs btn-outline" onclick="setTableDensity('comfortable')">舒适</button>
+        <button type="button" class="join-item btn btn-xs btn-outline" onclick="setTableDensity('compact')">紧凑</button>
+    </div>
+</div>

+ 16 - 0
resources/views/filament/partials/empty-state.blade.php

@@ -0,0 +1,16 @@
+<div class="ui-empty">
+    <div class="rounded-full bg-slate-100 p-3 text-slate-500">
+        {!! $icon ?? '<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>' !!}
+    </div>
+    <div>
+        <div class="ui-empty-title">{{ $title ?? '暂无数据' }}</div>
+        @if(!empty($description))
+            <div class="ui-empty-desc">{{ $description }}</div>
+        @endif
+    </div>
+    @if(!empty($action))
+        <div>
+            {{ $action }}
+        </div>
+    @endif
+</div>

+ 9 - 0
resources/views/filament/partials/loading-overlay.blade.php

@@ -0,0 +1,9 @@
+@php
+    $target = $target ?? null;
+@endphp
+<div wire:loading.delay.longer @if($target) wire:target="{{ $target }}" @endif class="fixed inset-0 z-50 flex items-center justify-center bg-white/70">
+    <div class="flex items-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow">
+        <span class="loading loading-spinner loading-sm"></span>
+        <span>正在处理,请稍候…</span>
+    </div>
+</div>

+ 21 - 0
resources/views/filament/partials/page-header.blade.php

@@ -0,0 +1,21 @@
+<div class="ui-header">
+    <div class="flex flex-wrap items-start justify-between gap-4">
+        <div>
+            <div class="ui-kicker">{{ $kicker ?? '管理台' }}</div>
+            <h1 class="ui-title">{{ $title }}</h1>
+            @if(!empty($subtitle))
+                <p class="ui-subtitle">{{ $subtitle }}</p>
+            @endif
+        </div>
+        @if(!empty($actions))
+            <div class="flex flex-wrap items-center gap-2">
+                {{ $actions }}
+            </div>
+        @endif
+    </div>
+    @if(!empty($slot))
+        <div>
+            {{ $slot }}
+        </div>
+    @endif
+</div>

+ 48 - 0
resources/views/filament/resources/markdown-import-resource/pages/create-markdown-import.blade.php

@@ -0,0 +1,48 @@
+<div class="ui-page">
+    <div class="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8" x-data="{ step: 1 }">
+        @include('filament.partials.page-header', [
+            'kicker' => 'Markdown 导入',
+            'title' => 'Step 1 · 上传与基础配置',
+            'subtitle' => '上传 Markdown 文件或粘贴内容,系统将自动读取并创建导入记录。',
+            'actions' => new \Illuminate\Support\HtmlString('<a class="btn btn-outline" href="' . route('filament.admin.resources.markdown-imports.index') . '">返回列表</a>'),
+        ])
+
+        <div class="ui-card">
+            <div class="ui-card-body">
+                <x-filament::form wire:submit="create">
+                    {{ $this->form }}
+                    <div class="mt-6 flex items-center justify-between">
+                        <div class="text-xs text-slate-500">支持 md / txt / zip,单文件最大 10MB。</div>
+                        <button type="submit" class="btn btn-primary">开始解析</button>
+                    </div>
+                </x-filament::form>
+            </div>
+        </div>
+
+        <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
+            <div class="ui-card">
+                <div class="ui-card-header">
+                    <div>
+                        <div class="ui-section-title">Step 2 · 结构识别与预览</div>
+                        <div class="ui-subtitle">创建记录后进入校对页面查看解析结果</div>
+                    </div>
+                </div>
+                <div class="ui-card-body text-sm text-slate-500">
+                    完成上传后,在导入列表中点击「进入校对」即可查看树状结构与题目预览。
+                </div>
+            </div>
+            <div class="ui-card">
+                <div class="ui-card-header">
+                    <div>
+                        <div class="ui-section-title">Step 3 · 审核入库</div>
+                        <div class="ui-subtitle">批量确认题目后一键入库</div>
+                    </div>
+                </div>
+                <div class="ui-card-body text-sm text-slate-500">
+                    校对完成后使用批量操作「入库到筛选库」,系统会生成完成报告。
+                </div>
+            </div>
+        </div>
+    </div>
+    @include('filament.partials.loading-overlay')
+</div>

+ 125 - 0
resources/views/filament/resources/markdown-import-resource/pages/list-markdown-imports.blade.php

@@ -0,0 +1,125 @@
+<div class="ui-page">
+    <div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-8">
+        @include('filament.partials.page-header', [
+            'kicker' => 'Markdown 导入',
+            'title' => 'Markdown 试卷导入管理',
+            'subtitle' => '上传 Markdown 文件,解析题目结构并进入人工校对',
+            'actions' => new \Illuminate\Support\HtmlString(
+                '<a class="btn btn-primary" href="' . route('filament.admin.resources.markdown-imports.create') . '">开始导入</a>'
+                . view('filament.partials.density-toggle')->render()
+            ),
+        ])
+
+        @php
+            $total = \App\Models\MarkdownImport::count();
+            $processing = \App\Models\MarkdownImport::where('status', 'processing')->count();
+            $parsed = \App\Models\MarkdownImport::where('status', 'parsed')->count();
+            $reviewed = \App\Models\MarkdownImport::where('status', 'reviewed')->count();
+            $completed = \App\Models\MarkdownImport::where('status', 'completed')->count();
+            $failed = \App\Models\MarkdownImport::where('status', 'failed')->count();
+        @endphp
+
+        <div class="grid grid-cols-1 gap-4 md:grid-cols-3 xl:grid-cols-6">
+            <div class="ui-stat">
+                <div class="ui-stat-label">导入总数</div>
+                <div class="ui-stat-value">{{ $total }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">处理中</div>
+                <div class="ui-stat-value text-amber-600">{{ $processing }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">待校对</div>
+                <div class="ui-stat-value text-blue-600">{{ $parsed }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已校对</div>
+                <div class="ui-stat-value text-indigo-600">{{ $reviewed }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已完成</div>
+                <div class="ui-stat-value text-emerald-600">{{ $completed }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">失败</div>
+                <div class="ui-stat-value text-rose-600">{{ $failed }}</div>
+            </div>
+        </div>
+
+        <div class="ui-card">
+            <div class="ui-card-body">
+                <div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
+                    <div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
+                        <div class="ui-kicker">Step 1</div>
+                        <div class="mt-1 text-sm font-semibold text-slate-900">上传 Markdown</div>
+                        <p class="mt-2 text-sm text-slate-500">支持 md / txt / zip,自动提取教材信息。</p>
+                    </div>
+                    <div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
+                        <div class="ui-kicker">Step 2</div>
+                        <div class="mt-1 text-sm font-semibold text-slate-900">结构识别与预览</div>
+                        <p class="mt-2 text-sm text-slate-500">查看卷子 → 区块 → 题目树结构,并高亮异常。</p>
+                    </div>
+                    <div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
+                        <div class="ui-kicker">Step 3</div>
+                        <div class="mt-1 text-sm font-semibold text-slate-900">审核入库</div>
+                        <p class="mt-2 text-sm text-slate-500">批量确认后入库,自动生成完成报告。</p>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <div class="ui-card">
+            <div class="ui-card-header">
+                <div>
+                    <div class="ui-section-title">导入记录</div>
+                    <div class="ui-subtitle">查看处理进度与进入校对</div>
+                </div>
+                <div class="ui-badge-muted">自动刷新进度</div>
+            </div>
+            <div class="ui-card-body">
+                {{ $this->table }}
+            </div>
+        </div>
+    </div>
+    @include('filament.partials.loading-overlay', [
+        'target' => 'tableFilters,tableSort,tableSearch,tableColumns,tableRecordsPerPage,tablePagination,run_pipeline,parse,review,delete,edit',
+    ])
+</div>
+@push('scripts')
+<script>
+    document.addEventListener('DOMContentLoaded', () => {
+        const source = new EventSource('/admin/markdown-imports/stream?type=markdown-imports');
+        let refreshTimer = null;
+
+        const refreshLivewire = () => {
+            if (refreshTimer) return;
+            refreshTimer = setTimeout(() => {
+                refreshTimer = null;
+            }, 800);
+
+            if (!window.Livewire || !Livewire.find) {
+                return;
+            }
+
+            document.querySelectorAll('[wire\\:id]').forEach((el) => {
+                const id = el.getAttribute('wire:id');
+                const component = id ? Livewire.find(id) : null;
+                if (component && typeof component.$refresh === 'function') {
+                    component.$refresh();
+                }
+            });
+        };
+
+        source.addEventListener('update', (event) => {
+            try {
+                JSON.parse(event.data || '{}');
+            } catch (e) {
+                return;
+            }
+            refreshLivewire();
+        });
+
+        window.addEventListener('beforeunload', () => source.close());
+    });
+</script>
+@endpush

+ 134 - 0
resources/views/filament/resources/pre-question-candidate-resource/pages/list-pre-question-candidates.blade.php

@@ -0,0 +1,134 @@
+<div class="ui-page">
+    <div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-8">
+        @include('filament.partials.page-header', [
+            'kicker' => 'Markdown 导入',
+            'title' => 'Step 2 · 结构识别与预览',
+            'subtitle' => '左侧查看卷子结构,右侧进行题目校对与批量入库。',
+            'actions' => view('filament.partials.density-toggle'),
+        ])
+
+        <div class="grid grid-cols-1 gap-4 md:grid-cols-3 xl:grid-cols-5">
+            <div class="ui-stat">
+                <div class="ui-stat-label">总题数</div>
+                <div class="ui-stat-value">{{ $this->summaryStats['total'] ?? 0 }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">待审核</div>
+                <div class="ui-stat-value text-amber-600">{{ $this->summaryStats['pending'] ?? 0 }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已审核</div>
+                <div class="ui-stat-value text-blue-600">{{ $this->summaryStats['reviewed'] ?? 0 }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已接受</div>
+                <div class="ui-stat-value text-emerald-600">{{ $this->summaryStats['accepted'] ?? 0 }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已拒绝</div>
+                <div class="ui-stat-value text-rose-600">{{ $this->summaryStats['rejected'] ?? 0 }}</div>
+            </div>
+        </div>
+
+        <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
+            <div class="lg:col-span-4 space-y-6">
+                <div class="ui-card">
+                    <div class="ui-card-header">
+                        <div>
+                            <div class="ui-section-title">卷子结构</div>
+                            <div class="ui-subtitle">source_papers → paper_parts</div>
+                        </div>
+                    </div>
+                    <div class="ui-card-body space-y-4">
+                        @forelse($this->paperTree as $paper)
+                            <div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
+                                <div class="text-sm font-semibold text-slate-900">{{ $paper['title'] }}</div>
+                                <div class="mt-2 space-y-2">
+                                    @foreach($paper['parts'] ?? [] as $part)
+                                        <div class="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm">
+                                            <span class="text-slate-700">{{ $part['title'] }}</span>
+                                            <span class="ui-badge-muted">{{ $part['count'] }} 题</span>
+                                        </div>
+                                    @endforeach
+                                </div>
+                            </div>
+                        @empty
+                            @include('filament.partials.empty-state', [
+                                'title' => '暂无解析结构',
+                                'description' => '请先完成 Markdown 解析流程。',
+                            ])
+                        @endforelse
+                    </div>
+                </div>
+
+                <div class="ui-card">
+                    <div class="ui-card-header">
+                        <div>
+                            <div class="ui-section-title">Step 3 · 审核入库</div>
+                            <div class="ui-subtitle">使用批量操作提交入库</div>
+                        </div>
+                    </div>
+                    <div class="ui-card-body text-sm text-slate-500">
+                        选中候选题后,使用批量操作「入库到筛选库」完成审核流程。
+                    </div>
+                </div>
+            </div>
+
+            <div class="lg:col-span-8">
+                <div class="ui-card">
+                    <div class="ui-card-header">
+                        <div>
+                            <div class="ui-section-title">题目预览与校对</div>
+                            <div class="ui-subtitle">右侧支持编辑与状态标记</div>
+                        </div>
+                        <div class="ui-badge-muted">支持批量操作</div>
+                    </div>
+                    <div class="ui-card-body">
+                        {{ $this->table }}
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    @include('filament.partials.loading-overlay')
+</div>
+@push('scripts')
+<script>
+    document.addEventListener('DOMContentLoaded', () => {
+        const params = new URLSearchParams(window.location.search);
+        const importId = params.get('import_id') || '';
+        const source = new EventSource(`/admin/markdown-imports/stream?type=pre-question-candidates&import_id=${importId}`);
+        let refreshTimer = null;
+
+        const refreshLivewire = () => {
+            if (refreshTimer) return;
+            refreshTimer = setTimeout(() => {
+                refreshTimer = null;
+            }, 800);
+
+            if (!window.Livewire || !Livewire.find) {
+                return;
+            }
+
+            document.querySelectorAll('[wire\\:id]').forEach((el) => {
+                const id = el.getAttribute('wire:id');
+                const component = id ? Livewire.find(id) : null;
+                if (component && typeof component.$refresh === 'function') {
+                    component.$refresh();
+                }
+            });
+        };
+
+        source.addEventListener('update', (event) => {
+            try {
+                JSON.parse(event.data || '{}');
+            } catch (e) {
+                return;
+            }
+            refreshLivewire();
+        });
+
+        window.addEventListener('beforeunload', () => source.close());
+    });
+</script>
+@endpush

+ 50 - 0
resources/views/filament/resources/source-paper-resource/pages/list-source-papers.blade.php

@@ -0,0 +1,50 @@
+<div class="ui-page">
+    <div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-8">
+        @include('filament.partials.page-header', [
+            'kicker' => '卷子管理',
+            'title' => '源卷子列表',
+            'subtitle' => '基于 Markdown 拆分的卷子结构与题型区块管理',
+            'actions' => view('filament.partials.density-toggle'),
+        ])
+
+        @php
+            $total = \App\Models\SourcePaper::count();
+            $draft = \App\Models\SourcePaper::doesntHave('parts')->count();
+            $completed = \App\Models\SourcePaper::has('parts')->has('candidates')->count();
+            $grading = \App\Models\SourcePaper::whereHas('candidates', fn ($q) => $q->where('status', 'pending'))->count();
+        @endphp
+
+        <div class="grid grid-cols-1 gap-4 md:grid-cols-4">
+            <div class="ui-stat">
+                <div class="ui-stat-label">总卷数</div>
+                <div class="ui-stat-value">{{ $total }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">草稿</div>
+                <div class="ui-stat-value text-amber-600">{{ $draft }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已完成</div>
+                <div class="ui-stat-value text-emerald-600">{{ $completed }}</div>
+            </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">评分中</div>
+                <div class="ui-stat-value text-blue-600">{{ $grading }}</div>
+            </div>
+        </div>
+
+        <div class="ui-card">
+            <div class="ui-card-header">
+                <div>
+                    <div class="ui-section-title">卷子列表</div>
+                    <div class="ui-subtitle">卷名、来源文件、题量与区块信息</div>
+                </div>
+                <div class="ui-badge-muted">支持 Tag + Range 过滤</div>
+            </div>
+            <div class="ui-card-body">
+                {{ $this->table }}
+            </div>
+        </div>
+    </div>
+    @include('filament.partials.loading-overlay')
+</div>

+ 84 - 0
resources/views/filament/resources/source-paper-resource/pages/view-source-paper.blade.php

@@ -0,0 +1,84 @@
+<div class="ui-page">
+    <div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-8" x-data="{ expandAll: true }">
+        @include('filament.partials.page-header', [
+            'kicker' => '卷子详情',
+            'title' => $this->record->title ?? '源卷子详情',
+            'subtitle' => '查看卷子元信息与题型区块',
+            'actions' => new \Illuminate\Support\HtmlString('<a class="btn btn-outline" href="' . route('filament.admin.resources.source-papers.index') . '">返回列表</a>'),
+        ])
+
+        <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
+            <div class="space-y-6">
+                <div class="ui-card">
+                    <div class="ui-card-header">
+                        <div class="ui-section-title">卷子元数据</div>
+                    </div>
+                    <div class="ui-card-body space-y-3 text-sm text-slate-600">
+                        <div class="ui-badge-muted">来源文件:{{ $this->record->file?->original_filename ?? '未知' }}</div>
+                        <div class="ui-badge-muted">来源类型:{{ $this->record->source_type ?? '未标注' }}</div>
+                        <div class="ui-badge-muted">章节:{{ $this->record->chapter ?? '未标注' }}</div>
+                        <div class="ui-badge-muted">年级:{{ $this->record->grade ?? '未标注' }}</div>
+                        <div class="ui-badge-muted">学期:{{ $this->record->term ?? '未标注' }}</div>
+                        <div class="ui-badge-muted">教材系列:{{ $this->record->textbook_series ?? '未标注' }}</div>
+                    </div>
+                </div>
+
+                <div class="ui-card">
+                    <div class="ui-card-header">
+                        <div>
+                            <div class="ui-section-title">快速操作</div>
+                            <div class="ui-subtitle">批量展开或收起区块</div>
+                        </div>
+                    </div>
+                    <div class="ui-card-body flex gap-2">
+                        <button class="btn btn-secondary btn-sm" @click="expandAll = true">展开全部</button>
+                        <button class="btn btn-ghost btn-sm" @click="expandAll = false">收起全部</button>
+                    </div>
+                </div>
+            </div>
+
+            <div class="lg:col-span-2">
+                <div class="ui-card">
+                    <div class="ui-card-header">
+                        <div>
+                            <div class="ui-section-title">题型区块列表</div>
+                            <div class="ui-subtitle">paper_parts 结构预览</div>
+                        </div>
+                    </div>
+                    <div class="ui-card-body space-y-4">
+                        @forelse($this->paperParts as $part)
+                            <div class="rounded-2xl border border-slate-200 bg-white" x-data="{ open: true }">
+                                <div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
+                                    <div>
+                                        <div class="text-sm font-semibold text-slate-900">{{ $part['title'] }}</div>
+                                        <div class="mt-1 flex flex-wrap gap-2 text-xs text-slate-500">
+                                            <span class="ui-tag">题型:{{ $part['type'] }}</span>
+                                            <span class="ui-tag">题量:{{ $part['question_count'] }}</span>
+                                            <span class="ui-tag">候选题:{{ $part['candidate_count'] }}</span>
+                                            @if($part['has_error'])
+                                                <span class="ui-tag text-rose-600 border-rose-200 bg-rose-50">解析异常</span>
+                                            @endif
+                                        </div>
+                                    </div>
+                                    <div class="flex items-center gap-2">
+                                        <a class="btn btn-outline btn-xs" href="{{ route('filament.admin.resources.paper-parts.view', ['record' => $part['id']]) }}">查看题目</a>
+                                        <button class="btn btn-ghost btn-xs" @click="open = !open" x-text="open ? '收起' : '展开'"></button>
+                                    </div>
+                                </div>
+                                <div class="border-t border-slate-200 px-4 py-3 text-sm text-slate-500" x-show="expandAll && open">
+                                    <div class="line-clamp-3">{{ \Illuminate\Support\Str::limit($part['raw_markdown'] ?? '', 240) }}</div>
+                                </div>
+                            </div>
+                        @empty
+                            @include('filament.partials.empty-state', [
+                                'title' => '暂无题型区块',
+                                'description' => '请确认 Markdown 解析是否完成。',
+                            ])
+                        @endforelse
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    @include('filament.partials.loading-overlay')
+</div>

+ 19 - 83
resources/views/filament/resources/textbook-resource/edit.blade.php

@@ -1,87 +1,23 @@
-<div class="p-6">
-    <form wire:submit="save">
-        <div class="space-y-6">
-            <!-- 基本信息 -->
-            <div class="bg-white shadow rounded-lg p-6">
-                <h2 class="text-lg font-medium text-gray-900 mb-4">基本信息</h2>
-
-                <div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
-                    <!-- 教材系列 -->
-                    <div>
-                        <label class="block text-sm font-medium text-gray-700">教材系列</label>
-                        <select wire:model="data.series_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
-                            <option value="">请选择教材系列</option>
-                            @php
-                                $apiService = app(App\Services\TextbookApiService::class);
-                                $series = $apiService->getTextbookSeries();
-                                foreach ($series['data'] as $s) {
-                                    echo '<option value="' . $s['id'] . '" ' . ($this->data['series_id'] == $s['id'] ? 'selected' : '') . '>' . htmlspecialchars($s['name']) . '</option>';
-                                }
-                            @endphp
-                        </select>
-                    </div>
-
-                    <!-- 学段 -->
-                    <div>
-                        <label class="block text-sm font-medium text-gray-700">学段</label>
-                        <select wire:model="data.stage" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
-                            <option value="">请选择学段</option>
-                            <option value="primary" {{ ($this->data['stage'] ?? '') == 'primary' ? 'selected' : '' }}>小学</option>
-                            <option value="junior" {{ ($this->data['stage'] ?? '') == 'junior' ? 'selected' : '' }}>初中</option>
-                            <option value="senior" {{ ($this->data['stage'] ?? '') == 'senior' ? 'selected' : '' }}>高中</option>
-                        </select>
-                    </div>
-
-                    <!-- 年级 -->
-                    <div>
-                        <label class="block text-sm font-medium text-gray-700">年级</label>
-                        <input type="number" wire:model="data.grade" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" placeholder="例如:7">
-                    </div>
-
-                    <!-- 学期 -->
-                    <div>
-                        <label class="block text-sm font-medium text-gray-700">学期</label>
-                        <select wire:model="data.semester" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
-                            <option value="">请选择学期</option>
-                            <option value="1" {{ ($this->data['semester'] ?? '') == 1 ? 'selected' : '' }}>上学期</option>
-                            <option value="2" {{ ($this->data['semester'] ?? '') == 2 ? 'selected' : '' }}>下学期</option>
-                        </select>
+<div class="ui-page">
+    <div class="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8">
+        @include('filament.partials.page-header', [
+            'kicker' => '教材编辑',
+            'title' => '编辑教材信息',
+            'subtitle' => '按区块完善教材基础信息、版本与封面',
+            'actions' => new \Illuminate\Support\HtmlString('<a class="btn btn-outline" href="' . route('filament.admin.resources.textbooks.index') . '">返回列表</a>'),
+        ])
+
+        <div class="ui-card">
+            <div class="ui-card-body">
+                <x-filament::form wire:submit="save">
+                    {{ $this->form }}
+                    <div class="mt-6 flex items-center justify-end gap-3">
+                        <a href="{{ route('filament.admin.resources.textbooks.index') }}" class="btn btn-ghost">取消</a>
+                        <button type="submit" class="btn btn-primary">保存更改</button>
                     </div>
-
-                    <!-- 书名 -->
-                    <div class="sm:col-span-2">
-                        <label class="block text-sm font-medium text-gray-700">书名</label>
-                        <input type="text" wire:model="data.official_title" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" placeholder="请输入书名">
-                    </div>
-
-                    <!-- ISBN -->
-                    <div>
-                        <label class="block text-sm font-medium text-gray-700">ISBN</label>
-                        <input type="text" wire:model="data.isbn" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" placeholder="请输入ISBN">
-                    </div>
-
-                    <!-- 状态 -->
-                    <div>
-                        <label class="block text-sm font-medium text-gray-700">状态</label>
-                        <select wire:model="data.status" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
-                            <option value="">请选择状态</option>
-                            <option value="draft" {{ ($this->data['status'] ?? '') == 'draft' ? 'selected' : '' }}>草稿</option>
-                            <option value="published" {{ ($this->data['status'] ?? '') == 'published' ? 'selected' : '' }}>已发布</option>
-                            <option value="archived" {{ ($this->data['status'] ?? '') == 'archived' ? 'selected' : '' }}>已归档</option>
-                        </select>
-                    </div>
-                </div>
-            </div>
-
-            <!-- 提交按钮 -->
-            <div class="flex items-center justify-end gap-3">
-                <a href="{{ route('filament.admin.resources.textbooks.index') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
-                    返回列表
-                </a>
-                <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
-                    保存更改
-                </button>
+                </x-filament::form>
             </div>
         </div>
-    </form>
+    </div>
+    @include('filament.partials.loading-overlay')
 </div>

+ 41 - 106
resources/views/filament/resources/textbook-resource/index-record.blade.php

@@ -1,116 +1,51 @@
-<div class="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50">
-    @push('styles')
-        <style>
-            .glass-card {
-                @apply bg-white/80 backdrop-blur-xl rounded-2xl border border-white/20 shadow-xl;
-            }
-
-            .gradient-text {
-                @apply bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent;
-            }
-
-            .stat-card {
-                @apply glass-card p-6 hover-lift;
-            }
-
-            .stat-icon {
-                @apply w-12 h-12 rounded-full flex items-center justify-center text-white text-xl shadow-lg;
-            }
-
-            .hover-lift {
-                @apply transition-all duration-300 hover:transform hover:-translate-y-1 hover:shadow-2xl;
-            }
-
-            .pulse-animation {
-                animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
-            }
-
-            @keyframes pulse {
-                0%, 100% {
-                    opacity: 1;
-                }
-                50% {
-                    opacity: .8;
-                }
-            }
-        </style>
-    @endpush
-
-    <div class="container mx-auto px-4 py-8 max-w-7xl">
-        <!-- 页面标题 -->
-        <div class="text-center mb-8">
-            <div class="glass-card inline-block px-8 py-4 mb-6">
-                <h1 class="text-4xl font-bold gradient-text mb-2">
-                    教材管理中心
-                </h1>
-                <p class="text-slate-600">
-                    管理所有教材信息和版本
-                </p>
+<div class="ui-page">
+    <div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-8">
+        @include('filament.partials.page-header', [
+            'kicker' => '教材管理',
+            'title' => '教材管理中心',
+            'subtitle' => '集中管理教材信息、版本状态与目录结构',
+            'actions' => view('filament.partials.density-toggle'),
+        ])
+
+        @php
+            $textbooks = $this->table->getRecords();
+            $total = $textbooks->count();
+            $published = $textbooks->where('status', 'published')->count();
+            $draft = $textbooks->where('status', 'draft')->count();
+            $archived = $textbooks->where('status', 'archived')->count();
+        @endphp
+
+        <div class="grid grid-cols-1 gap-4 md:grid-cols-4">
+            <div class="ui-stat">
+                <div class="ui-stat-label">总教材数</div>
+                <div class="ui-stat-value">{{ $total }}</div>
             </div>
-        </div>
-
-        <!-- 统计卡片 -->
-        <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
-            @php
-                $textbooks = $this->table->getRecords();
-                $total = $textbooks->count();
-                $published = $textbooks->where('status', 'published')->count();
-                $draft = $textbooks->where('status', 'draft')->count();
-                $archived = $textbooks->where('status', 'archived')->count();
-            @endphp
-
-            <div class="stat-card">
-                <div class="flex items-center justify-between">
-                    <div>
-                        <p class="text-sm font-medium text-slate-600">教材总数</p>
-                        <p class="text-3xl font-bold gradient-text">{{ $total }}</p>
-                    </div>
-                    <div class="stat-icon bg-gradient-to-r from-blue-500 to-blue-600 pulse-animation" style="animation-delay: 0s;">
-                        📖
-                    </div>
-                </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已发布</div>
+                <div class="ui-stat-value text-emerald-600">{{ $published }}</div>
             </div>
-
-            <div class="stat-card">
-                <div class="flex items-center justify-between">
-                    <div>
-                        <p class="text-sm font-medium text-slate-600">已发布</p>
-                        <p class="text-3xl font-bold text-green-600">{{ $published }}</p>
-                    </div>
-                    <div class="stat-icon bg-gradient-to-r from-green-500 to-green-600 pulse-animation" style="animation-delay: 0.3s;">
-                        ✓
-                    </div>
-                </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">草稿</div>
+                <div class="ui-stat-value text-amber-600">{{ $draft }}</div>
             </div>
-
-            <div class="stat-card">
-                <div class="flex items-center justify-between">
-                    <div>
-                        <p class="text-sm font-medium text-slate-600">草稿</p>
-                        <p class="text-3xl font-bold text-yellow-600">{{ $draft }}</p>
-                    </div>
-                    <div class="stat-icon bg-gradient-to-r from-yellow-500 to-yellow-600 pulse-animation" style="animation-delay: 0.6s;">
-                        📝
-                    </div>
-                </div>
+            <div class="ui-stat">
+                <div class="ui-stat-label">已归档</div>
+                <div class="ui-stat-value text-slate-500">{{ $archived }}</div>
             </div>
+        </div>
 
-            <div class="stat-card">
-                <div class="flex items-center justify-between">
-                    <div>
-                        <p class="text-sm font-medium text-slate-600">已归档</p>
-                        <p class="text-3xl font-bold text-gray-600">{{ $archived }}</p>
-                    </div>
-                    <div class="stat-icon bg-gradient-to-r from-gray-500 to-gray-600 pulse-animation" style="animation-delay: 0.9s;">
-                        📦
-                    </div>
+        <div class="ui-card">
+            <div class="ui-card-header">
+                <div>
+                    <div class="ui-section-title">教材列表</div>
+                    <div class="ui-subtitle">封面、基础信息与状态标签一并展示</div>
                 </div>
+                <div class="ui-badge-muted">支持批量操作</div>
+            </div>
+            <div class="ui-card-body">
+                {{ $this->table }}
             </div>
-        </div>
-
-        <!-- 表格容器 -->
-        <div class="glass-card p-6">
-            {{ $this->table }}
         </div>
     </div>
+    @include('filament.partials.loading-overlay')
 </div>

+ 112 - 0
resources/views/filament/resources/textbook-resource/view.blade.php

@@ -0,0 +1,112 @@
+<div class="ui-page">
+    <div class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-8">
+        @include('filament.partials.page-header', [
+            'kicker' => '教材详情',
+            'title' => $this->record->official_title ?? '教材详情',
+            'subtitle' => '查看教材信息、目录结构与关联卷子',
+            'actions' => new \Illuminate\Support\HtmlString(
+                '<a class="btn btn-primary" href="' . route('filament.admin.resources.textbooks.edit', ['record' => $this->record->id]) . '">编辑教材</a>'
+                . '<a class="btn btn-secondary" href="' . route('filament.admin.pages.textbook-excel-import-page') . '?type=textbook_catalog">导入目录</a>'
+                . '<a class="btn btn-outline" href="' . route('filament.admin.resources.textbooks.edit', ['record' => $this->record->id]) . '#cover">上传封面</a>'
+            ),
+        ])
+
+        <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
+            <div class="space-y-6">
+                <div class="ui-card">
+                    <div class="ui-card-header">
+                        <div class="ui-section-title">教材信息</div>
+                    </div>
+                    <div class="ui-card-body space-y-4">
+                        <div class="flex items-start gap-4">
+                            @php
+                                $cover = $this->record->cover_path ?? null;
+                                $coverUrl = null;
+                                if ($cover) {
+                                    $coverUrl = \Illuminate\Support\Str::startsWith($cover, ['http://', 'https://', '/'])
+                                        ? $cover
+                                        : \Illuminate\Support\Facades\Storage::disk('public')->url($cover);
+                                }
+                            @endphp
+                            @if($coverUrl)
+                                <img src="{{ $coverUrl }}" alt="封面" class="h-28 w-20 rounded-lg border border-slate-200 object-cover" />
+                            @else
+                                <div class="flex h-28 w-20 items-center justify-center rounded-lg border border-dashed border-slate-200 bg-slate-50 text-xs text-slate-400">暂无封面</div>
+                            @endif
+                            <div class="space-y-2">
+                                <div class="text-lg font-semibold text-slate-900">{{ $this->record->official_title ?? '未命名教材' }}</div>
+                                <div class="text-sm text-slate-500">系列:{{ $this->record->series->name ?? '未归类系列' }}</div>
+                                <div class="flex flex-wrap gap-2">
+                                    <span class="ui-tag">学段:{{ $this->record->stage ?? '未标注' }}</span>
+                                    <span class="ui-tag">年级:{{ $this->record->grade ? $this->record->grade . '年级' : '未标注' }}</span>
+                                    <span class="ui-tag">学期:{{ $this->record->semester == 1 ? '上学期' : ($this->record->semester == 2 ? '下学期' : '未标注') }}</span>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="grid grid-cols-2 gap-3 text-sm text-slate-600">
+                            <div class="ui-badge-muted">ISBN:{{ $this->record->isbn ?? '未填写' }}</div>
+                            <div class="ui-badge-muted">体系:{{ $this->record->naming_scheme ?? '未标注' }}</div>
+                            <div class="ui-badge-muted">状态:{{ $this->record->status ?? '未知' }}</div>
+                            <div class="ui-badge-muted">ID:{{ $this->record->id }}</div>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="ui-card">
+                    <div class="ui-card-header">
+                        <div>
+                            <div class="ui-section-title">绑定卷子</div>
+                            <div class="ui-subtitle">基于教材系列匹配的最近卷子</div>
+                        </div>
+                    </div>
+                    <div class="ui-card-body">
+                        @if(empty($this->linkedPapers))
+                            @include('filament.partials.empty-state', [
+                                'title' => '暂无关联卷子',
+                                'description' => '当前教材系列尚未关联到源卷子。',
+                            ])
+                        @else
+                            <div class="space-y-3">
+                                @foreach($this->linkedPapers as $paper)
+                                    <div class="rounded-xl border border-slate-200 px-4 py-3">
+                                        <div class="text-sm font-semibold text-slate-900">{{ $paper['title'] }}</div>
+                                        <div class="mt-1 flex flex-wrap gap-2 text-xs text-slate-500">
+                                            <span class="ui-tag">章节:{{ $paper['chapter'] ?? '未标注' }}</span>
+                                            <span class="ui-tag">年级:{{ $paper['grade'] ?? '未标注' }}</span>
+                                            <span class="ui-tag">学期:{{ $paper['term'] ?? '未标注' }}</span>
+                                            <span class="ui-tag">来源:{{ $paper['source_type'] ?? '未知' }}</span>
+                                        </div>
+                                    </div>
+                                @endforeach
+                            </div>
+                        @endif
+                    </div>
+                </div>
+            </div>
+
+            <div class="lg:col-span-2">
+                <div class="ui-card">
+                    <div class="ui-card-header">
+                        <div>
+                            <div class="ui-section-title">目录结构</div>
+                            <div class="ui-subtitle">教材章节目录树</div>
+                        </div>
+                        <a class="btn btn-outline btn-sm" href="{{ route('filament.admin.resources.textbook-catalogs.index', ['tableFilters[textbook_id][value]' => $this->record->id]) }}">管理目录</a>
+                    </div>
+                    <div class="ui-card-body">
+                        @if(empty($this->catalogTree))
+                            @include('filament.partials.empty-state', [
+                                'title' => '暂无目录',
+                                'description' => '请先导入或维护教材目录结构。',
+                                'action' => new \Illuminate\Support\HtmlString('<a class="btn btn-primary btn-sm" href="' . route('filament.admin.pages.textbook-excel-import-page') . '?type=textbook_catalog">导入目录</a>'),
+                            ])
+                        @else
+                            @include('filament.partials.catalog-tree', ['nodes' => $this->catalogTree])
+                        @endif
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    @include('filament.partials.loading-overlay')
+</div>

+ 4 - 0
routes/web.php

@@ -3,6 +3,7 @@
 use Illuminate\Support\Facades\Route;
 use App\Http\Controllers\NotificationController;
 use App\Http\Controllers\MenuVisibilityController;
+use App\Http\Controllers\ImportStreamController;
 
 Route::get('/', function () {
     return redirect()->route('filament.admin.pages.dashboard');
@@ -31,3 +32,6 @@ Route::get('/test-livewire', function() {
 // 使用GET方法避免CSRF问题
 Route::get('/admin/textbooks/{id}/delete', [\App\Http\Controllers\TextbookController::class, 'delete'])
     ->name('filament.admin.resources.textbooks.delete');
+
+Route::get('/admin/markdown-imports/stream', [ImportStreamController::class, 'stream'])
+    ->name('filament.admin.markdown-imports.stream');