TextbookResource.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. <?php
  2. namespace App\Filament\Resources;
  3. use App\Filament\Resources\TextbookResource\Pages;
  4. use App\Models\Textbook;
  5. use App\Services\TextbookApiService;
  6. use App\Services\PdfStorageService;
  7. use BackedEnum;
  8. use UnitEnum;
  9. use Filament\Facades\Filament;
  10. use Filament\Schemas\Schema;
  11. use Filament\Forms\Components\TextInput;
  12. use Filament\Forms\Components\Select;
  13. use Filament\Forms\Components\Toggle;
  14. use Filament\Forms\Components\Textarea;
  15. use Filament\Forms\Components\FileUpload;
  16. use Filament\Resources\Resource;
  17. use Filament\Tables;
  18. use Filament\Tables\Columns\TextColumn;
  19. use Filament\Tables\Columns\BadgeColumn;
  20. use Filament\Tables\Columns\ToggleColumn;
  21. use Filament\Tables\Columns\ImageColumn;
  22. use Filament\Actions\EditAction;
  23. use Filament\Actions\DeleteAction;
  24. use Filament\Actions\Action;
  25. use Illuminate\Database\Eloquent\Model;
  26. use Illuminate\Database\Eloquent\Collection;
  27. class TextbookResource extends Resource
  28. {
  29. protected static ?string $model = Textbook::class;
  30. protected static ?string $recordTitleAttribute = 'official_title';
  31. protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-book-open';
  32. protected static ?string $navigationLabel = '教材管理';
  33. protected static UnitEnum|string|null $navigationGroup = '教材管理';
  34. protected static ?int $navigationSort = 2;
  35. protected static ?TextbookApiService $apiService = null;
  36. public static function boot()
  37. {
  38. parent::boot();
  39. static::$apiService = app(TextbookApiService::class);
  40. }
  41. protected static function getApiService(): TextbookApiService
  42. {
  43. if (!static::$apiService) {
  44. static::$apiService = app(TextbookApiService::class);
  45. }
  46. return static::$apiService;
  47. }
  48. public static function form(Schema $schema): Schema
  49. {
  50. return $schema
  51. ->schema([
  52. Select::make('series_id')
  53. ->label('教材系列')
  54. ->options(function () {
  55. $series = static::getApiService()->getTextbookSeries();
  56. $options = [];
  57. foreach ($series['data'] as $s) {
  58. $displayName = $s['name'];
  59. if (!empty($s['publisher'])) {
  60. $displayName .= ' (' . $s['publisher'] . ')';
  61. }
  62. $options[$s['id']] = $displayName;
  63. }
  64. return $options;
  65. })
  66. ->required()
  67. ->searchable()
  68. ->preload(),
  69. Select::make('stage')
  70. ->label('学段')
  71. ->options([
  72. 'primary' => '小学',
  73. 'junior' => '初中',
  74. 'senior' => '高中',
  75. ])
  76. ->default('junior')
  77. ->required()
  78. ->reactive(),
  79. Select::make('schooling_system')
  80. ->label('学制')
  81. ->options([
  82. '63' => '六三学制',
  83. '54' => '五四学制',
  84. ])
  85. ->default('63')
  86. ->visible(fn ($get): bool => in_array($get('stage'), ['primary', 'junior'])),
  87. TextInput::make('grade')
  88. ->label('年级')
  89. ->numeric()
  90. ->helperText('小学1-6、初中7-9、高中10-12'),
  91. Select::make('semester')
  92. ->label('学期')
  93. ->options([
  94. 1 => '上册',
  95. 2 => '下册',
  96. ])
  97. ->visible(fn ($get): bool => in_array($get('stage'), ['primary', 'junior'])),
  98. Select::make('naming_scheme')
  99. ->label('命名体系')
  100. ->options([
  101. 'new' => '新体系',
  102. 'old' => '旧体系',
  103. ])
  104. ->visible(fn ($get): bool => $get('stage') === 'senior')
  105. ->reactive(),
  106. Select::make('track')
  107. ->label('版本')
  108. ->options([
  109. 'A' => 'A版',
  110. 'B' => 'B版',
  111. ])
  112. ->visible(fn ($get): bool => $get('stage') === 'senior' && $get('naming_scheme') === 'new'),
  113. Select::make('module_type')
  114. ->label('模块类型')
  115. ->options([
  116. 'compulsory' => '必修',
  117. 'selective_compulsory' => '选择性必修',
  118. 'elective' => '选修',
  119. ])
  120. ->visible(fn ($get): bool => $get('stage') === 'senior'),
  121. TextInput::make('volume_no')
  122. ->label('册次')
  123. ->numeric()
  124. ->helperText('如:1、2、3')
  125. ->visible(fn ($get): bool => $get('stage') === 'senior' && $get('naming_scheme') === 'new'),
  126. TextInput::make('legacy_code')
  127. ->label('旧体系编码')
  128. ->placeholder('如:必修1、选修1-1')
  129. ->visible(fn ($get): bool => $get('stage') === 'senior' && $get('naming_scheme') === 'old'),
  130. TextInput::make('curriculum_standard_year')
  131. ->label('课标年代')
  132. ->numeric()
  133. ->helperText('义务教育:2011/2022,高中:2017'),
  134. TextInput::make('curriculum_revision_year')
  135. ->label('修订年份')
  136. ->numeric()
  137. ->helperText('高中:2020'),
  138. TextInput::make('approval_year')
  139. ->label('审定年份')
  140. ->numeric()
  141. ->helperText('如:2024'),
  142. TextInput::make('edition_label')
  143. ->label('版次标识')
  144. ->placeholder('如:2024秋版、修订版'),
  145. TextInput::make('isbn')
  146. ->label('ISBN')
  147. ->maxLength(32),
  148. FileUpload::make('cover_path')
  149. ->label('封面图片')
  150. ->image()
  151. ->imageEditor()
  152. ->imageResizeTargetWidth(400)
  153. ->imageResizeTargetHeight(600)
  154. ->imageCropAspectRatio('2:3')
  155. ->imagePreviewHeight('200')
  156. ->directory('textbook-covers')
  157. ->maxSize(5120) // 5MB
  158. ->hint('支持 JPG、PNG、WebP 格式,最大 5MB')
  159. ->preserveFilenames()
  160. ->multiple(false)
  161. ->downloadable(false)
  162. ->afterStateHydrated(function ($component, $state) {
  163. // 确保状态是字符串或 null,而不是数组
  164. if (is_array($state)) {
  165. $component->state(!empty($state) ? $state[0] : null);
  166. }
  167. }),
  168. TextInput::make('official_title')
  169. ->label('官方书名')
  170. ->maxLength(512)
  171. ->helperText('自动生成,可手动覆盖'),
  172. TextInput::make('display_title')
  173. ->label('展示名称')
  174. ->maxLength(512)
  175. ->helperText('站内显示名称'),
  176. Textarea::make('aliases')
  177. ->label('别名')
  178. ->helperText('JSON 格式,如:["别名1", "别名2"]')
  179. ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
  180. ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
  181. ->columnSpanFull(),
  182. Select::make('status')
  183. ->label('状态')
  184. ->options([
  185. 'draft' => '草稿',
  186. 'published' => '已发布',
  187. 'archived' => '已归档',
  188. ])
  189. ->default('draft')
  190. ->required(),
  191. Textarea::make('meta')
  192. ->label('扩展信息')
  193. ->placeholder('JSON 格式')
  194. ->formatStateUsing(fn ($state) => is_array($state) ? json_encode($state, JSON_UNESCAPED_UNICODE) : $state)
  195. ->dehydrateStateUsing(fn ($state) => is_string($state) ? json_decode($state, true) : $state)
  196. ->columnSpanFull(),
  197. ]);
  198. }
  199. public static function table(Tables\Table $table): Tables\Table
  200. {
  201. return $table
  202. ->columns([
  203. ImageColumn::make('cover_path')
  204. ->label('封面')
  205. ->square()
  206. ->defaultImageUrl(url('/images/no-image.png'))
  207. ->disk('public')
  208. ->size(60),
  209. TextColumn::make('series.name')
  210. ->label('系列')
  211. ->searchable()
  212. ->sortable(),
  213. BadgeColumn::make('stage')
  214. ->label('学段')
  215. ->formatStateUsing(function ($state): string {
  216. // 确保返回字符串,即使输入是数组
  217. $state = is_array($state) ? ($state[0] ?? '') : $state;
  218. return match ((string) $state) {
  219. 'primary' => '小学',
  220. 'junior' => '初中',
  221. 'senior' => '高中',
  222. default => (string) $state,
  223. };
  224. })
  225. ->color('success'),
  226. TextColumn::make('grade')
  227. ->label('年级')
  228. ->sortable(),
  229. TextColumn::make('semester')
  230. ->label('学期')
  231. ->formatStateUsing(function ($state): string {
  232. // 确保返回字符串,即使输入是数组
  233. $state = is_array($state) ? ($state[0] ?? null) : $state;
  234. return match ((int) $state) {
  235. 1 => '上册',
  236. 2 => '下册',
  237. default => '',
  238. };
  239. }),
  240. TextColumn::make('official_title')
  241. ->label('官方书名')
  242. ->searchable()
  243. ->wrap(),
  244. BadgeColumn::make('status')
  245. ->label('状态')
  246. ->formatStateUsing(function ($state): string {
  247. // 确保返回字符串,即使输入是数组
  248. $state = is_array($state) ? ($state[0] ?? '') : $state;
  249. return match ((string) $state) {
  250. 'draft' => '草稿',
  251. 'published' => '已发布',
  252. 'archived' => '已归档',
  253. default => (string) $state,
  254. };
  255. })
  256. ->color(function ($state): string {
  257. // 确保返回字符串,即使输入是数组
  258. $state = is_array($state) ? ($state[0] ?? '') : $state;
  259. return match ((string) $state) {
  260. 'draft' => 'warning',
  261. 'published' => 'success',
  262. 'archived' => 'gray',
  263. default => 'gray',
  264. };
  265. }),
  266. TextColumn::make('approval_year')
  267. ->label('审定年份')
  268. ->sortable(),
  269. TextColumn::make('created_at')
  270. ->label('创建时间')
  271. ->dateTime('Y-m-d H:i')
  272. ->sortable()
  273. ->toggleable(),
  274. ])
  275. ->filters([
  276. Tables\Filters\SelectFilter::make('stage')
  277. ->label('学段')
  278. ->options([
  279. 'primary' => '小学',
  280. 'junior' => '初中',
  281. 'senior' => '高中',
  282. ])
  283. ->query(function ($query, $data) {
  284. if ($data['value']) {
  285. // API 过滤
  286. return $query;
  287. }
  288. }),
  289. Tables\Filters\SelectFilter::make('status')
  290. ->label('状态')
  291. ->options([
  292. 'draft' => '草稿',
  293. 'published' => '已发布',
  294. 'archived' => '已归档',
  295. ])
  296. ->query(function ($query, $data) {
  297. if ($data['value']) {
  298. // API 过滤
  299. return $query;
  300. }
  301. }),
  302. ])
  303. ->actions([
  304. EditAction::make()
  305. ->label('编辑'),
  306. DeleteAction::make()
  307. ->label('删除'),
  308. Action::make('view_catalog')
  309. ->label('查看目录')
  310. ->icon('heroicon-o-list-bullet')
  311. ->url(fn(Model $record): string =>
  312. route('filament.admin.resources.textbook-catalogs.index', ['tableFilters[textbook_id][value]' => $record->id])
  313. ),
  314. ])
  315. ->bulkActions([
  316. \Filament\Actions\BulkActionGroup::make([
  317. \Filament\Actions\DeleteBulkAction::make()
  318. ->label('批量删除'),
  319. ]),
  320. ])
  321. ->defaultSort('id', 'desc')
  322. ->paginated([10, 25, 50, 100])
  323. ->poll('30s');
  324. }
  325. public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder
  326. {
  327. // 返回空查询,实际数据通过 API 获取
  328. return parent::getEloquentQuery()->whereRaw('1=0');
  329. }
  330. public static function resolveRecordRouteBinding(int | string $key, ?\Closure $modifyQuery = null): ?\Illuminate\Database\Eloquent\Model
  331. {
  332. $record = static::getApiService()->getTextbook((int) $key);
  333. if (!$record) {
  334. return null;
  335. }
  336. $model = new \App\Models\Textbook($record);
  337. $model->exists = true;
  338. $model->id = $record['id'];
  339. return $model;
  340. }
  341. public static function getPages(): array
  342. {
  343. return [
  344. 'index' => Pages\ManageTextbooks::route('/'),
  345. 'create' => Pages\CreateTextbook::route('/create'),
  346. 'edit' => Pages\EditTextbook::route('/{record}/edit'),
  347. ];
  348. }
  349. public static function canViewAny(): bool
  350. {
  351. // 临时允许所有用户查看,等待权限系统完善
  352. return true;
  353. }
  354. public static function getHeaderActions(): array
  355. {
  356. return [
  357. \Filament\Actions\Action::make('import_excel')
  358. ->label('Excel导入')
  359. ->icon('heroicon-o-document-arrow-up')
  360. ->color('success')
  361. ->url(fn(): string =>
  362. route('filament.admin.pages.textbook-excel-import-page')
  363. ),
  364. ];
  365. }
  366. public static function canCreate(): bool
  367. {
  368. // 临时允许所有用户创建,等待权限系统完善
  369. return true;
  370. }
  371. public static function canEdit(Model $record): bool
  372. {
  373. // 临时允许所有用户编辑,等待权限系统完善
  374. return true;
  375. }
  376. public static function canDelete(Model $record): bool
  377. {
  378. // 临时允许所有用户删除,等待权限系统完善
  379. return true;
  380. }
  381. public static function canDeleteAny(): bool
  382. {
  383. // 临时允许所有用户批量删除,等待权限系统完善
  384. return true;
  385. }
  386. }