| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- <?php
- 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;
- 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 = '';
- public string $seriesName = '';
- public ?array $previousTextbook = null;
- public ?array $nextTextbook = null;
- /** @var list<string> */
- public array $coverUrls = [];
- /** 本次待追加的本地文件(可多次选择、多次点「追加到列表」) */
- public array $photos = [];
- public ?string $saveFeedback = null;
- public string $saveFeedbackType = 'info';
- public bool $hasUnsavedChanges = false;
- /**
- * 必须与路由参数名一致({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->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}";
- }
- /**
- * @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:10240',
- ],
- [
- 'photos.*.uploaded' => '图片上传失败(可能文件过大或上传中断),请重试。',
- 'photos.*.image' => '仅支持 JPG / PNG / WEBP 图片。',
- 'photos.*.max' => '单张图片不能超过 10MB。',
- ]
- );
- $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) {
- $this->hasUnsavedChanges = true;
- $n->success();
- $this->dispatch('covers-appended', count: $added);
- } 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);
- $this->hasUnsavedChanges = true;
- }
- 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;
- $this->hasUnsavedChanges = true;
- }
- 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;
- $this->hasUnsavedChanges = true;
- }
- public function saveCovers(): void
- {
- \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),
- ]);
- $isCleared = ($this->coverUrls === []);
- $this->saveFeedback = ($isCleared ? '已清空并保存' : '保存成功') . '(' . now()->format('H:i:s') . ')';
- $this->saveFeedbackType = 'success';
- $this->hasUnsavedChanges = false;
- \Filament\Notifications\Notification::make()
- ->title($isCleared ? '配图已清空' : '配图已保存')
- ->success()
- ->send();
- }
- public function getTitle(): string
- {
- return '教材配图';
- }
- public function getView(): string
- {
- return 'filament.resources.textbook-resource.manage-covers';
- }
- }
|