TextbookFormSchema.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. <?php
  2. namespace App\Filament\Resources\TextbookResource\Schemas;
  3. use App\Filament\Resources\TextbookResource;
  4. use App\Services\TextbookApiService;
  5. use App\Services\TextbookCoverStorageService;
  6. use Closure;
  7. use Filament\Forms\Components\BaseFileUpload;
  8. use Filament\Forms\Components\FileUpload;
  9. use Filament\Forms\Components\Select;
  10. use Filament\Schemas\Components\Section;
  11. use Filament\Forms\Components\TextInput;
  12. use Filament\Forms\Components\Textarea;
  13. use Filament\Schemas\Schema;
  14. use Illuminate\Filesystem\FilesystemAdapter;
  15. use League\Flysystem\UnableToCheckFileExistence;
  16. use Throwable;
  17. class TextbookFormSchema
  18. {
  19. /**
  20. * 封面多图上传。库存为春笋云等返回的绝对 URL 时,Filament 默认会对 disk 做 exists(),
  21. * 非磁盘路径会被过滤掉,编辑页无法预览;需关闭远程信息拉取并为绝对 URL 直接提供预览地址。
  22. */
  23. public static function coverPathFileUpload(Closure $saveUploadedFileUsing): FileUpload
  24. {
  25. return FileUpload::make('cover_path')
  26. ->label('教材配图')
  27. ->image()
  28. ->multiple()
  29. ->reorderable()
  30. ->directory('textbook-covers')
  31. ->fetchFileInformation(false)
  32. ->formatStateUsing(function ($state) {
  33. // FileUploadStateCast::get() 会把 DB 里「逗号拼接的一整串」变成单元素数组
  34. // ['https://a.jpg,https://b.jpg'],必须逐项再按逗号拆开,否则会当成一个路径无法预览。
  35. if (is_string($state)) {
  36. return array_values(array_filter(array_map('trim', explode(',', $state))));
  37. }
  38. if (! is_array($state)) {
  39. return [];
  40. }
  41. $urls = [];
  42. foreach ($state as $part) {
  43. if (! is_string($part) || $part === '') {
  44. continue;
  45. }
  46. foreach (array_map('trim', explode(',', $part)) as $piece) {
  47. if ($piece !== '') {
  48. $urls[] = $piece;
  49. }
  50. }
  51. }
  52. return array_values(array_unique($urls));
  53. })
  54. ->dehydrateStateUsing(function ($state) {
  55. if (is_string($state)) {
  56. return trim($state);
  57. }
  58. if (! is_array($state)) {
  59. return null;
  60. }
  61. $urls = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $state)));
  62. return empty($urls) ? null : implode(',', $urls);
  63. })
  64. ->getUploadedFileUsing(static function (BaseFileUpload $component, string $file, string|array|null $storedFileNames): ?array {
  65. $trimmed = trim($file);
  66. if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
  67. $path = parse_url($trimmed, PHP_URL_PATH) ?: '';
  68. $name = basename((string) $path) ?: 'image';
  69. return [
  70. 'name' => $name,
  71. 'size' => 0,
  72. 'type' => null,
  73. 'url' => $trimmed,
  74. ];
  75. }
  76. /** @var FilesystemAdapter $storage */
  77. $storage = $component->getDisk();
  78. $shouldFetchFileInformation = $component->shouldFetchFileInformation();
  79. if ($shouldFetchFileInformation) {
  80. try {
  81. if (! $storage->exists($file)) {
  82. return null;
  83. }
  84. } catch (UnableToCheckFileExistence $exception) {
  85. return null;
  86. }
  87. }
  88. $url = null;
  89. if ($component->getVisibility() === 'private') {
  90. try {
  91. $url = $storage->temporaryUrl(
  92. $file,
  93. now()->addMinutes(30)->endOfHour(),
  94. );
  95. } catch (Throwable $exception) {
  96. // Driver may not support temporary URLs.
  97. }
  98. }
  99. $url ??= $storage->url($file);
  100. return [
  101. 'name' => ($component->isMultiple() ? ($storedFileNames[$file] ?? null) : $storedFileNames) ?? basename($file),
  102. 'size' => $shouldFetchFileInformation ? $storage->size($file) : 0,
  103. 'type' => $shouldFetchFileInformation ? $storage->mimeType($file) : null,
  104. 'url' => $url,
  105. ];
  106. })
  107. ->saveUploadedFileUsing($saveUploadedFileUsing)
  108. ->helperText('支持多张图片上传(如封面、目录页),保存时按上传/排序顺序以英文逗号拼接。');
  109. }
  110. public static function make(Schema $schema): Schema
  111. {
  112. return $schema
  113. ->schema([
  114. Section::make('基本信息')
  115. ->schema([
  116. Select::make('series_id')
  117. ->label('教材系列')
  118. ->options(function () {
  119. $series = app(TextbookApiService::class)->getTextbookSeries();
  120. $options = [];
  121. foreach ($series['data'] as $s) {
  122. $displayName = $s['name'] ?? '未命名系列';
  123. if (!empty($s['publisher'])) {
  124. $displayName .= ' (' . $s['publisher'] . ')';
  125. }
  126. $options[$s['id']] = $displayName;
  127. }
  128. return $options;
  129. })
  130. ->required()
  131. ->searchable()
  132. ->preload(),
  133. TextInput::make('official_title')
  134. ->label('教材名称')
  135. ->maxLength(512)
  136. ->helperText('教材名称(用户输入)'),
  137. TextInput::make('isbn')
  138. ->label('ISBN')
  139. ->maxLength(20),
  140. Textarea::make('aliases')
  141. ->label('别名')
  142. ->helperText('JSON 格式,如:["别名1", "别名2"]')
  143. ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
  144. ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
  145. ->columnSpanFull(),
  146. ])
  147. ->columns(2),
  148. Section::make('学段/年级/学期')
  149. ->schema([
  150. Select::make('stage')
  151. ->label('学段')
  152. ->options([
  153. 'primary' => '小学',
  154. 'junior' => '初中',
  155. 'senior' => '高中',
  156. ])
  157. ->default('junior')
  158. ->required()
  159. ->reactive(),
  160. Select::make('schooling_system')
  161. ->label('学制')
  162. ->options([
  163. '63' => '六三学制',
  164. '54' => '五四学制',
  165. ])
  166. ->default('63')
  167. ->visible(fn ($get): bool => in_array($get('stage'), ['primary', 'junior'])),
  168. TextInput::make('grade')
  169. ->label('年级')
  170. ->numeric()
  171. ->minValue(1)
  172. ->maxValue(12)
  173. ->helperText('数字1-12,例:1年级填1'),
  174. Select::make('semester')
  175. ->label('学期')
  176. ->options([
  177. 1 => '上学期',
  178. 2 => '下学期',
  179. ])
  180. ->required(),
  181. ])
  182. ->columns(2),
  183. Section::make('封面上传')
  184. ->schema([
  185. static::coverPathFileUpload(function ($component, $file) {
  186. $uploader = app(TextbookCoverStorageService::class);
  187. $url = $uploader->uploadCover($file, null);
  188. if ($url) {
  189. return $url;
  190. }
  191. return $file->storePubliclyAs(
  192. $component->getDirectory(),
  193. $component->getUploadedFileNameForStorage($file),
  194. $component->getDiskName(),
  195. );
  196. }),
  197. ]),
  198. Section::make('发布与排序')
  199. ->schema([
  200. Select::make('status')
  201. ->label('状态')
  202. ->options([
  203. 'draft' => '草稿',
  204. 'published' => '已发布',
  205. 'archived' => '已归档',
  206. ])
  207. ->default('draft')
  208. ->required(),
  209. TextInput::make('sort_order')
  210. ->label('排序')
  211. ->numeric()
  212. ->default(0)
  213. ->helperText('数字越小排序越靠前'),
  214. Textarea::make('meta')
  215. ->label('扩展信息')
  216. ->helperText('JSON 格式')
  217. ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
  218. ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
  219. ->columnSpanFull(),
  220. ])
  221. ->columns(2),
  222. ])
  223. ->columns(2);
  224. }
  225. }