ManageTextbookCovers.php 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. <?php
  2. namespace App\Filament\Resources\TextbookResource\Pages;
  3. use App\Filament\Resources\TextbookResource;
  4. use App\Models\Textbook;
  5. use App\Models\TextbookSeries;
  6. use App\Services\TextbookApiService;
  7. use App\Services\TextbookCoverStorageService;
  8. use Filament\Resources\Pages\Page;
  9. use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
  10. use Livewire\WithFileUploads;
  11. use Throwable;
  12. /**
  13. * 教材配图独立页:与「编辑教材」分离,支持多次选择多图追加、排序、删除后统一保存。
  14. */
  15. class ManageTextbookCovers extends Page
  16. {
  17. use WithFileUploads;
  18. protected static string $resource = TextbookResource::class;
  19. public ?int $recordId = null;
  20. public string $officialTitle = '';
  21. public string $seriesName = '';
  22. public ?array $previousTextbook = null;
  23. public ?array $nextTextbook = null;
  24. /** @var list<string> */
  25. public array $coverUrls = [];
  26. /** 本次待追加的本地文件(可多次选择、多次点「追加到列表」) */
  27. public array $photos = [];
  28. public ?string $saveFeedback = null;
  29. public string $saveFeedbackType = 'info';
  30. public bool $hasUnsavedChanges = false;
  31. /**
  32. * 必须与路由参数名一致({record}),Livewire 全页组件会把 URL 段注入 mount,勿仅用 Request::route(部分环境下为空)。
  33. */
  34. public function mount(int|string $record): void
  35. {
  36. $this->recordId = (int) $record;
  37. if ($this->recordId <= 0) {
  38. abort(404);
  39. }
  40. $data = app(TextbookApiService::class)->getTextbook($this->recordId);
  41. if (! $data) {
  42. abort(404);
  43. }
  44. $this->officialTitle = (string) ($data['official_title'] ?? '');
  45. $this->seriesName = (string) (
  46. $data['series_name']
  47. ?? data_get($data, 'series.name')
  48. ?? TextbookSeries::query()->whereKey($data['series_id'] ?? null)->value('name')
  49. ?? ''
  50. );
  51. $this->coverUrls = $this->splitCoverPath($data['cover_path'] ?? null);
  52. $this->hydrateSiblingTextbooks($data);
  53. $this->hasUnsavedChanges = false;
  54. }
  55. protected function hydrateSiblingTextbooks(array $data): void
  56. {
  57. $seriesId = isset($data['series_id']) ? (int) $data['series_id'] : 0;
  58. if ($seriesId <= 0 || $this->recordId === null) {
  59. $this->previousTextbook = null;
  60. $this->nextTextbook = null;
  61. return;
  62. }
  63. $records = Textbook::query()
  64. ->where('series_id', $seriesId)
  65. ->orderBy('grade')
  66. ->orderBy('semester')
  67. ->orderBy('id')
  68. ->get(['id', 'official_title', 'grade', 'semester']);
  69. $currentIndex = $records->search(fn (Textbook $textbook): bool => (int) $textbook->getKey() === $this->recordId);
  70. if (! is_int($currentIndex)) {
  71. $this->previousTextbook = null;
  72. $this->nextTextbook = null;
  73. return;
  74. }
  75. $this->previousTextbook = $this->makeSiblingPayload($records->get($currentIndex - 1));
  76. $this->nextTextbook = $this->makeSiblingPayload($records->get($currentIndex + 1));
  77. }
  78. protected function makeSiblingPayload(?Textbook $textbook): ?array
  79. {
  80. if (! $textbook) {
  81. return null;
  82. }
  83. return [
  84. 'id' => (int) $textbook->getKey(),
  85. 'title' => (string) ($textbook->official_title ?? ''),
  86. 'meta' => $this->formatTextbookOrderLabel(
  87. isset($textbook->grade) ? (int) $textbook->grade : null,
  88. isset($textbook->semester) ? (int) $textbook->semester : null,
  89. ),
  90. 'url' => TextbookResource::getUrl('covers', ['record' => $textbook->getKey()]),
  91. ];
  92. }
  93. protected function formatTextbookOrderLabel(?int $grade, ?int $semester): string
  94. {
  95. $gradeLabel = match ($grade) {
  96. 1 => '一年级',
  97. 2 => '二年级',
  98. 3 => '三年级',
  99. 4 => '四年级',
  100. 5 => '五年级',
  101. 6 => '六年级',
  102. 7 => '七年级',
  103. 8 => '八年级',
  104. 9 => '九年级',
  105. 10 => '高一',
  106. 11 => '高二',
  107. 12 => '高三',
  108. default => $grade ? "{$grade}年级" : '未分年级',
  109. };
  110. $semesterLabel = match ($semester) {
  111. 1 => '上册',
  112. 2 => '下册',
  113. default => '未分学期',
  114. };
  115. return "{$gradeLabel} · {$semesterLabel}";
  116. }
  117. /**
  118. * @return list<string>
  119. */
  120. protected function splitCoverPath(?string $path): array
  121. {
  122. if ($path === null || $path === '') {
  123. return [];
  124. }
  125. return array_values(array_unique(array_filter(array_map('trim', explode(',', $path)))));
  126. }
  127. public function appendPhotos(): void
  128. {
  129. if ($this->photos === []) {
  130. \Filament\Notifications\Notification::make()
  131. ->title('请先选择图片')
  132. ->warning()
  133. ->send();
  134. return;
  135. }
  136. $this->validate(
  137. [
  138. 'photos' => 'array',
  139. 'photos.*' => 'image|max:10240',
  140. ],
  141. [
  142. 'photos.*.uploaded' => '图片上传失败(可能文件过大或上传中断),请重试。',
  143. 'photos.*.image' => '仅支持 JPG / PNG / WEBP 图片。',
  144. 'photos.*.max' => '单张图片不能超过 10MB。',
  145. ]
  146. );
  147. $storage = app(TextbookCoverStorageService::class);
  148. $added = 0;
  149. foreach ($this->photos as $file) {
  150. if (! $file instanceof TemporaryUploadedFile) {
  151. continue;
  152. }
  153. $url = $storage->uploadCover($file, (string) $this->recordId);
  154. if ($url !== null && $url !== '') {
  155. $this->coverUrls[] = $url;
  156. $added++;
  157. }
  158. }
  159. $this->photos = [];
  160. $n = \Filament\Notifications\Notification::make()
  161. ->title($added > 0 ? "已追加 {$added} 张" : '未能上传')
  162. ->body($added > 0 ? '确认顺序后,点击页面底部「保存到服务器」写入教材。' : '请检查图片格式或存储配置。');
  163. if ($added > 0) {
  164. $this->hasUnsavedChanges = true;
  165. $n->success();
  166. $this->dispatch('covers-appended', count: $added);
  167. } else {
  168. $n->warning();
  169. }
  170. $n->send();
  171. }
  172. public function removeAt(int $index): void
  173. {
  174. if (! isset($this->coverUrls[$index])) {
  175. return;
  176. }
  177. unset($this->coverUrls[$index]);
  178. $this->coverUrls = array_values($this->coverUrls);
  179. $this->hasUnsavedChanges = true;
  180. }
  181. public function moveUp(int $index): void
  182. {
  183. if ($index < 1 || ! isset($this->coverUrls[$index])) {
  184. return;
  185. }
  186. $tmp = $this->coverUrls[$index - 1];
  187. $this->coverUrls[$index - 1] = $this->coverUrls[$index];
  188. $this->coverUrls[$index] = $tmp;
  189. $this->hasUnsavedChanges = true;
  190. }
  191. public function moveDown(int $index): void
  192. {
  193. $n = count($this->coverUrls);
  194. if ($index >= $n - 1 || ! isset($this->coverUrls[$index])) {
  195. return;
  196. }
  197. $tmp = $this->coverUrls[$index + 1];
  198. $this->coverUrls[$index + 1] = $this->coverUrls[$index];
  199. $this->coverUrls[$index] = $tmp;
  200. $this->hasUnsavedChanges = true;
  201. }
  202. public function saveCovers(): void
  203. {
  204. \Log::info('ManageTextbookCovers::saveCovers start', [
  205. 'record_id' => $this->recordId,
  206. 'count' => count($this->coverUrls),
  207. ]);
  208. $csv = $this->coverUrls === [] ? null : implode(',', $this->coverUrls);
  209. try {
  210. $response = app(TextbookApiService::class)->updateTextbook($this->recordId, [
  211. 'cover_path' => $csv,
  212. ]);
  213. } catch (Throwable $e) {
  214. \Log::error('ManageTextbookCovers::saveCovers failed', [
  215. 'record_id' => $this->recordId,
  216. 'error' => $e->getMessage(),
  217. ]);
  218. $this->saveFeedback = '保存失败:' . $e->getMessage();
  219. $this->saveFeedbackType = 'error';
  220. \Filament\Notifications\Notification::make()
  221. ->title('保存失败')
  222. ->body($e->getMessage())
  223. ->danger()
  224. ->send();
  225. return;
  226. }
  227. $row = $response['data'] ?? $response;
  228. if (is_array($row) && isset($row['cover_path']) && is_string($row['cover_path'])) {
  229. $this->coverUrls = $this->splitCoverPath($row['cover_path']);
  230. }
  231. \Log::info('ManageTextbookCovers::saveCovers success', [
  232. 'record_id' => $this->recordId,
  233. 'saved_count' => count($this->coverUrls),
  234. ]);
  235. $isCleared = ($this->coverUrls === []);
  236. $this->saveFeedback = ($isCleared ? '已清空并保存' : '保存成功') . '(' . now()->format('H:i:s') . ')';
  237. $this->saveFeedbackType = 'success';
  238. $this->hasUnsavedChanges = false;
  239. \Filament\Notifications\Notification::make()
  240. ->title($isCleared ? '配图已清空' : '配图已保存')
  241. ->success()
  242. ->send();
  243. }
  244. public function getTitle(): string
  245. {
  246. return '教材配图';
  247. }
  248. public function getView(): string
  249. {
  250. return 'filament.resources.textbook-resource.manage-covers';
  251. }
  252. }