TextbookResource.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  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 getRecord(?string $key): ?Model
  331. {
  332. $record = static::getApiService()->getTextbook((int) $key);
  333. return $record ? new ApiTextbook($record) : null;
  334. }
  335. public static function getRecords(): array
  336. {
  337. $page = request()->get('page', 1);
  338. $perPage = request()->get('perPage', 10);
  339. $params = [
  340. 'page' => $page,
  341. 'per_page' => $perPage,
  342. ];
  343. $result = static::getApiService()->getTextbooks($params);
  344. $records = [];
  345. foreach ($result['data'] ?? [] as $item) {
  346. $records[] = new ApiTextbook($item);
  347. }
  348. return $records;
  349. }
  350. protected static function newModel(array $data): Model
  351. {
  352. // 处理封面上传
  353. if (isset($data['cover_path']) && $data['cover_path'] instanceof \Illuminate\Http\UploadedFile) {
  354. $coverService = app(TextbookCoverStorageService::class);
  355. $coverUrl = $coverService->uploadCover($data['cover_path']);
  356. if ($coverUrl) {
  357. $data['cover_path'] = $coverUrl;
  358. } else {
  359. // 上传失败则不保存封面路径
  360. unset($data['cover_path']);
  361. }
  362. }
  363. // ⚠️ 重要:通过 API 创建教材,数据同步到题库服务的 PostgreSQL 数据库
  364. $record = static::getApiService()->createTextbook($data);
  365. return new ApiTextbook($record['data']);
  366. }
  367. protected static function updateRecord(Model $record, array $data): Model
  368. {
  369. // 处理封面上传
  370. if (isset($data['cover_path']) && $data['cover_path'] instanceof \Illuminate\Http\UploadedFile) {
  371. $coverService = app(TextbookCoverStorageService::class);
  372. $coverUrl = $coverService->uploadCover($data['cover_path'], (string) $record->id);
  373. if ($coverUrl) {
  374. $data['cover_path'] = $coverUrl;
  375. } else {
  376. // 上传失败则不更新封面路径
  377. unset($data['cover_path']);
  378. }
  379. }
  380. $result = static::getApiService()->updateTextbook($record->id, $data);
  381. return new ApiTextbook($result['data']);
  382. }
  383. protected static function deleteRecord(Model $record): bool
  384. {
  385. // 删除记录时,同时通过 API 删除题库服务中的数据
  386. return static::getApiService()->deleteTextbook($record->id);
  387. }
  388. public static function getPages(): array
  389. {
  390. return [
  391. 'index' => Pages\ManageTextbooks::route('/'),
  392. 'create' => Pages\CreateTextbook::route('/create'),
  393. 'edit' => Pages\EditTextbook::route('/{record}/edit'),
  394. ];
  395. }
  396. public static function canViewAny(): bool
  397. {
  398. // 临时允许所有用户查看,等待权限系统完善
  399. return true;
  400. }
  401. public static function getHeaderActions(): array
  402. {
  403. return [
  404. \Filament\Actions\Action::make('import_excel')
  405. ->label('Excel导入')
  406. ->icon('heroicon-o-document-arrow-up')
  407. ->color('success')
  408. ->url(fn(): string =>
  409. route('filament.admin.pages.textbook-excel-import-page')
  410. ),
  411. ];
  412. }
  413. public static function canCreate(): bool
  414. {
  415. // 临时允许所有用户创建,等待权限系统完善
  416. return true;
  417. }
  418. public static function canEdit(Model $record): bool
  419. {
  420. // 临时允许所有用户编辑,等待权限系统完善
  421. return true;
  422. }
  423. public static function canDelete(Model $record): bool
  424. {
  425. // 临时允许所有用户删除,等待权限系统完善
  426. return true;
  427. }
  428. public static function canDeleteAny(): bool
  429. {
  430. // 临时允许所有用户批量删除,等待权限系统完善
  431. return true;
  432. }
  433. }
  434. /**
  435. * API 教材模型
  436. */
  437. class ApiTextbook extends Model
  438. {
  439. protected $table = 'api_textbooks';
  440. protected $fillable = [
  441. 'id', 'series_id', 'series', 'stage', 'grade', 'semester',
  442. 'naming_scheme', 'track', 'module_type', 'volume_no',
  443. 'legacy_code', 'curriculum_standard_year', 'curriculum_revision_year',
  444. 'approval_year', 'edition_label', 'official_title', 'display_title',
  445. 'aliases', 'isbn', 'cover_path', 'status', 'created_at'
  446. ];
  447. protected $casts = [
  448. // 移除所有 array cast,直接使用 JSON 字符串
  449. ];
  450. public function __construct(array $attributes = [])
  451. {
  452. foreach ($attributes as $key => $value) {
  453. $this->setAttribute($key, $value);
  454. }
  455. parent::__construct($attributes);
  456. }
  457. }