Parcourir la source

fix(admin): stabilize textbook filters and cover workflow UX

Make series-to-textbook filtering reliable, optimize textbook page/list responsiveness, and polish cover upload feedback and validation messaging for smoother admin operations.

Made-with: Cursor
yemeishu il y a 1 mois
Parent
commit
9140cdb579

+ 106 - 4
app/Filament/Resources/TextbookResource/Pages/ManageTextbookCovers.php

@@ -3,6 +3,8 @@
 namespace App\Filament\Resources\TextbookResource\Pages;
 
 use App\Filament\Resources\TextbookResource;
+use App\Models\Textbook;
+use App\Models\TextbookSeries;
 use App\Services\TextbookApiService;
 use App\Services\TextbookCoverStorageService;
 use Filament\Resources\Pages\Page;
@@ -22,6 +24,9 @@ class ManageTextbookCovers extends Page
     public ?int $recordId = null;
 
     public string $officialTitle = '';
+    public string $seriesName = '';
+    public ?array $previousTextbook = null;
+    public ?array $nextTextbook = null;
 
     /** @var list<string> */
     public array $coverUrls = [];
@@ -31,6 +36,7 @@ class ManageTextbookCovers extends Page
 
     public ?string $saveFeedback = null;
     public string $saveFeedbackType = 'info';
+    public bool $hasUnsavedChanges = false;
 
     /**
      * 必须与路由参数名一致({record}),Livewire 全页组件会把 URL 段注入 mount,勿仅用 Request::route(部分环境下为空)。
@@ -50,7 +56,90 @@ class ManageTextbookCovers extends Page
         }
 
         $this->officialTitle = (string) ($data['official_title'] ?? '');
+        $this->seriesName = (string) (
+            $data['series_name']
+            ?? data_get($data, 'series.name')
+            ?? TextbookSeries::query()->whereKey($data['series_id'] ?? null)->value('name')
+            ?? ''
+        );
         $this->coverUrls = $this->splitCoverPath($data['cover_path'] ?? null);
+        $this->hydrateSiblingTextbooks($data);
+        $this->hasUnsavedChanges = false;
+    }
+
+    protected function hydrateSiblingTextbooks(array $data): void
+    {
+        $seriesId = isset($data['series_id']) ? (int) $data['series_id'] : 0;
+
+        if ($seriesId <= 0 || $this->recordId === null) {
+            $this->previousTextbook = null;
+            $this->nextTextbook = null;
+
+            return;
+        }
+
+        $records = Textbook::query()
+            ->where('series_id', $seriesId)
+            ->orderBy('grade')
+            ->orderBy('semester')
+            ->orderBy('id')
+            ->get(['id', 'official_title', 'grade', 'semester']);
+
+        $currentIndex = $records->search(fn (Textbook $textbook): bool => (int) $textbook->getKey() === $this->recordId);
+
+        if (! is_int($currentIndex)) {
+            $this->previousTextbook = null;
+            $this->nextTextbook = null;
+
+            return;
+        }
+
+        $this->previousTextbook = $this->makeSiblingPayload($records->get($currentIndex - 1));
+        $this->nextTextbook = $this->makeSiblingPayload($records->get($currentIndex + 1));
+    }
+
+    protected function makeSiblingPayload(?Textbook $textbook): ?array
+    {
+        if (! $textbook) {
+            return null;
+        }
+
+        return [
+            'id' => (int) $textbook->getKey(),
+            'title' => (string) ($textbook->official_title ?? ''),
+            'meta' => $this->formatTextbookOrderLabel(
+                isset($textbook->grade) ? (int) $textbook->grade : null,
+                isset($textbook->semester) ? (int) $textbook->semester : null,
+            ),
+            'url' => TextbookResource::getUrl('covers', ['record' => $textbook->getKey()]),
+        ];
+    }
+
+    protected function formatTextbookOrderLabel(?int $grade, ?int $semester): string
+    {
+        $gradeLabel = match ($grade) {
+            1 => '一年级',
+            2 => '二年级',
+            3 => '三年级',
+            4 => '四年级',
+            5 => '五年级',
+            6 => '六年级',
+            7 => '七年级',
+            8 => '八年级',
+            9 => '九年级',
+            10 => '高一',
+            11 => '高二',
+            12 => '高三',
+            default => $grade ? "{$grade}年级" : '未分年级',
+        };
+
+        $semesterLabel = match ($semester) {
+            1 => '上册',
+            2 => '下册',
+            default => '未分学期',
+        };
+
+        return "{$gradeLabel} · {$semesterLabel}";
     }
 
     /**
@@ -76,10 +165,17 @@ class ManageTextbookCovers extends Page
             return;
         }
 
-        $this->validate([
-            'photos' => 'array',
-            'photos.*' => 'image|max:5120',
-        ]);
+        $this->validate(
+            [
+                'photos' => 'array',
+                'photos.*' => 'image|max:10240',
+            ],
+            [
+                'photos.*.uploaded' => '图片上传失败(可能文件过大或上传中断),请重试。',
+                'photos.*.image' => '仅支持 JPG / PNG / WEBP 图片。',
+                'photos.*.max' => '单张图片不能超过 10MB。',
+            ]
+        );
 
         $storage = app(TextbookCoverStorageService::class);
         $added = 0;
@@ -101,7 +197,9 @@ class ManageTextbookCovers extends Page
             ->body($added > 0 ? '确认顺序后,点击页面底部「保存到服务器」写入教材。' : '请检查图片格式或存储配置。');
 
         if ($added > 0) {
+            $this->hasUnsavedChanges = true;
             $n->success();
+            $this->dispatch('covers-appended', count: $added);
         } else {
             $n->warning();
         }
@@ -116,6 +214,7 @@ class ManageTextbookCovers extends Page
         }
         unset($this->coverUrls[$index]);
         $this->coverUrls = array_values($this->coverUrls);
+        $this->hasUnsavedChanges = true;
     }
 
     public function moveUp(int $index): void
@@ -126,6 +225,7 @@ class ManageTextbookCovers extends Page
         $tmp = $this->coverUrls[$index - 1];
         $this->coverUrls[$index - 1] = $this->coverUrls[$index];
         $this->coverUrls[$index] = $tmp;
+        $this->hasUnsavedChanges = true;
     }
 
     public function moveDown(int $index): void
@@ -137,6 +237,7 @@ class ManageTextbookCovers extends Page
         $tmp = $this->coverUrls[$index + 1];
         $this->coverUrls[$index + 1] = $this->coverUrls[$index];
         $this->coverUrls[$index] = $tmp;
+        $this->hasUnsavedChanges = true;
     }
 
     public function saveCovers(): void
@@ -183,6 +284,7 @@ class ManageTextbookCovers extends Page
         $isCleared = ($this->coverUrls === []);
         $this->saveFeedback = ($isCleared ? '已清空并保存' : '保存成功') . '(' . now()->format('H:i:s') . ')';
         $this->saveFeedbackType = 'success';
+        $this->hasUnsavedChanges = false;
 
         \Filament\Notifications\Notification::make()
             ->title($isCleared ? '配图已清空' : '配图已保存')

+ 63 - 11
app/Filament/Resources/TextbookResource/Pages/ManageTextbooks.php

@@ -13,6 +13,44 @@ class ManageTextbooks extends ListRecords
 {
     protected static string $resource = TextbookResource::class;
 
+    private function pickFilterValue(array $filters, string $key): mixed
+    {
+        $fromState = $filters[$key] ?? null;
+
+        if (is_array($fromState)) {
+            if (array_key_exists('value', $fromState)) {
+                return $fromState['value'];
+            }
+
+            if (array_key_exists('values', $fromState)) {
+                return $fromState['values'];
+            }
+        }
+
+        if ($fromState !== null) {
+            return $fromState;
+        }
+
+        return request()->input("filters.{$key}.value", request()->input("tableFilters.{$key}.value"));
+    }
+
+    private function getActiveKeyword(array $filters): ?string
+    {
+        $keyword = $this->pickFilterValue($filters, 'keyword');
+
+        if (filled($keyword)) {
+            return trim((string) $keyword);
+        }
+
+        if (filled($this->tableSearch)) {
+            return trim((string) $this->tableSearch);
+        }
+
+        $search = request()->input('search');
+
+        return filled($search) ? trim((string) $search) : null;
+    }
+
     protected function getHeaderActions(): array
     {
         return [
@@ -21,7 +59,7 @@ class ManageTextbooks extends ListRecords
         ];
     }
 
-    protected function paginateTableQuery(\Illuminate\Database\Eloquent\builder $query): Paginator
+    protected function paginateTableQuery(\Illuminate\Database\Eloquent\Builder $query): Paginator
     {
         return $this->getTableRecords();
     }
@@ -29,26 +67,40 @@ class ManageTextbooks extends ListRecords
     public function getTableRecords(): Paginator
     {
         $apiService = app(TextbookApiService::class);
-        $page = request()->get('page', 1);
-        $perPage = $this->getTableRecordsPerPage();
-        $filters = request()->input('tableFilters', []);
+        $page = max(1, (int) $this->getTablePage());
+        $perPageOption = $this->getTableRecordsPerPage();
+        $perPage = is_numeric($perPageOption) ? (int) $perPageOption : 10;
+        $perPage = min(max($perPage, 10), 50);
+        $filters = $this->tableFilters ?? [];
+        $seriesId = $this->pickFilterValue($filters, 'series_id');
+        $stage = $this->pickFilterValue($filters, 'stage');
+        $grade = $this->pickFilterValue($filters, 'grade');
+        $semester = $this->pickFilterValue($filters, 'semester');
+        $namingScheme = $this->pickFilterValue($filters, 'naming_scheme');
+        $status = $this->pickFilterValue($filters, 'status');
+        $keyword = $this->getActiveKeyword($filters);
 
         $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,
+            'series_id' => $seriesId,
+            'stage' => $stage,
+            'grade' => $grade,
+            'semester' => $semester,
+            'naming_scheme' => $namingScheme,
+            'status' => $status,
+            'keyword' => $keyword,
         ];
 
         $params = array_filter($params, fn ($value) => $value !== null && $value !== '');
 
         \Log::info('ManageTextbooks::getTableRecords called', [
             'page' => $page,
-            'perPage' => $perPage
+            'perPage' => $perPage,
+            'series_id' => $seriesId,
+            'tableFilters' => $filters,
+            'tableSearch' => $this->tableSearch,
+            'resolved_params' => $params,
         ]);
 
         $result = $apiService->getTextbooks($params);

+ 42 - 14
app/Filament/Resources/TextbookResource/Pages/ViewTextbook.php

@@ -23,12 +23,20 @@ class ViewTextbook extends ViewRecord
 
     public function mount(int|string $record): void
     {
+        $startedAt = microtime(true);
         parent::mount($record);
 
         $apiService = app(TextbookApiService::class);
+        $catalogStartedAt = microtime(true);
         $this->catalogTree = $apiService->getTextbookCatalog((int) $this->record->id, 'tree');
+        \Log::debug('ViewTextbook: catalog loaded', [
+            'record_id' => $this->record->id,
+            'elapsed_ms' => (int) ((microtime(true) - $catalogStartedAt) * 1000),
+            'node_count' => count($this->catalogTree),
+        ]);
 
         $seriesName = $this->record->series->name ?? null;
+        $linkedStartedAt = microtime(true);
         $this->linkedPapers = SourcePaper::query()
             ->when($seriesName, fn ($query) => $query->where('textbook_series', $seriesName))
             ->latest('updated_at')
@@ -44,22 +52,42 @@ class ViewTextbook extends ViewRecord
                 'updated_at' => $paper->updated_at,
             ])
             ->toArray();
+        \Log::debug('ViewTextbook: linked papers loaded', [
+            'record_id' => $this->record->id,
+            'elapsed_ms' => (int) ((microtime(true) - $linkedStartedAt) * 1000),
+            'count' => count($this->linkedPapers),
+        ]);
 
-        $coverage = [];
-        $unlinked = 0;
-        SourcePaper::query()
+        // 用 SQL 聚合替代全量 get()->each(),避免教材关联卷子多时页面卡顿。
+        $coverageStartedAt = microtime(true);
+        $this->catalogCoverage = SourcePaper::query()
             ->where('textbook_id', $this->record->id)
-            ->get(['id', 'meta'])
-            ->each(function ($paper) use (&$coverage, &$unlinked) {
-                $catalogId = $paper->meta['catalog_node_id'] ?? null;
-                if ($catalogId) {
-                    $coverage[$catalogId] = ($coverage[$catalogId] ?? 0) + 1;
-                } else {
-                    $unlinked++;
-                }
-            });
+            ->whereRaw("JSON_EXTRACT(meta, '$.catalog_node_id') IS NOT NULL")
+            ->selectRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.catalog_node_id')) AS catalog_node_id, COUNT(*) AS aggregate_count")
+            ->groupBy('catalog_node_id')
+            ->pluck('aggregate_count', 'catalog_node_id')
+            ->map(fn ($count) => (int) $count)
+            ->all();
 
-        $this->catalogCoverage = $coverage;
-        $this->unlinkedPaperCount = $unlinked;
+        $this->unlinkedPaperCount = SourcePaper::query()
+            ->where('textbook_id', $this->record->id)
+            ->where(function ($query) {
+                $query
+                    ->whereNull('meta')
+                    ->orWhereRaw("JSON_EXTRACT(meta, '$.catalog_node_id') IS NULL");
+            })
+            ->count();
+
+        \Log::debug('ViewTextbook: coverage computed', [
+            'record_id' => $this->record->id,
+            'elapsed_ms' => (int) ((microtime(true) - $coverageStartedAt) * 1000),
+            'coverage_nodes' => count($this->catalogCoverage),
+            'unlinked_count' => $this->unlinkedPaperCount,
+        ]);
+
+        \Log::debug('ViewTextbook: mount done', [
+            'record_id' => $this->record->id,
+            'total_elapsed_ms' => (int) ((microtime(true) - $startedAt) * 1000),
+        ]);
     }
 }

+ 10 - 1
app/Filament/Resources/TextbookResource/Tables/TextbookTable.php

@@ -3,6 +3,7 @@
 namespace App\Filament\Resources\TextbookResource\Tables;
 
 use App\Filament\Resources\TextbookResource;
+use App\Models\TextbookSeries;
 use Filament\Actions\EditAction;
 use Filament\Actions\Action;
 use Filament\Actions\BulkAction;
@@ -86,6 +87,14 @@ class TextbookTable
                     ->toggleable(isToggledHiddenByDefault: true),
             ])
             ->filters([
+                SelectFilter::make('series_id')
+                    ->label('教材系列')
+                    ->options(fn (): array => TextbookSeries::query()
+                        ->orderBy('sort_order')
+                        ->orderBy('id')
+                        ->pluck('name', 'id')
+                        ->toArray()),
+
                 SelectFilter::make('stage')
                     ->label('学段')
                     ->options([
@@ -183,7 +192,7 @@ class TextbookTable
                     ->iconButton()
                     ->tooltip('查看目录')
                     ->url(fn(Model $record): string =>
-                        route('filament.admin.resources.textbook-catalogs.index', ['tableFilters[textbook_id][value]' => $record->id])
+                        route('filament.admin.resources.textbook-catalogs.index', ['filters[textbook_id][value]' => $record->id])
                     ),
             ])
             ->bulkActions([

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

@@ -206,7 +206,7 @@ class TextbookSeriesResource extends Resource
                     ->label('查看教材')
                     ->icon('heroicon-o-book-open')
                     ->url(fn(Model $record): string =>
-                        route('filament.admin.resources.textbooks.index', ['tableFilters[series_id][value]' => $record->id])
+                        route('filament.admin.resources.textbooks.index', ['filters[series_id][value]' => $record->id])
                     ),
             ])
             ->bulkActions([

+ 10 - 0
app/Models/Textbook.php

@@ -72,6 +72,16 @@ class Textbook extends Model
 
     public function getSeriesNameAttribute(): string
     {
+        $seriesName = $this->getAttributeFromArray('series_name');
+
+        if (filled($seriesName)) {
+            return (string) $seriesName;
+        }
+
+        if ($this->relationLoaded('series') && $this->getRelation('series')) {
+            return $this->getRelation('series')->name ?? '未归类系列';
+        }
+
         $series = $this->series()->first();
         if ($series) {
             return $series->name ?? '未归类系列';

+ 42 - 2
app/Services/TextbookApiService.php

@@ -217,7 +217,7 @@ class TextbookApiService
     {
         if ($this->useDatabase) {
             $query = Textbook::query()
-                ->select('textbooks.*')
+                ->select('textbooks.*', 'textbook_series.name as series_name')
                 ->join('textbook_series', 'textbooks.series_id', '=', 'textbook_series.id');
 
             // 默认只返回已发布且系列启用的教材
@@ -255,7 +255,47 @@ class TextbookApiService
                 $query->where('textbooks.status', $params['status']);
             }
 
-            $textbooks = $query->orderBy('textbooks.id')->get();
+            // 关键词筛选:教材名称 / ISBN / 系列名
+            if (isset($params['keyword']) && trim((string) $params['keyword']) !== '') {
+                $keyword = trim((string) $params['keyword']);
+                $query->where(function ($q) use ($keyword) {
+                    $q->where('textbooks.official_title', 'like', "%{$keyword}%")
+                        ->orWhere('textbooks.isbn', 'like', "%{$keyword}%")
+                        ->orWhere('textbook_series.name', 'like', "%{$keyword}%");
+                });
+            }
+
+            // 命名方案筛选
+            if (isset($params['naming_scheme']) && $params['naming_scheme'] !== '') {
+                $query->where('textbooks.naming_scheme', $params['naming_scheme']);
+            }
+
+            $query->orderBy('textbooks.id');
+
+            $page = max(1, (int) ($params['page'] ?? 1));
+            $perPage = isset($params['per_page']) && is_numeric($params['per_page'])
+                ? max(1, (int) $params['per_page'])
+                : null;
+
+            if ($perPage !== null) {
+                $total = (clone $query)->count('textbooks.id');
+                $textbooks = $query
+                    ->forPage($page, $perPage)
+                    ->get();
+
+                return [
+                    'data' => $textbooks->toArray(),
+                    'meta' => [
+                        'total' => $total,
+                        'current_page' => $page,
+                        'per_page' => $perPage,
+                        'last_page' => (int) ceil($total / $perPage),
+                    ],
+                ];
+            }
+
+            $textbooks = $query->get();
+
             return ['data' => $textbooks->toArray(), 'meta' => ['total' => $textbooks->count()]];
         }
 

+ 3 - 3
app/Services/TextbookCoverStorageService.php

@@ -22,8 +22,8 @@ class TextbookCoverStorageService
             return null;
         }
 
-        // 验证文件大小(最大 5MB)
-        $maxSize = 5 * 1024 * 1024;
+        // 验证文件大小(最大 10MB)
+        $maxSize = 10 * 1024 * 1024;
         if ($file->getSize() > $maxSize) {
             Log::error('TextbookCoverStorageService: 文件过大', ['size' => $file->getSize()]);
             return null;
@@ -87,4 +87,4 @@ class TextbookCoverStorageService
             'supports_delete' => false, // 又拍云和春笋都不支持删除API
         ];
     }
-}
+}

+ 4 - 0
public/.user.ini

@@ -0,0 +1,4 @@
+upload_max_filesize=20M
+post_max_size=64M
+max_file_uploads=50
+memory_limit=512M

+ 28 - 18
resources/lang/zh_CN/validation.php

@@ -1,30 +1,40 @@
 <?php
 
 return [
-    /*
-    |--------------------------------------------------------------------------
-    | Validation Language Lines
-    |--------------------------------------------------------------------------
-    */
-
-    'mimetypes' => ':attribute 的文件类型必须是::values。',
-    'mimes' => ':attribute 的文件格式必须是::values。',
-
+    'array' => ':attribute格式不正确。',
+    'file' => ':attribute必须是文件。',
+    'image' => ':attribute必须是图片文件。',
     'max' => [
-        'string' => ':attribute 不能超过 :max 个字符。',
-        'file' => ':attribute 不能超过 :max KB。',
-        'array' => ':attribute 不能超过 :max 项。',
-        'numeric' => ':attribute 不能大于 :max。',
+        'array' => ':attribute不能超过:max项。',
+        'file' => ':attribute不能大于:max KB。',
+        'numeric' => ':attribute不能大于:max。',
+        'string' => ':attribute不能超过:max个字符。',
     ],
-
     'min' => [
-        'string' => ':attribute 不能少于 :min 个字符。',
-        'file' => ':attribute 不能小于 :min KB。',
-        'array' => ':attribute 不能少于 :min 项。',
-        'numeric' => ':attribute 不能小于 :min。',
+        'array' => ':attribute不能少于:min项。',
+        'file' => ':attribute不能小于:min KB。',
+        'numeric' => ':attribute不能小于:min。',
+        'string' => ':attribute不能少于:min个字符。',
+    ],
+    'mimes' => ':attribute格式不支持,允许的格式有::values。',
+    'mimetypes' => ':attribute格式不支持,允许的类型有::values。',
+    'required' => '请填写:attribute。',
+    'uploaded' => ':attribute上传失败,请重试。',
+
+    'custom' => [
+        'photos' => [
+            'array' => '图片列表格式不正确,请重新选择图片。',
+        ],
+        'photos.*' => [
+            'uploaded' => '图片上传失败(可能文件过大、上传中断或临时文件生成失败),请重试。',
+            'image' => '仅支持 JPG、PNG、WEBP 图片。',
+            'max' => '单张图片不能超过 10MB。',
+        ],
     ],
 
     'attributes' => [
+        'photos' => '图片列表',
+        'photos.*' => '图片',
         'markdown_file' => 'Markdown 文件',
         'original_markdown' => 'Markdown 内容',
         'file_name' => '文件名(来源名称)',

+ 0 - 28
resources/views/filament/resources/textbook-resource/index-record.blade.php

@@ -7,33 +7,6 @@
             '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 class="ui-stat">
-                <div class="ui-stat-label">已发布</div>
-                <div class="ui-stat-value text-emerald-600">{{ $published }}</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-slate-500">{{ $archived }}</div>
-            </div>
-        </div>
-
         <div class="ui-card">
             <div class="ui-card-header">
                 <div>
@@ -47,5 +20,4 @@
             </div>
         </div>
     </div>
-    @include('filament.partials.loading-overlay')
 </div>

+ 132 - 37
resources/views/filament/resources/textbook-resource/manage-covers.blade.php

@@ -11,12 +11,17 @@
         ])
 
         @if($officialTitle !== '')
-            <div class="inline-flex max-w-full items-center gap-2 rounded-full border border-slate-200 bg-slate-50/90 px-4 py-2 text-sm text-slate-700">
+            <div class="inline-flex max-w-full flex-wrap items-center gap-2 rounded-full border border-slate-200 bg-slate-50/90 px-4 py-2 text-sm text-slate-700">
                 <svg class="h-4 w-4 shrink-0 text-sky-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
                     <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3A1.5 1.5 0 0 0 1.5 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008H12V8.25Z" />
                 </svg>
                 <span class="text-slate-500">当前教材</span>
                 <span class="truncate font-medium text-slate-900" title="{{ $officialTitle }}">{{ $officialTitle }}</span>
+                @if($seriesName !== '')
+                    <span class="text-slate-300">|</span>
+                    <span class="text-slate-500">教材系列</span>
+                    <span class="truncate font-medium text-slate-900" title="{{ $seriesName }}">{{ $seriesName }}</span>
+                @endif
             </div>
         @endif
 
@@ -24,7 +29,7 @@
             <div class="ui-card-header !items-start !border-b-slate-100">
                 <div>
                     <div class="ui-section-title">上传与排序</div>
-                    <p class="ui-subtitle mt-0.5 max-w-xl">支持 JPEG / PNG / WebP,单张不超过 5MB。第一张为封面主图。</p>
+                    <p class="ui-subtitle mt-0.5 max-w-xl">支持 JPEG / PNG / WebP,单张不超过 10MB。第一张为封面主图。</p>
                 </div>
                 <span class="shrink-0 rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
                     已选 {{ count($coverUrls) }} 张
@@ -38,7 +43,13 @@
                     x-data="{
                         previews: [],
                         uploading: false,
+                        appending: false,
                         progress: 0,
+                        autoMessage: '',
+                        autoMessageTimer: null,
+                        shouldPromptOnEmpty: {{ count($coverUrls) === 0 ? 'true' : 'false' }},
+                        autoPrompted: false,
+                        autoPromptBlocked: false,
                         refreshPreviews(event) {
                             this.previews.forEach((p) => URL.revokeObjectURL(p.url))
                             const files = Array.from(event.target.files || [])
@@ -54,11 +65,57 @@
                             this.previews = []
                             this.progress = 0
                         },
+                        showAutoMessage(message) {
+                            this.autoMessage = message
+
+                            if (this.autoMessageTimer) {
+                                clearTimeout(this.autoMessageTimer)
+                            }
+
+                            this.autoMessageTimer = setTimeout(() => {
+                                this.autoMessage = ''
+                                this.autoMessageTimer = null
+                            }, 2200)
+                        },
+                        promptForPhotos() {
+                            if (!this.shouldPromptOnEmpty || this.autoPrompted || !this.$refs.photoInput) {
+                                return
+                            }
+
+                            this.autoPrompted = true
+
+                            setTimeout(() => {
+                                try {
+                                    if (typeof this.$refs.photoInput.showPicker === 'function') {
+                                        this.$refs.photoInput.showPicker()
+                                    } else {
+                                        this.$refs.photoInput.click()
+                                    }
+                                } catch (error) {
+                                    this.autoPromptBlocked = true
+                                }
+                            }, 250)
+                        },
+                        triggerAppend() {
+                            if (this.uploading || this.appending || this.previews.length === 0) {
+                                return
+                            }
+
+                            this.appending = true
+
+                            $wire.appendPhotos()
+                                .catch(() => {})
+                                .finally(() => {
+                                    this.appending = false
+                                })
+                        },
                     }"
+                    x-init="promptForPhotos()"
                     x-on:livewire-upload-start="uploading = true; progress = 0"
                     x-on:livewire-upload-progress="uploading = true; progress = $event.detail.progress"
-                    x-on:livewire-upload-finish="uploading = false; progress = 100"
+                    x-on:livewire-upload-finish="uploading = false; progress = 100; triggerAppend()"
                     x-on:livewire-upload-error="uploading = false"
+                    x-on:covers-appended.window="clearPreviews(); if ($refs.photoInput) { $refs.photoInput.value = '' }; showAutoMessage(`已自动加入列表${$event.detail?.count ? `(${$event.detail.count} 张)` : ''}`)"
                 >
                     <div class="flex gap-4" style="display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
                         <div class="space-y-3" style="flex:1 1 420px;min-width:320px;">
@@ -76,7 +133,21 @@
                                     x-on:change="refreshPreviews($event)"
                                     class="block w-full cursor-pointer text-sm text-slate-600 file:mr-4 file:cursor-pointer file:rounded-lg file:border-0 file:bg-sky-50 file:px-4 file:py-2.5 file:text-sm file:font-medium file:text-sky-800 file:ring-1 file:ring-sky-200/80 hover:file:bg-sky-100"
                                 />
-                                <p class="text-xs text-slate-500" style="white-space:normal;word-break:normal;">可多次选择,每次点「加入列表」把本次选中的图追加到下方;全部满意后再点底部保存。</p>
+                                <p class="text-xs text-slate-500" style="white-space:normal;word-break:normal;">可多次选择;选中后会自动加入下方列表,全部满意后再点底部保存。</p>
+                                <div
+                                    x-cloak
+                                    x-show="shouldPromptOnEmpty && autoPromptBlocked"
+                                    class="flex flex-wrap items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700"
+                                >
+                                    <span>浏览器未自动弹出选择窗口,请点这里继续。</span>
+                                    <button
+                                        type="button"
+                                        x-on:click="$refs.photoInput?.click()"
+                                        class="inline-flex items-center rounded-md bg-amber-100 px-2.5 py-1 font-medium text-amber-800 hover:bg-amber-200"
+                                    >
+                                        选择图片
+                                    </button>
+                                </div>
                             </div>
                             <div class="space-y-2" x-show="previews.length > 0" x-cloak>
                                 <div class="flex items-center justify-between">
@@ -112,6 +183,12 @@
                                 <span class="loading loading-spinner loading-sm text-sky-500"></span>
                                 读取文件中…
                             </div>
+                            <p
+                                x-cloak
+                                x-show="autoMessage"
+                                x-text="autoMessage"
+                                class="inline-flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-medium text-emerald-700"
+                            ></p>
                             @error('photos.*')
                                 <p class="text-sm text-error">{{ $message }}</p>
                             @enderror
@@ -120,10 +197,9 @@
                             <span class="hidden text-xs font-medium text-slate-500" aria-hidden="true">操作</span>
                             <button
                                 type="button"
-                                wire:click="appendPhotos"
                                 wire:loading.attr="disabled"
                                 wire:target="appendPhotos,photos"
-                                x-on:click="setTimeout(() => { if (!uploading) { clearPreviews(); if ($refs.photoInput) { $refs.photoInput.value = '' } } }, 300)"
+                                x-on:click="triggerAppend()"
                                 class="btn btn-primary w-full border-0 bg-sky-500 text-white shadow-sm hover:bg-sky-600"
                                 style="width:100%;"
                             >
@@ -131,7 +207,7 @@
                                     <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
                                         <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
                                     </svg>
-                                    加入列表
+                                    手动加入列表
                                 </span>
                                 <span wire:loading wire:target="appendPhotos" class="inline-flex items-center gap-2">
                                     <span class="loading loading-spinner loading-sm"></span>
@@ -157,7 +233,14 @@
                                 </svg>
                             </div>
                             <p class="ui-empty-title">暂无配图</p>
-                            <p class="ui-empty-desc max-w-sm">从上方选择图片并「加入列表」,或先在存储/API 侧同步后再刷新本页。</p>
+                            <p class="ui-empty-desc max-w-sm">页面会优先尝试自动打开选图窗口;如果浏览器没有放行,直接点下方按钮继续。</p>
+                            <button
+                                type="button"
+                                x-on:click="$refs.photoInput?.click()"
+                                class="btn btn-primary mt-4 border-0 bg-sky-500 px-6 text-white shadow-sm hover:bg-sky-600"
+                            >
+                                选择图片
+                            </button>
                         </div>
                     @else
                         <ul class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
@@ -184,35 +267,47 @@
                 </div>
 
                 <div class="border-t border-slate-100 pt-5">
-                    <p class="text-xs text-slate-500" style="display:block;max-width:100%;white-space:normal;word-break:normal;">保存后会写入教材的配图字段;移除仅影响本次会话直至保存。</p>
-                    <div class="mt-3 flex justify-end">
-                    <button
-                        type="button"
-                        wire:click="saveCovers"
-                        wire:loading.attr="disabled"
-                        wire:target="saveCovers"
-                        class="btn btn-primary border-0 bg-sky-500 px-8 text-white shadow-sm hover:bg-sky-600"
-                        style="min-width:220px;"
-                    >
-                        <span wire:loading.remove wire:target="saveCovers" class="inline-flex items-center justify-center gap-2">
-                            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
-                                <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
-                            </svg>
-                            保存到服务器
-                        </span>
-                        <span wire:loading wire:target="saveCovers" class="inline-flex items-center gap-2">
-                            <span class="loading loading-spinner loading-sm"></span>
-                            保存中…
-                        </span>
-                    </button>
-                    </div>
-                    <div class="mt-3">
-                        <p wire:loading wire:target="saveCovers" class="text-xs text-sky-600">正在保存到服务器,请稍候…</p>
-                        @if($saveFeedback)
-                            <p class="text-xs {{ $saveFeedbackType === 'success' ? 'text-emerald-600' : ($saveFeedbackType === 'error' ? 'text-rose-600' : 'text-amber-600') }}">
-                                {{ $saveFeedback }}
-                            </p>
-                        @endif
+                    <div class="sticky bottom-4 z-20 -mx-2 rounded-2xl px-4 py-3 shadow-lg backdrop-blur transition-all sm:mx-0 sm:px-5 {{ $hasUnsavedChanges ? 'border border-sky-300 bg-sky-50/95 shadow-sky-200/70 ring-2 ring-sky-200/80' : 'border border-slate-200/90 bg-white/95 shadow-slate-200/60' }}">
+                        <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+                            <div class="min-w-0">
+                                <p class="text-sm font-semibold {{ $hasUnsavedChanges ? 'text-sky-900' : 'text-slate-900' }}">
+                                    {{ $hasUnsavedChanges ? '有未保存的配图改动' : '配图队列已就绪' }}
+                                </p>
+                                <p class="text-xs text-slate-500" style="display:block;max-width:100%;white-space:normal;word-break:normal;">保存后会写入教材的配图字段;移除仅影响本次会话直至保存。</p>
+                                <div class="mt-2 space-y-1">
+                                    <p wire:loading wire:target="saveCovers" class="text-xs text-sky-600">正在保存到服务器,请稍候…</p>
+                                    @if($saveFeedback)
+                                        <p class="text-xs {{ $saveFeedbackType === 'success' ? 'text-emerald-600' : ($saveFeedbackType === 'error' ? 'text-rose-600' : 'text-amber-600') }}">
+                                            {{ $saveFeedback }}
+                                        </p>
+                                    @endif
+                                </div>
+                            </div>
+                            <div class="flex shrink-0 items-center gap-3">
+                                <span class="rounded-full px-3 py-1 text-xs font-medium {{ $hasUnsavedChanges ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600' }}">
+                                    {{ $hasUnsavedChanges ? '待保存 ' : '当前 ' }}{{ count($coverUrls) }} 张
+                                </span>
+                                <button
+                                    type="button"
+                                    wire:click="saveCovers"
+                                    wire:loading.attr="disabled"
+                                    wire:target="saveCovers"
+                                    class="btn btn-primary border-0 px-8 text-white shadow-sm {{ $hasUnsavedChanges ? 'bg-sky-600 ring-2 ring-sky-300 hover:bg-sky-700' : 'bg-sky-500 hover:bg-sky-600' }}"
+                                    style="min-width:220px;"
+                                >
+                                    <span wire:loading.remove wire:target="saveCovers" class="inline-flex items-center justify-center gap-2">
+                                        <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
+                                            <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+                                        </svg>
+                                        保存到服务器
+                                    </span>
+                                    <span wire:loading wire:target="saveCovers" class="inline-flex items-center gap-2">
+                                        <span class="loading loading-spinner loading-sm"></span>
+                                        保存中…
+                                    </span>
+                                </button>
+                            </div>
+                        </div>
                     </div>
                 </div>
             </div>

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

@@ -131,5 +131,4 @@
             </div>
         </div>
     </div>
-    @include('filament.partials.loading-overlay')
 </div>