user()?->can('document.view') ?? false; } public static function getEloquentQuery(): Builder { $query = parent::getEloquentQuery(); // 应用 accessibleBy 作用域,确保用户只能看到有权限的文档 $user = auth()->user(); if ($user) { $query->accessibleBy($user); } return $query; } public static function form(Form $form): Form { return $form ->schema([ Forms\Components\TextInput::make('title') ->label('文档标题') ->required() ->maxLength(255) ->placeholder('请输入文档标题') ->columnSpanFull(), Forms\Components\Textarea::make('description') ->label('文档描述') ->rows(3) ->maxLength(65535) ->placeholder('请输入文档描述(可选)') ->columnSpanFull(), Forms\Components\FileUpload::make('file') ->label('文档文件') ->required() ->acceptedFileTypes(['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']) ->maxSize(51200) // 50MB ->disk('local') ->directory('documents/' . date('Y/m/d')) ->visibility('private') ->downloadable() ->preserveFilenames() // 保留原始文件名 ->helperText('仅支持 .doc 和 .docx 格式,最大 50MB') ->columnSpanFull(), Forms\Components\Select::make('type') ->label('文档类型') ->required() ->options([ 'global' => '全局知识库', 'dedicated' => '专用知识库', ]) ->default('global') ->reactive() ->afterStateUpdated(fn ($state, callable $set) => $state === 'global' ? $set('group_id', null) : null ) ->helperText('全局知识库所有用户可见,专用知识库仅指定分组可见'), Forms\Components\Select::make('group_id') ->label('所属分组') ->relationship('group', 'name') ->searchable() ->preload() ->required(fn (Forms\Get $get): bool => $get('type') === 'dedicated') ->visible(fn (Forms\Get $get): bool => $get('type') === 'dedicated') ->helperText('专用知识库必须选择所属分组'), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('title') ->label('文档标题') ->searchable() ->sortable() ->limit(50) ->tooltip(function (Tables\Columns\TextColumn $column): ?string { $state = $column->getState(); if (strlen($state) > 50) { return $state; } return null; }), Tables\Columns\TextColumn::make('type') ->label('文档类型') ->badge() ->color(fn (string $state): string => match ($state) { 'global' => 'success', 'dedicated' => 'warning', }) ->formatStateUsing(fn (string $state): string => match ($state) { 'global' => '全局知识库', 'dedicated' => '专用知识库', default => $state, }) ->sortable(), Tables\Columns\TextColumn::make('group.name') ->label('所属分组') ->searchable() ->sortable() ->placeholder('—') ->toggleable(), Tables\Columns\TextColumn::make('uploader.name') ->label('上传者') ->searchable() ->sortable() ->toggleable(), Tables\Columns\TextColumn::make('file_size') ->label('文件大小') ->formatStateUsing(fn ($state): string => self::formatFileSize($state)) ->sortable() ->toggleable(), Tables\Columns\TextColumn::make('conversion_status') ->label('转换状态') ->badge() ->color(fn (?string $state): string => match ($state) { 'completed' => 'success', 'processing' => 'info', 'pending' => 'warning', 'failed' => 'danger', default => 'gray', }) ->formatStateUsing(fn (?string $state): string => match ($state) { 'completed' => '已完成', 'processing' => '转换中', 'pending' => '等待转换', 'failed' => '转换失败', default => '未知', }) ->sortable() ->toggleable(), Tables\Columns\TextColumn::make('created_at') ->label('上传时间') ->dateTime('Y年m月d日 H:i') ->sortable() ->toggleable(), Tables\Columns\TextColumn::make('updated_at') ->label('更新时间') ->dateTime('Y年m月d日 H:i') ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ Tables\Filters\SelectFilter::make('type') ->label('文档类型') ->options([ 'global' => '全局知识库', 'dedicated' => '专用知识库', ]) ->placeholder('全部类型'), Tables\Filters\SelectFilter::make('group_id') ->label('所属分组') ->relationship('group', 'name') ->searchable() ->preload() ->placeholder('全部分组'), Tables\Filters\SelectFilter::make('uploaded_by') ->label('上传者') ->relationship('uploader', 'name') ->searchable() ->preload() ->placeholder('全部上传者'), Tables\Filters\SelectFilter::make('conversion_status') ->label('转换状态') ->options([ 'pending' => '等待转换', 'processing' => '转换中', 'completed' => '已完成', 'failed' => '转换失败', ]) ->placeholder('全部状态'), ]) ->actions([ Tables\Actions\Action::make('retry_conversion') ->label('重试转换') ->icon('heroicon-o-arrow-path') ->color('warning') ->visible(fn (Document $record): bool => $record->conversion_status === 'failed') ->requiresConfirmation() ->modalHeading('重试文档转换') ->modalDescription(fn (Document $record): string => '确定要重新转换文档 "' . $record->title . '" 吗?' . ($record->conversion_error ? "\n\n上次失败原因:" . $record->conversion_error : '') ) ->modalSubmitActionLabel('确认重试') ->action(function (Document $record) { try { // 重置转换状态 $record->conversion_status = 'pending'; $record->conversion_error = null; $record->save(); // 重新派发转换任务 \App\Jobs\ConvertDocumentToMarkdown::dispatch($record); \Filament\Notifications\Notification::make() ->success() ->title('重试成功') ->body('文档转换任务已重新加入队列,请稍后查看转换结果。') ->send(); } catch (\Exception $e) { \Filament\Notifications\Notification::make() ->danger() ->title('重试失败') ->body('无法重新派发转换任务:' . $e->getMessage()) ->send(); } }), Tables\Actions\Action::make('view_error') ->label('查看错误') ->icon('heroicon-o-exclamation-triangle') ->color('danger') ->visible(fn (Document $record): bool => $record->conversion_status === 'failed' && !empty($record->conversion_error) ) ->modalHeading('转换错误详情') ->modalContent(fn (Document $record): \Illuminate\Contracts\View\View => view('filament.modals.conversion-error', [ 'document' => $record, 'error' => $record->conversion_error, ]) ) ->modalSubmitAction(false) ->modalCancelActionLabel('关闭'), Tables\Actions\Action::make('preview') ->label('预览 Markdown') ->icon('heroicon-o-eye') ->color('info') ->visible(fn (Document $record): bool => $record->conversion_status === 'completed') ->url(fn (Document $record): string => route('documents.preview', $record)) ->openUrlInNewTab() ->tooltip(fn (Document $record): ?string => $record->conversion_status !== 'completed' ? '文档尚未完成转换' : null ), Tables\Actions\Action::make('download') ->label('下载') ->icon('heroicon-o-arrow-down-tray') ->color('success') ->action(function (Document $record) { $documentService = app(\App\Services\DocumentService::class); $user = auth()->user(); try { // 记录下载日志 $documentService->logDownload($record, $user); // 返回文件下载响应 return $documentService->downloadDocument($record, $user); } catch (\Exception $e) { \Filament\Notifications\Notification::make() ->danger() ->title('下载失败') ->body($e->getMessage()) ->send(); return null; } }), Tables\Actions\ViewAction::make() ->label('查看'), Tables\Actions\EditAction::make() ->label('编辑'), Tables\Actions\DeleteAction::make() ->label('删除'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make() ->label('批量删除'), ]), ]) ->defaultSort('created_at', 'desc'); } /** * 格式化文件大小 */ public static function formatFileSize(?int $bytes): string { if ($bytes === null) { return '—'; } $units = ['B', 'KB', 'MB', 'GB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= (1 << (10 * $pow)); return round($bytes, 2) . ' ' . $units[$pow]; } public static function getRelations(): array { return [ // ]; } public static function getPages(): array { return [ 'index' => Pages\ListDocuments::route('/'), 'create' => Pages\CreateDocument::route('/create'), 'view' => Pages\ViewDocument::route('/{record}'), 'edit' => Pages\EditDocument::route('/{record}/edit'), ]; } }