Преглед изворни кода

feat(admin): add textbook cover management workflow

Split textbook cover uploads into a dedicated management page with preview/sorting flow, and align Docker dev/runtime config so Livewire uploads and UI updates work reliably.

Made-with: Cursor
yemeishu пре 1 месец
родитељ
комит
7c5cb5881f

+ 11 - 0
Dockerfile

@@ -82,6 +82,17 @@ RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
     && docker-php-ext-enable redis \
     && apk del .build-deps
 
+# 调高 PHP 上传限制(Livewire 多图上传需要)
+RUN { \
+      echo "file_uploads=On"; \
+      echo "upload_max_filesize=20M"; \
+      echo "post_max_size=64M"; \
+      echo "max_file_uploads=50"; \
+      echo "max_execution_time=120"; \
+      echo "max_input_time=120"; \
+      echo "memory_limit=512M"; \
+    } > /usr/local/etc/php/conf.d/99-upload-limits.ini
+
 # 安装 Composer - 使用国内镜像
 COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
 RUN composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/

+ 3 - 1
app/Filament/Resources/TextbookResource.php

@@ -97,11 +97,13 @@ class TextbookResource extends Resource
 
     public static function getPages(): array
     {
+        // 具体路径(…/covers、…/edit)必须写在 `/{record}` 详情页之前注册,避免个别环境下路由匹配异常。
         return [
             'index' => Pages\ManageTextbooks::route('/'),
             'create' => Pages\CreateTextbook::route('/create'),
-            'view' => Pages\ViewTextbook::route('/{record}'),
+            'covers' => Pages\ManageTextbookCovers::route('/{record}/covers'),
             'edit' => Pages\EditTextbook::route('/{record}/edit'),
+            'view' => Pages\ViewTextbook::route('/{record}'),
         ];
     }
 

+ 39 - 52
app/Filament/Resources/TextbookResource/Pages/EditTextbook.php

@@ -4,19 +4,15 @@ namespace App\Filament\Resources\TextbookResource\Pages;
 
 use App\Filament\Resources\TextbookResource;
 use App\Services\TextbookApiService;
-use App\Services\TextbookCoverStorageService;
-use App\Models\Textbook;
 use Filament\Forms;
 use Filament\Actions;
 use Filament\Schemas\Components\Section;
 use Filament\Resources\Pages\Page;
-use Illuminate\Http\Request;
-use Livewire\WithFileUploads;
+use Throwable;
 
 class EditTextbook extends Page implements Forms\Contracts\HasForms
 {
     use Forms\Concerns\InteractsWithForms;
-    use WithFileUploads;
 
     protected static string $resource = TextbookResource::class;
 
@@ -24,12 +20,11 @@ class EditTextbook extends Page implements Forms\Contracts\HasForms
 
     public ?int $recordId = null;
 
-    public function mount(Request $request): void
+    public function mount(int|string $record): void
     {
-        // 从路由参数获取教材ID,避免Livewire的隐式绑定
-        $this->recordId = (int) $request->route('record');
+        $this->recordId = (int) $record;
 
-        if (!$this->recordId) {
+        if ($this->recordId <= 0) {
             abort(404);
         }
 
@@ -41,49 +36,58 @@ class EditTextbook extends Page implements Forms\Contracts\HasForms
             abort(404);
         }
 
-        // 初始化表单数据
         $this->data = $textbookData;
         $this->form->fill($this->data);
     }
 
     public function save(): void
     {
-        // 验证数据
-        $this->validate([
-            'data.series_id' => 'required|integer',
-            'data.stage' => 'required|string',
-            'data.grade' => 'nullable|integer',
-            'data.semester' => 'nullable|integer',
-            'data.official_title' => 'required|string|max:255',
-            'data.isbn' => 'nullable|string|max:255',
-            'data.status' => 'required|string|in:draft,published,archived',
-        ]);
-
-        // 只传递标量字段到API,跳过关系字段和嵌套对象
+        // 必须用 form->getState():会触发临时文件落盘/春笋 URL、再执行 dehydrateStateUsing 得到逗号串。
+        // 直接读 $this->data 会跳过上述步骤,导致「保存无效」且新图不会上传。
+        $data = $this->form->getState();
+
         $allowedFields = [
             'series_id', 'stage', 'grade', 'semester', 'naming_scheme',
             'track', 'module_type', 'volume_no', 'legacy_code',
             'curriculum_standard_year', 'curriculum_revision_year',
             'approval_authority', 'approval_year', 'edition_label',
             'official_title', 'aliases', 'isbn',
-            'cover_path', 'status', 'sort_order', 'meta'
+            'status', 'sort_order', 'meta',
         ];
 
         $updateData = [];
         foreach ($allowedFields as $field) {
-            if (isset($this->data[$field]) && !is_array($this->data[$field]) && !is_object($this->data[$field])) {
-                $updateData[$field] = $this->data[$field];
+            if (! array_key_exists($field, $data)) {
+                continue;
+            }
+            $value = $data[$field];
+            if (is_array($value) || is_object($value)) {
+                continue;
             }
+            $updateData[$field] = $value;
         }
 
-        // 调用API更新
-        $apiService = app(TextbookApiService::class);
-        $updatedData = $apiService->updateTextbook($this->recordId, $updateData);
+        try {
+            $apiService = app(TextbookApiService::class);
+            $response = $apiService->updateTextbook($this->recordId, $updateData);
+        } catch (Throwable $e) {
+            \Filament\Notifications\Notification::make()
+                ->title('保存失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+
+            return;
+        }
 
-        // 更新本地数据
-        $this->data = array_merge($this->data, $updatedData);
+        $row = $response['data'] ?? $response;
+        if (! is_array($row)) {
+            $row = [];
+        }
+
+        $this->data = array_merge($this->data, $row);
+        $this->form->fill($this->data);
 
-        // 显示成功消息
         \Filament\Notifications\Notification::make()
             ->title('教材更新成功')
             ->success()
@@ -93,6 +97,10 @@ class EditTextbook extends Page implements Forms\Contracts\HasForms
     protected function getHeaderActions(): array
     {
         return [
+            Actions\Action::make('covers')
+                ->label('管理配图')
+                ->icon('heroicon-o-photo')
+                ->url(fn (): string => static::$resource::getUrl('covers', ['record' => $this->recordId])),
             Actions\Action::make('back')
                 ->label('返回列表')
                 ->url(static::$resource::getUrl('index'))
@@ -172,27 +180,6 @@ class EditTextbook extends Page implements Forms\Contracts\HasForms
                             ->required(),
                     ])
                     ->columns(2),
-                Section::make('封面上传')
-                    ->schema([
-                        Forms\Components\FileUpload::make('cover_path')
-                            ->label('封面图片')
-                            ->image()
-                            ->directory('textbook-covers')
-                            ->saveUploadedFileUsing(function ($component, $file) {
-                                $uploader = app(TextbookCoverStorageService::class);
-                                $url = $uploader->uploadCover($file, $this->recordId ? (string) $this->recordId : null);
-                                if ($url) {
-                                    return $url;
-                                }
-                                return $file->storePubliclyAs(
-                                    $component->getDirectory(),
-                                    $component->getUploadedFileNameForStorage($file),
-                                    $component->getDiskName(),
-                                );
-                            })
-                            ->helperText('建议尺寸 600x800,JPG/PNG'),
-                    ])
-                    ->extraAttributes(['id' => 'cover']),
             ])
             ->statePath('data');
     }

+ 213 - 0
app/Filament/Resources/TextbookResource/Pages/ManageTextbookCovers.php

@@ -0,0 +1,213 @@
+<?php
+
+namespace App\Filament\Resources\TextbookResource\Pages;
+
+use App\Filament\Resources\TextbookResource;
+use App\Services\TextbookApiService;
+use App\Services\TextbookCoverStorageService;
+use Filament\Resources\Pages\Page;
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
+use Livewire\WithFileUploads;
+use Throwable;
+
+/**
+ * 教材配图独立页:与「编辑教材」分离,支持多次选择多图追加、排序、删除后统一保存。
+ */
+class ManageTextbookCovers extends Page
+{
+    use WithFileUploads;
+
+    protected static string $resource = TextbookResource::class;
+
+    public ?int $recordId = null;
+
+    public string $officialTitle = '';
+
+    /** @var list<string> */
+    public array $coverUrls = [];
+
+    /** 本次待追加的本地文件(可多次选择、多次点「追加到列表」) */
+    public array $photos = [];
+
+    public ?string $saveFeedback = null;
+    public string $saveFeedbackType = 'info';
+
+    /**
+     * 必须与路由参数名一致({record}),Livewire 全页组件会把 URL 段注入 mount,勿仅用 Request::route(部分环境下为空)。
+     */
+    public function mount(int|string $record): void
+    {
+        $this->recordId = (int) $record;
+
+        if ($this->recordId <= 0) {
+            abort(404);
+        }
+
+        $data = app(TextbookApiService::class)->getTextbook($this->recordId);
+
+        if (! $data) {
+            abort(404);
+        }
+
+        $this->officialTitle = (string) ($data['official_title'] ?? '');
+        $this->coverUrls = $this->splitCoverPath($data['cover_path'] ?? null);
+    }
+
+    /**
+     * @return list<string>
+     */
+    protected function splitCoverPath(?string $path): array
+    {
+        if ($path === null || $path === '') {
+            return [];
+        }
+
+        return array_values(array_unique(array_filter(array_map('trim', explode(',', $path)))));
+    }
+
+    public function appendPhotos(): void
+    {
+        if ($this->photos === []) {
+            \Filament\Notifications\Notification::make()
+                ->title('请先选择图片')
+                ->warning()
+                ->send();
+
+            return;
+        }
+
+        $this->validate([
+            'photos' => 'array',
+            'photos.*' => 'image|max:5120',
+        ]);
+
+        $storage = app(TextbookCoverStorageService::class);
+        $added = 0;
+        foreach ($this->photos as $file) {
+            if (! $file instanceof TemporaryUploadedFile) {
+                continue;
+            }
+            $url = $storage->uploadCover($file, (string) $this->recordId);
+            if ($url !== null && $url !== '') {
+                $this->coverUrls[] = $url;
+                $added++;
+            }
+        }
+
+        $this->photos = [];
+
+        $n = \Filament\Notifications\Notification::make()
+            ->title($added > 0 ? "已追加 {$added} 张" : '未能上传')
+            ->body($added > 0 ? '确认顺序后,点击页面底部「保存到服务器」写入教材。' : '请检查图片格式或存储配置。');
+
+        if ($added > 0) {
+            $n->success();
+        } else {
+            $n->warning();
+        }
+
+        $n->send();
+    }
+
+    public function removeAt(int $index): void
+    {
+        if (! isset($this->coverUrls[$index])) {
+            return;
+        }
+        unset($this->coverUrls[$index]);
+        $this->coverUrls = array_values($this->coverUrls);
+    }
+
+    public function moveUp(int $index): void
+    {
+        if ($index < 1 || ! isset($this->coverUrls[$index])) {
+            return;
+        }
+        $tmp = $this->coverUrls[$index - 1];
+        $this->coverUrls[$index - 1] = $this->coverUrls[$index];
+        $this->coverUrls[$index] = $tmp;
+    }
+
+    public function moveDown(int $index): void
+    {
+        $n = count($this->coverUrls);
+        if ($index >= $n - 1 || ! isset($this->coverUrls[$index])) {
+            return;
+        }
+        $tmp = $this->coverUrls[$index + 1];
+        $this->coverUrls[$index + 1] = $this->coverUrls[$index];
+        $this->coverUrls[$index] = $tmp;
+    }
+
+    public function saveCovers(): void
+    {
+        if ($this->coverUrls === []) {
+            $this->saveFeedback = '当前没有可保存的配图,请先上传并加入列表。';
+            $this->saveFeedbackType = 'warning';
+
+            \Filament\Notifications\Notification::make()
+                ->title('暂无可保存图片')
+                ->warning()
+                ->send();
+
+            return;
+        }
+
+        \Log::info('ManageTextbookCovers::saveCovers start', [
+            'record_id' => $this->recordId,
+            'count' => count($this->coverUrls),
+        ]);
+
+        $csv = $this->coverUrls === [] ? null : implode(',', $this->coverUrls);
+
+        try {
+            $response = app(TextbookApiService::class)->updateTextbook($this->recordId, [
+                'cover_path' => $csv,
+            ]);
+        } catch (Throwable $e) {
+            \Log::error('ManageTextbookCovers::saveCovers failed', [
+                'record_id' => $this->recordId,
+                'error' => $e->getMessage(),
+            ]);
+
+            $this->saveFeedback = '保存失败:' . $e->getMessage();
+            $this->saveFeedbackType = 'error';
+
+            \Filament\Notifications\Notification::make()
+                ->title('保存失败')
+                ->body($e->getMessage())
+                ->danger()
+                ->send();
+
+            return;
+        }
+
+        $row = $response['data'] ?? $response;
+        if (is_array($row) && isset($row['cover_path']) && is_string($row['cover_path'])) {
+            $this->coverUrls = $this->splitCoverPath($row['cover_path']);
+        }
+
+        \Log::info('ManageTextbookCovers::saveCovers success', [
+            'record_id' => $this->recordId,
+            'saved_count' => count($this->coverUrls),
+        ]);
+
+        $this->saveFeedback = '保存成功(' . now()->format('H:i:s') . ')';
+        $this->saveFeedbackType = 'success';
+
+        \Filament\Notifications\Notification::make()
+            ->title('配图已保存')
+            ->success()
+            ->send();
+    }
+
+    public function getTitle(): string
+    {
+        return '教材配图';
+    }
+
+    public function getView(): string
+    {
+        return 'filament.resources.textbook-resource.manage-covers';
+    }
+}

+ 122 - 16
app/Filament/Resources/TextbookResource/Schemas/TextbookFormSchema.php

@@ -5,15 +5,124 @@ namespace App\Filament\Resources\TextbookResource\Schemas;
 use App\Filament\Resources\TextbookResource;
 use App\Services\TextbookApiService;
 use App\Services\TextbookCoverStorageService;
+use Closure;
+use Filament\Forms\Components\BaseFileUpload;
 use Filament\Forms\Components\FileUpload;
 use Filament\Forms\Components\Select;
 use Filament\Schemas\Components\Section;
 use Filament\Forms\Components\TextInput;
 use Filament\Forms\Components\Textarea;
 use Filament\Schemas\Schema;
+use Illuminate\Filesystem\FilesystemAdapter;
+use League\Flysystem\UnableToCheckFileExistence;
+use Throwable;
 
 class TextbookFormSchema
 {
+    /**
+     * 封面多图上传。库存为春笋云等返回的绝对 URL 时,Filament 默认会对 disk 做 exists(),
+     * 非磁盘路径会被过滤掉,编辑页无法预览;需关闭远程信息拉取并为绝对 URL 直接提供预览地址。
+     */
+    public static function coverPathFileUpload(Closure $saveUploadedFileUsing): FileUpload
+    {
+        return FileUpload::make('cover_path')
+            ->label('教材配图')
+            ->image()
+            ->multiple()
+            ->reorderable()
+            ->directory('textbook-covers')
+            ->fetchFileInformation(false)
+            ->formatStateUsing(function ($state) {
+                // FileUploadStateCast::get() 会把 DB 里「逗号拼接的一整串」变成单元素数组
+                // ['https://a.jpg,https://b.jpg'],必须逐项再按逗号拆开,否则会当成一个路径无法预览。
+                if (is_string($state)) {
+                    return array_values(array_filter(array_map('trim', explode(',', $state))));
+                }
+
+                if (! is_array($state)) {
+                    return [];
+                }
+
+                $urls = [];
+                foreach ($state as $part) {
+                    if (! is_string($part) || $part === '') {
+                        continue;
+                    }
+                    foreach (array_map('trim', explode(',', $part)) as $piece) {
+                        if ($piece !== '') {
+                            $urls[] = $piece;
+                        }
+                    }
+                }
+
+                return array_values(array_unique($urls));
+            })
+            ->dehydrateStateUsing(function ($state) {
+                if (is_string($state)) {
+                    return trim($state);
+                }
+                if (! is_array($state)) {
+                    return null;
+                }
+
+                $urls = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $state)));
+
+                return empty($urls) ? null : implode(',', $urls);
+            })
+            ->getUploadedFileUsing(static function (BaseFileUpload $component, string $file, string|array|null $storedFileNames): ?array {
+                $trimmed = trim($file);
+                if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
+                    $path = parse_url($trimmed, PHP_URL_PATH) ?: '';
+                    $name = basename((string) $path) ?: 'image';
+
+                    return [
+                        'name' => $name,
+                        'size' => 0,
+                        'type' => null,
+                        'url' => $trimmed,
+                    ];
+                }
+
+                /** @var FilesystemAdapter $storage */
+                $storage = $component->getDisk();
+                $shouldFetchFileInformation = $component->shouldFetchFileInformation();
+
+                if ($shouldFetchFileInformation) {
+                    try {
+                        if (! $storage->exists($file)) {
+                            return null;
+                        }
+                    } catch (UnableToCheckFileExistence $exception) {
+                        return null;
+                    }
+                }
+
+                $url = null;
+
+                if ($component->getVisibility() === 'private') {
+                    try {
+                        $url = $storage->temporaryUrl(
+                            $file,
+                            now()->addMinutes(30)->endOfHour(),
+                        );
+                    } catch (Throwable $exception) {
+                        // Driver may not support temporary URLs.
+                    }
+                }
+
+                $url ??= $storage->url($file);
+
+                return [
+                    'name' => ($component->isMultiple() ? ($storedFileNames[$file] ?? null) : $storedFileNames) ?? basename($file),
+                    'size' => $shouldFetchFileInformation ? $storage->size($file) : 0,
+                    'type' => $shouldFetchFileInformation ? $storage->mimeType($file) : null,
+                    'url' => $url,
+                ];
+            })
+            ->saveUploadedFileUsing($saveUploadedFileUsing)
+            ->helperText('支持多张图片上传(如封面、目录页),保存时按上传/排序顺序以英文逗号拼接。');
+    }
+
     public static function make(Schema $schema): Schema
     {
         return $schema
@@ -92,22 +201,19 @@ class TextbookFormSchema
 
                 Section::make('封面上传')
                     ->schema([
-                        FileUpload::make('cover_path')
-                            ->label('封面图片')
-                            ->image()
-                            ->directory('textbook-covers')
-                            ->saveUploadedFileUsing(function ($component, $file) {
-                                $uploader = app(TextbookCoverStorageService::class);
-                                $url = $uploader->uploadCover($file, null);
-                                if ($url) {
-                                    return $url;
-                                }
-                                return $file->storePubliclyAs(
-                                    $component->getDirectory(),
-                                    $component->getUploadedFileNameForStorage($file),
-                                    $component->getDiskName(),
-                                );
-                            }),
+                        static::coverPathFileUpload(function ($component, $file) {
+                            $uploader = app(TextbookCoverStorageService::class);
+                            $url = $uploader->uploadCover($file, null);
+                            if ($url) {
+                                return $url;
+                            }
+
+                            return $file->storePubliclyAs(
+                                $component->getDirectory(),
+                                $component->getUploadedFileNameForStorage($file),
+                                $component->getDiskName(),
+                            );
+                        }),
                     ]),
 
                 Section::make('发布与排序')

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

@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Model;
 use Filament\Tables\Enums\FiltersLayout;
 use Filament\Tables\Filters\Filter;
 use Filament\Forms\Components\TextInput;
+use Illuminate\Support\HtmlString;
 
 class TextbookTable
 {
@@ -24,7 +25,32 @@ class TextbookTable
             ->defaultSort('id', 'asc')
             ->columns([
                 TextColumn::make('id')->label('ID')->sortable(),
-                TextColumn::make('official_title')->label('教材名称')->searchable()->wrap(),
+                TextColumn::make('official_title')
+                    ->label('教材名称')
+                    ->searchable()
+                    ->wrap()
+                    // 副标题链接:不依赖单独「配图」列是否被横向滚动/列管理隐藏,用户一定能点到
+                    ->description(function (Model $record): HtmlString {
+                        $href = TextbookResource::getUrl('covers', ['record' => $record->getKey()]);
+
+                        return new HtmlString(
+                            '<a href="' . e($href) . '" class="fi-link fi-size-sm text-primary-600 hover:underline dark:text-primary-400">管理配图</a>'
+                        );
+                    }),
+                // 独立列:用 HTML 直链,避免 TextColumn::url() + icon/color 与 getUrl($stateItem) 组合导致链接丢失
+                TextColumn::make('covers_nav')
+                    ->label('配图')
+                    ->alignCenter()
+                    ->tooltip('上传、排序、删除多图(独立页面)')
+                    ->state(static fn (): string => "\u{00A0}")
+                    ->formatStateUsing(static function ($state, TextColumn $column): HtmlString {
+                        $href = TextbookResource::getUrl('covers', ['record' => $column->getRecord()->getKey()]);
+
+                        return new HtmlString(
+                            '<a href="' . e($href) . '" class="fi-link fi-size-sm font-medium text-primary-600 hover:underline dark:text-primary-400">管理配图</a>'
+                        );
+                    })
+                    ->html(),
                 TextColumn::make('series_name')->label('教材系列')->sortable(),
                 TextColumn::make('series_id')->label('系列ID')->sortable(),
                 TextColumn::make('stage')
@@ -102,6 +128,13 @@ class TextbookTable
                     ->iconButton()
                     ->tooltip('编辑'),
 
+                Action::make('covers')
+                    ->label('配图')
+                    ->icon('heroicon-o-photo')
+                    ->color('primary')
+                    ->tooltip('管理配图')
+                    ->url(fn (Model $record): string => TextbookResource::getUrl('covers', ['record' => $record->getKey()])),
+
                 Action::make('delete')
                     ->label('删除')
                     ->color('danger')

+ 37 - 17
app/Http/Controllers/ExamPdfController.php

@@ -1409,13 +1409,12 @@ class ExamPdfController extends Controller
         ]);
 
         try {
-            $papers = Paper::with('questions')
+            $baseQuery = Paper::query()
                 ->whereBetween('created_at', [$startTime, $endTime])
                 ->whereNull('completed_at')
-                ->orderBy('paper_id')
-                ->get();
+                ->orderBy('paper_id');
 
-            if ($papers->isEmpty()) {
+            if (! $baseQuery->exists()) {
                 return response()->json([
                     'success' => true,
                     'message' => '未找到符合条件的试卷',
@@ -1429,15 +1428,31 @@ class ExamPdfController extends Controller
 
             $queued = [];
             $skipped = 0;
-            foreach ($papers as $paper) {
-                if ($paper->questions->isEmpty()) {
-                    $skipped++;
+            $baseQuery
+                ->select('paper_id')
+                ->chunk(500, function ($papers) use (&$queued, &$skipped) {
+                    $paperIds = $papers->pluck('paper_id')->filter()->values()->all();
+                    if (empty($paperIds)) {
+                        return;
+                    }
 
-                    continue;
-                }
-                RegeneratePdfJob::dispatch($paper->paper_id);
-                $queued[] = $paper->paper_id;
-            }
+                    // 仅查询有题目的 paper_id,避免 with('questions') 把整批题目载入内存。
+                    $paperIdsWithQuestions = \App\Models\PaperQuestion::query()
+                        ->whereIn('paper_id', $paperIds)
+                        ->distinct()
+                        ->pluck('paper_id')
+                        ->flip();
+
+                    foreach ($paperIds as $paperId) {
+                        if (! isset($paperIdsWithQuestions[$paperId])) {
+                            $skipped++;
+
+                            continue;
+                        }
+                        RegeneratePdfJob::dispatch($paperId);
+                        $queued[] = $paperId;
+                    }
+                });
 
             Log::info('RegeneratePdfBatch: 已投递', [
                 'start_date' => $startDate,
@@ -1495,16 +1510,21 @@ class ExamPdfController extends Controller
         Log::info('RegeneratePdfBatchByIds: 投递队列', ['paper_ids' => $paperIds, 'count' => count($paperIds)]);
 
         try {
-            $papers = Paper::with('questions')
+            $existingPaperIds = Paper::query()
+                ->whereIn('paper_id', $paperIds)
+                ->pluck('paper_id')
+                ->flip();
+
+            $paperIdsWithQuestions = \App\Models\PaperQuestion::query()
                 ->whereIn('paper_id', $paperIds)
-                ->get()
-                ->keyBy('paper_id');
+                ->distinct()
+                ->pluck('paper_id')
+                ->flip();
 
             $queued = [];
             $skipped = 0;
             foreach ($paperIds as $paperId) {
-                $paper = $papers->get($paperId);
-                if (! $paper || $paper->questions->isEmpty()) {
+                if (! isset($existingPaperIds[$paperId]) || ! isset($paperIdsWithQuestions[$paperId])) {
                     $skipped++;
 
                     continue;

+ 7 - 0
app/Services/TextbookApiService.php

@@ -861,6 +861,13 @@ class TextbookApiService
             return '';
         }
 
+        // cover_path 可能存储为多图逗号拼接,这里默认返回第一张作为封面图。
+        $coverParts = array_values(array_filter(array_map('trim', explode(',', (string) $coverPath))));
+        $coverPath = $coverParts[0] ?? '';
+        if ($coverPath === '') {
+            return '';
+        }
+
         // 如果已经是完整URL,直接返回
         if (str_starts_with($coverPath, 'http://') || str_starts_with($coverPath, 'https://')) {
             return $coverPath;

+ 1 - 4
docker-compose.mount.yml

@@ -1,7 +1,4 @@
-# 代码卷映射模式的 docker-compose 配置
-# 用于快速部署:只需 git pull + 清缓存,无需 build
-#
-# 使用方式:docker compose -f docker-compose.yml -f docker-compose.mount.yml up -d
+# 可选叠加:`docker-compose.yml` 已默认挂载源码;仅在需要覆盖挂载策略时再 `-f` 本文件。
 
 services:
   app:

+ 16 - 2
docker-compose.yml

@@ -11,8 +11,14 @@ services:
     env_file:
       - .env
     volumes:
-      - ./storage:/app/storage  # 日志 + 临时文件 + OCR上传
-      - ./.env:/app/.env        # 环境配置文件
+      # 源码挂载:宿主机改 PHP/Blade 立即进容器(否则镜像内仍是旧代码,改什么都不生效)
+      - .:/app
+      - /app/vendor
+      - /app/node_modules
+      - /app/public/build
+      - ./storage:/app/storage
+      - ./.env:/app/.env
+      - ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
     restart: unless-stopped
     healthcheck:
       test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
@@ -31,6 +37,10 @@ services:
     env_file:
       - .env
     volumes:
+      - .:/app
+      - /app/vendor
+      - /app/node_modules
+      - /app/public/build
       - ./storage:/app/storage
       - ./.env:/app/.env
     restart: unless-stopped
@@ -75,6 +85,10 @@ services:
     env_file:
       - .env
     volumes:
+      - .:/app
+      - /app/vendor
+      - /app/node_modules
+      - /app/public/build
       - ./storage:/app/storage
       - ./.env:/app/.env
     restart: unless-stopped

+ 8 - 0
docker/nginx.conf

@@ -49,6 +49,13 @@ http {
             try_files $uri $uri/ /index.php?$query_string;
         }
 
+        # Livewire v4 动态资源路由(如 /livewire-<hash>/livewire.js)
+        # 这些 .js/.css 不是静态文件,必须回退到 Laravel 路由处理。
+        # 量词 {8} 必须放在引号内,否则 Nginx 会把 { 当成配置块
+        location ~ "^/livewire-[a-f0-9]{8}/" {
+            try_files $uri $uri/ /index.php?$query_string;
+        }
+
         # PHP-FPM 处理
         location ~ \.php$ {
             fastcgi_pass 127.0.0.1:9000;
@@ -69,6 +76,7 @@ http {
 
         # 静态文件缓存
         location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+            try_files $uri =404;
             expires 1y;
             add_header Cache-Control "public, immutable";
             access_log off;

+ 5 - 2
resources/views/filament/resources/textbook-resource/edit.blade.php

@@ -3,8 +3,11 @@
         @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>'),
+            'subtitle' => '基础信息在此页修改;封面与多图在「管理配图」中单独上传与排序。',
+            'actions' => new \Illuminate\Support\HtmlString(
+                '<a class="btn btn-primary" href="' . \App\Filament\Resources\TextbookResource::getUrl('covers', ['record' => $recordId]) . '">管理配图</a>'
+                . '<a class="btn btn-outline" href="' . route('filament.admin.resources.textbooks.index') . '">返回列表</a>'
+            ),
         ])
 
         <div class="ui-card">

+ 221 - 0
resources/views/filament/resources/textbook-resource/manage-covers.blade.php

@@ -0,0 +1,221 @@
+<div class="ui-page">
+    <div class="mx-auto flex max-w-4xl flex-col gap-6 px-4 py-6 sm:py-8">
+        @include('filament.partials.page-header', [
+            'kicker' => '教材配图',
+            'title' => '管理配图',
+            'subtitle' => '与「编辑教材」分开维护;可多次多选追加,排好序后一次保存到教材数据。',
+            'actions' => new \Illuminate\Support\HtmlString(
+                '<a class="btn btn-ghost border border-slate-200 text-slate-700 hover:bg-slate-50" href="' . \App\Filament\Resources\TextbookResource::getUrl('edit', ['record' => $recordId]) . '">编辑教材信息</a>' .
+                '<a class="btn btn-outline border-slate-200" href="' . \App\Filament\Resources\TextbookResource::getUrl('index') . '">返回列表</a>'
+            ),
+        ])
+
+        @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">
+                <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>
+            </div>
+        @endif
+
+        <div class="ui-card overflow-hidden">
+            <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>
+                </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) }} 张
+                </span>
+            </div>
+
+            <div class="ui-card-body space-y-8 !py-6">
+                {{-- 上传区 --}}
+                <div
+                    class="rounded-2xl border border-slate-200 bg-gradient-to-b from-slate-50/80 to-white p-5 sm:p-6"
+                    x-data="{
+                        previews: [],
+                        uploading: false,
+                        progress: 0,
+                        refreshPreviews(event) {
+                            this.previews.forEach((p) => URL.revokeObjectURL(p.url))
+                            const files = Array.from(event.target.files || [])
+                            this.previews = files.map((file, idx) => ({
+                                id: idx,
+                                name: file.name,
+                                sizeKb: Math.max(1, Math.round(file.size / 1024)),
+                                url: URL.createObjectURL(file),
+                            }))
+                        },
+                        clearPreviews() {
+                            this.previews.forEach((p) => URL.revokeObjectURL(p.url))
+                            this.previews = []
+                            this.progress = 0
+                        },
+                    }"
+                    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-error="uploading = false"
+                >
+                    <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;">
+                            <label class="block">
+                                <span class="text-sm font-medium text-slate-800">选择本地图片</span>
+                                <span class="ml-1.5 text-xs font-normal text-slate-500">(可多选)</span>
+                            </label>
+                            <div class="flex flex-col gap-3 rounded-xl border-2 border-dashed border-slate-200 bg-white/80 p-4 transition-colors hover:border-slate-300">
+                                <input
+                                    type="file"
+                                    wire:model="photos"
+                                    multiple
+                                    accept="image/jpeg,image/png,image/webp"
+                                    x-ref="photoInput"
+                                    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>
+                            </div>
+                            <div class="space-y-2" x-show="previews.length > 0" x-cloak>
+                                <div class="flex items-center justify-between">
+                                    <p class="text-xs font-medium text-slate-600">本次待上传预览</p>
+                                    <p class="text-xs text-slate-500" x-show="uploading">上传中:<span x-text="progress"></span>%</p>
+                                </div>
+                                <ul class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
+                                    <template x-for="item in previews" :key="item.id">
+                                        <li class="space-y-1">
+                                            <div
+                                                class="rounded-xl p-[2px] transition-all"
+                                                :style="uploading
+                                                    ? `background: conic-gradient(#0ea5e9 ${progress * 3.6}deg, #e2e8f0 0deg);`
+                                                    : 'background:#e2e8f0;'"
+                                            >
+                                                <div class="relative aspect-square overflow-hidden rounded-[10px] bg-white">
+                                                    <img :src="item.url" alt="" class="h-full w-full object-cover" />
+                                                    <div
+                                                        class="absolute inset-0 flex items-center justify-center bg-slate-900/35 text-xs font-semibold text-white"
+                                                        x-show="uploading"
+                                                    >
+                                                        <span x-text="`${progress}%`"></span>
+                                                    </div>
+                                                </div>
+                                            </div>
+                                            <p class="truncate text-[11px] text-slate-500" :title="item.name" x-text="item.name"></p>
+                                            <p class="text-[10px] text-slate-400" x-text="`${item.sizeKb} KB`"></p>
+                                        </li>
+                                    </template>
+                                </ul>
+                            </div>
+                            <div wire:loading wire:target="photos" class="flex items-center gap-2 text-sm text-slate-500">
+                                <span class="loading loading-spinner loading-sm text-sky-500"></span>
+                                读取文件中…
+                            </div>
+                            @error('photos.*')
+                                <p class="text-sm text-error">{{ $message }}</p>
+                            @enderror
+                        </div>
+                        <div class="flex shrink-0 flex-col items-stretch gap-2" style="flex:0 0 170px;min-width:170px;">
+                            <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)"
+                                class="btn btn-primary w-full border-0 bg-sky-500 text-white shadow-sm hover:bg-sky-600"
+                                style="width:100%;"
+                            >
+                                <span wire:loading.remove wire:target="appendPhotos" 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="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>
+                                    处理中
+                                </span>
+                            </button>
+                        </div>
+                    </div>
+                </div>
+
+                {{-- 预览列表 --}}
+                <div>
+                    <div class="mb-4 flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 pb-3">
+                        <h3 class="text-sm font-semibold text-slate-900">配图顺序</h3>
+                        <p class="text-xs text-slate-500">首张通常为封面;可用上移 / 下移调整。</p>
+                    </div>
+
+                    @if(count($coverUrls) === 0)
+                        <div class="ui-empty bg-slate-50/50 py-12">
+                            <div class="rounded-full bg-slate-100 p-4 text-slate-400">
+                                <svg class="mx-auto h-10 w-10" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
+                                    <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>
+                            </div>
+                            <p class="ui-empty-title">暂无配图</p>
+                            <p class="ui-empty-desc max-w-sm">从上方选择图片并「加入列表」,或先在存储/API 侧同步后再刷新本页。</p>
+                        </div>
+                    @else
+                        <ul class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
+                            @foreach($coverUrls as $i => $url)
+                                <li class="group overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm ring-slate-100 transition hover:border-slate-300 hover:shadow-md">
+                                    <div class="relative aspect-[3/4] w-full overflow-hidden bg-slate-100">
+                                        @if($i === 0)
+                                            <span class="absolute left-2 top-2 z-10 rounded-md bg-sky-500 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white shadow-sm">封面</span>
+                                        @endif
+                                        <img src="{{ $url }}" alt="" class="h-full w-full object-contain" loading="lazy" />
+                                    </div>
+                                    <div class="border-t border-slate-100 p-3">
+                                        <p class="truncate font-mono text-[11px] text-slate-400" title="{{ $url }}">{{ \Illuminate\Support\Str::limit($url, 36) }}</p>
+                                        <div class="mt-2 flex flex-wrap gap-1">
+                                            <button type="button" wire:click="moveUp({{ $i }})" class="btn btn-ghost btn-xs h-7 min-h-0 gap-0 border border-transparent px-2 hover:border-slate-200" @if($i === 0) disabled @endif>上移</button>
+                                            <button type="button" wire:click="moveDown({{ $i }})" class="btn btn-ghost btn-xs h-7 min-h-0 gap-0 border border-transparent px-2 hover:border-slate-200" @if($i === count($coverUrls) - 1) disabled @endif>下移</button>
+                                            <button type="button" wire:click="removeAt({{ $i }})" class="btn btn-ghost btn-xs h-7 min-h-0 text-rose-600 hover:bg-rose-50">移除</button>
+                                        </div>
+                                    </div>
+                                </li>
+                            @endforeach
+                        </ul>
+                    @endif
+                </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>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>

+ 21 - 3
resources/views/filament/resources/textbook-resource/view.blade.php

@@ -25,10 +25,15 @@
                             @php
                                 $cover = $this->record->cover_path ?? null;
                                 $coverUrl = null;
+                                $coverUrls = [];
                                 if ($cover) {
-                                    $coverUrl = \Illuminate\Support\Str::startsWith($cover, ['http://', 'https://', '/'])
-                                        ? $cover
-                                        : \Illuminate\Support\Facades\Storage::disk('public')->url($cover);
+                                    $covers = array_values(array_filter(array_map('trim', explode(',', (string) $cover))));
+                                    foreach ($covers as $item) {
+                                        $coverUrls[] = \Illuminate\Support\Str::startsWith($item, ['http://', 'https://', '/'])
+                                            ? $item
+                                            : \Illuminate\Support\Facades\Storage::disk('public')->url($item);
+                                    }
+                                    $coverUrl = $coverUrls[0] ?? null;
                                 }
                             @endphp
                             @if($coverUrl)
@@ -51,6 +56,19 @@
                             <div class="ui-badge-muted">状态:{{ $this->record->status ?? '未知' }}</div>
                             <div class="ui-badge-muted">ID:{{ $this->record->id }}</div>
                         </div>
+                        @if(!empty($coverUrls))
+                            <div>
+                                <div class="mb-2 text-sm font-medium text-slate-700">教材配图({{ count($coverUrls) }})</div>
+                                <div class="grid grid-cols-2 gap-2">
+                                    @foreach($coverUrls as $index => $url)
+                                        <a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="group block overflow-hidden rounded-lg border border-slate-200">
+                                            <img src="{{ $url }}" alt="教材配图{{ $index + 1 }}" class="h-28 w-full object-cover transition group-hover:scale-[1.02]" />
+                                            <div class="border-t border-slate-100 px-2 py-1 text-center text-xs text-slate-500">第 {{ $index + 1 }} 张</div>
+                                        </a>
+                                    @endforeach
+                                </div>
+                            </div>
+                        @endif
                     </div>
                 </div>