feat: 初始化知识库系统项目

- 实现基于 Laravel 11 和 Filament 3.X 的文档管理系统
- 添加用户认证和分组管理功能
- 实现文档上传、分类和权限控制
- 集成 Word 文档自动转换为 Markdown
- 集成 Meilisearch 全文搜索引擎
- 实现文档在线预览功能
- 添加安全日志和审计功能
- 完整的简体中文界面
- 包含完整的项目文档和部署指南

技术栈:
- Laravel 11.x
- Filament 3.X
- Meilisearch 1.5+
- Pandoc 文档转换
- Redis 队列系统
- Pest PHP 测试框架
This commit is contained in:
Knowledge Base System
2025-12-05 14:44:44 +08:00
commit acf549c43c
165 changed files with 32838 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
<?php
namespace App\Filament\Pages;
use App\Models\Document;
use App\Models\Group;
use App\Services\DocumentSearchService;
use App\Services\DocumentService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
class SearchPage extends Page implements HasForms, HasTable
{
use InteractsWithForms;
use InteractsWithTable;
protected static ?string $navigationIcon = 'heroicon-o-magnifying-glass';
protected static string $view = 'filament.pages.search-page';
protected static ?string $navigationLabel = '搜索文档';
protected static ?string $title = '搜索文档';
protected static ?int $navigationSort = 2;
// 表单数据
public ?string $searchQuery = null;
public ?string $documentType = null;
public ?int $groupId = null;
// 搜索结果
public $searchResults = null;
public bool $hasSearched = false;
/**
* 挂载页面时的初始化
*/
public function mount(): void
{
$this->form->fill([
'searchQuery' => '',
'documentType' => null,
'groupId' => null,
]);
}
/**
* 定义搜索表单
*/
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('searchQuery')
->label('搜索关键词')
->placeholder('请输入搜索关键词...')
->required()
->maxLength(255),
Select::make('documentType')
->label('文档类型')
->placeholder('全部类型')
->options([
'global' => '全局知识库',
'dedicated' => '专用知识库',
])
->native(false),
Select::make('groupId')
->label('所属分组')
->placeholder('全部分组')
->options(Group::pluck('name', 'id'))
->searchable()
->native(false),
])
->columns(3);
}
/**
* 定义搜索结果表格
*/
public function table(Table $table): Table
{
return $table
->query($this->getTableQuery())
->columns([
TextColumn::make('title')
->label('文档标题')
->searchable()
->sortable()
->limit(50),
TextColumn::make('markdown_preview')
->label('内容片段')
->limit(100)
->wrap()
->default('暂无内容预览'),
TextColumn::make('type')
->label('文档类型')
->badge()
->formatStateUsing(fn (string $state): string => match ($state) {
'global' => '全局知识库',
'dedicated' => '专用知识库',
default => $state,
})
->color(fn (string $state): string => match ($state) {
'global' => 'success',
'dedicated' => 'info',
default => 'gray',
}),
TextColumn::make('group.name')
->label('所属分组')
->default('无')
->sortable(),
TextColumn::make('created_at')
->label('上传时间')
->dateTime('Y-m-d H:i')
->sortable(),
])
->actions([
Action::make('preview')
->label('预览')
->icon('heroicon-o-eye')
->color('info')
->modalHeading(fn (Document $record) => $record->title)
->modalContent(fn (Document $record) => view('filament.pages.document-preview-modal', [
'document' => $record,
]))
->modalWidth('7xl')
->modalSubmitAction(false)
->modalCancelActionLabel('关闭')
->visible(fn (Document $record) => $record->conversion_status === 'completed'),
Action::make('download')
->label('下载')
->icon('heroicon-o-arrow-down-tray')
->action(function (Document $record) {
try {
$documentService = app(DocumentService::class);
$user = Auth::user();
// 记录下载日志
$documentService->logDownload($record, $user);
// 返回文件下载响应
return $documentService->downloadDocument($record, $user);
} catch (\Exception $e) {
Notification::make()
->title('下载失败')
->body($e->getMessage())
->danger()
->send();
}
}),
])
->paginated([10, 25, 50, 100])
->defaultPaginationPageOption(25)
->emptyStateHeading('暂无搜索结果')
->emptyStateDescription('请输入搜索关键词并点击搜索按钮')
->emptyStateIcon('heroicon-o-magnifying-glass');
}
/**
* 获取表格查询构建器
*/
protected function getTableQuery(): Builder
{
if (!$this->hasSearched || empty($this->searchQuery)) {
// 如果还没有搜索或搜索关键词为空,返回空查询
return Document::query()->whereRaw('1 = 0');
}
// 使用 DocumentSearchService 进行搜索
$searchService = app(DocumentSearchService::class);
$user = Auth::user();
$filters = [];
if ($this->documentType) {
$filters['type'] = $this->documentType;
}
if ($this->groupId) {
$filters['group_id'] = $this->groupId;
}
// 执行搜索
$results = $searchService->search($this->searchQuery, $user, $filters);
// 获取搜索结果的 ID 列表
$documentIds = $results->pluck('id')->toArray();
// 返回包含这些 ID 的查询构建器
if (empty($documentIds)) {
return Document::query()->whereRaw('1 = 0');
}
return Document::query()
->whereIn('id', $documentIds)
->with(['group', 'uploader']);
}
/**
* 执行搜索
*/
public function search(): void
{
// 验证表单
$data = $this->form->getState();
// 检查搜索关键词是否为空
if (empty($data['searchQuery'])) {
Notification::make()
->title('请输入搜索关键词')
->warning()
->send();
return;
}
// 更新搜索参数
$this->searchQuery = $data['searchQuery'];
$this->documentType = $data['documentType'];
$this->groupId = $data['groupId'];
$this->hasSearched = true;
// 重置表格分页
$this->resetTable();
Notification::make()
->title('搜索完成')
->success()
->send();
}
/**
* 清空搜索
*/
public function clearSearch(): void
{
$this->form->fill([
'searchQuery' => '',
'documentType' => null,
'groupId' => null,
]);
$this->searchQuery = null;
$this->documentType = null;
$this->groupId = null;
$this->hasSearched = false;
$this->resetTable();
Notification::make()
->title('已清空搜索')
->success()
->send();
}
/**
* 获取页面头部操作
*/
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,301 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\DocumentResource\Pages;
use App\Filament\Resources\DocumentResource\RelationManagers;
use App\Models\Document;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class DocumentResource extends Resource
{
protected static ?string $model = Document::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static ?string $navigationLabel = '文档管理';
protected static ?string $modelLabel = '文档';
protected static ?string $pluralModelLabel = '文档';
protected static ?int $navigationSort = 1;
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('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'),
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Filament\Resources\DocumentResource\Pages;
use App\Filament\Resources\DocumentResource;
use App\Services\DocumentService;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class CreateDocument extends CreateRecord
{
protected static string $resource = DocumentResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
// 设置上传者为当前用户
$data['uploaded_by'] = Auth::id();
// 如果是全局文档,确保 group_id 为 null
if ($data['type'] === 'global') {
$data['group_id'] = null;
}
// 处理文件上传
if (isset($data['file'])) {
$filePath = $data['file'];
// 获取原始文件名(由于使用了 preserveFilenames()basename 就是原始文件名)
$originalFileName = basename($filePath);
// 保存文件信息
$data['file_path'] = $filePath;
$data['file_name'] = $originalFileName; // 保存原始文件名
$data['file_size'] = Storage::disk('local')->size($filePath);
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
// 移除临时的 file 字段
unset($data['file']);
}
return $data;
}
protected function afterCreate(): void
{
// 文档创建后,触发转换任务
$conversionService = app(\App\Services\DocumentConversionService::class);
$conversionService->queueConversion($this->record);
}
protected function getCreatedNotification(): ?Notification
{
return Notification::make()
->success()
->title('文档上传成功')
->body('文档已成功上传到知识库。');
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Filament\Resources\DocumentResource\Pages;
use App\Filament\Resources\DocumentResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Storage;
class EditDocument extends EditRecord
{
protected static string $resource = DocumentResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make()
->label('查看'),
Actions\DeleteAction::make()
->label('删除'),
];
}
protected function mutateFormDataBeforeFill(array $data): array
{
// 将文件路径设置到 file 字段以便显示
if (isset($data['file_path'])) {
$data['file'] = $data['file_path'];
}
return $data;
}
protected function mutateFormDataBeforeSave(array $data): array
{
// 如果是全局文档,确保 group_id 为 null
if ($data['type'] === 'global') {
$data['group_id'] = null;
}
// 处理文件更新
if (isset($data['file']) && $data['file'] !== $this->record->file_path) {
$filePath = $data['file'];
// 删除旧的 Word 文件
if ($this->record->file_path && Storage::disk('local')->exists($this->record->file_path)) {
Storage::disk('local')->delete($this->record->file_path);
}
// 删除旧的 Markdown 文件
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
Storage::disk('markdown')->delete($this->record->markdown_path);
}
// 获取原始文件名(由于使用了 preserveFilenames()basename 就是原始文件名)
$originalFileName = basename($filePath);
// 更新文件信息
$data['file_path'] = $filePath;
$data['file_name'] = $originalFileName; // 保存原始文件名
$data['file_size'] = Storage::disk('local')->size($filePath);
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
// 重置转换状态,准备重新转换
$data['conversion_status'] = 'pending';
$data['markdown_path'] = null;
$data['markdown_preview'] = null;
$data['conversion_error'] = null;
}
// 移除临时的 file 字段
unset($data['file']);
return $data;
}
protected function afterSave(): void
{
// 如果文档的转换状态是 pending说明文件已更新需要触发重新转换
if ($this->record->conversion_status === 'pending') {
$conversionService = app(\App\Services\DocumentConversionService::class);
$conversionService->queueConversion($this->record);
}
}
protected function getSavedNotification(): ?Notification
{
return Notification::make()
->success()
->title('文档更新成功')
->body('文档信息已成功更新。');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\DocumentResource\Pages;
use App\Filament\Resources\DocumentResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListDocuments extends ListRecords
{
protected static string $resource = DocumentResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('上传文档'),
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Filament\Resources\DocumentResource\Pages;
use App\Filament\Resources\DocumentResource;
use App\Services\DocumentPreviewService;
use App\Services\DocumentService;
use Filament\Actions;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Infolists\Infolist;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
class ViewDocument extends ViewRecord
{
protected static string $resource = DocumentResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('preview')
->label('预览 Markdown')
->icon('heroicon-o-eye')
->color('info')
->visible(fn (): bool => $this->record->conversion_status === 'completed')
->url(fn (): string => route('documents.preview', $this->record))
->openUrlInNewTab()
->tooltip(fn (): ?string =>
$this->record->conversion_status !== 'completed'
? '文档尚未完成转换'
: null
),
Actions\Action::make('download')
->label('下载文档')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->action(function () {
$documentService = app(DocumentService::class);
$user = auth()->user();
try {
// 记录下载日志
$documentService->logDownload($this->record, $user);
// 返回文件下载响应
return $documentService->downloadDocument($this->record, $user);
} catch (\Exception $e) {
Notification::make()
->danger()
->title('下载失败')
->body($e->getMessage())
->send();
return null;
}
}),
Actions\EditAction::make()
->label('编辑'),
Actions\DeleteAction::make()
->label('删除'),
];
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make('文档信息')
->schema([
TextEntry::make('title')
->label('文档标题')
->size(TextEntry\TextEntrySize::Large)
->weight('bold'),
TextEntry::make('description')
->label('文档描述')
->placeholder('无描述')
->columnSpanFull(),
TextEntry::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,
}),
TextEntry::make('group.name')
->label('所属分组')
->placeholder('—')
->visible(fn ($record) => $record->type === 'dedicated'),
TextEntry::make('uploader.name')
->label('上传者'),
TextEntry::make('file_name')
->label('文件名'),
TextEntry::make('file_size')
->label('文件大小')
->formatStateUsing(fn ($state): string => DocumentResource::formatFileSize($state)),
TextEntry::make('created_at')
->label('上传时间')
->dateTime('Y年m月d日 H:i:s'),
TextEntry::make('updated_at')
->label('更新时间')
->dateTime('Y年m月d日 H:i:s'),
])
->columns(2),
Section::make('文档预览')
->schema([
ViewEntry::make('preview')
->label('')
->view('filament.resources.document.preview')
->viewData([
'document' => $this->record,
]),
])
->collapsible()
->collapsed(false),
]);
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\GroupResource\Pages;
use App\Filament\Resources\GroupResource\RelationManagers;
use App\Models\Group;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class GroupResource extends Resource
{
protected static ?string $model = Group::class;
protected static ?string $navigationIcon = 'heroicon-o-user-group';
protected static ?string $navigationLabel = '分组管理';
protected static ?string $modelLabel = '分组';
protected static ?string $pluralModelLabel = '分组';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('分组名称')
->required()
->maxLength(255)
->placeholder('请输入分组名称'),
Forms\Components\Textarea::make('description')
->label('分组描述')
->rows(3)
->maxLength(65535)
->placeholder('请输入分组描述(可选)')
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID')
->sortable(),
Tables\Columns\TextColumn::make('name')
->label('分组名称')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->label('分组描述')
->limit(50)
->searchable()
->toggleable(),
Tables\Columns\TextColumn::make('users_count')
->label('成员数量')
->counts('users')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
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 getRelations(): array
{
return [
RelationManagers\UsersRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListGroups::route('/'),
'create' => Pages\CreateGroup::route('/create'),
'edit' => Pages\EditGroup::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\GroupResource\Pages;
use App\Filament\Resources\GroupResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateGroup extends CreateRecord
{
protected static string $resource = GroupResource::class;
protected static ?string $title = '创建分组';
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function getCreatedNotificationTitle(): ?string
{
return '分组创建成功';
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Filament\Resources\GroupResource\Pages;
use App\Filament\Resources\GroupResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditGroup extends EditRecord
{
protected static string $resource = GroupResource::class;
protected static ?string $title = '编辑分组';
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->label('删除')
->modalHeading('删除分组')
->modalDescription('确定要删除此分组吗?此操作无法撤销。')
->modalSubmitActionLabel('确认删除')
->modalCancelActionLabel('取消'),
];
}
protected function getSavedNotificationTitle(): ?string
{
return '分组更新成功';
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\GroupResource\Pages;
use App\Filament\Resources\GroupResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListGroups extends ListRecords
{
protected static string $resource = GroupResource::class;
protected static ?string $title = '分组列表';
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('创建分组'),
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Filament\Resources\GroupResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class UsersRelationManager extends RelationManager
{
protected static string $relationship = 'users';
protected static ?string $title = '分组成员';
protected static ?string $modelLabel = '用户';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('用户名称')
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
->label('邮箱')
->email()
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID')
->sortable(),
Tables\Columns\TextColumn::make('name')
->label('用户名称')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->label('邮箱')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('加入时间')
->dateTime('Y-m-d H:i:s')
->sortable(),
])
->filters([
//
])
->headerActions([
Tables\Actions\AttachAction::make()
->label('添加成员')
->preloadRecordSelect()
->modalHeading('添加分组成员')
->modalSubmitActionLabel('添加')
->modalCancelActionLabel('取消'),
])
->actions([
Tables\Actions\DetachAction::make()
->label('移除')
->modalHeading('移除分组成员')
->modalDescription('确定要将此用户从分组中移除吗?')
->modalSubmitActionLabel('确认移除')
->modalCancelActionLabel('取消'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make()
->label('批量移除')
->modalHeading('批量移除分组成员')
->modalDescription('确定要将选中的用户从分组中移除吗?')
->modalSubmitActionLabel('确认移除')
->modalCancelActionLabel('取消'),
]),
]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationLabel = '用户管理';
protected static ?string $modelLabel = '用户';
protected static ?string $pluralModelLabel = '用户';
protected static ?int $navigationSort = 3;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('用户名称')
->required()
->maxLength(255)
->placeholder('请输入用户名称'),
Forms\Components\TextInput::make('email')
->label('邮箱')
->email()
->required()
->maxLength(255)
->placeholder('请输入邮箱地址'),
Forms\Components\TextInput::make('password')
->label('密码')
->password()
->required(fn (string $context): bool => $context === 'create')
->dehydrated(fn ($state) => filled($state))
->minLength(8)
->placeholder('请输入密码至少8位')
->helperText('编辑时留空表示不修改密码'),
Forms\Components\Select::make('groups')
->label('所属分组')
->multiple()
->relationship('groups', 'name')
->preload()
->placeholder('请选择用户所属的分组')
->helperText('用户可以属于多个分组'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID')
->sortable(),
Tables\Columns\TextColumn::make('name')
->label('用户名称')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->label('邮箱')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('groups.name')
->label('所属分组')
->badge()
->searchable()
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i:s')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
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 getRelations(): array
{
return [
RelationManagers\GroupsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function getCreatedNotificationTitle(): ?string
{
return '用户创建成功';
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->label('删除')
->modalHeading('删除用户')
->modalDescription('确定要删除此用户吗?此操作无法撤销。')
->modalSubmitActionLabel('确认删除')
->modalCancelActionLabel('取消'),
];
}
protected function getSavedNotificationTitle(): ?string
{
return '用户更新成功';
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('创建用户'),
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Resources\UserResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class GroupsRelationManager extends RelationManager
{
protected static string $relationship = 'groups';
protected static ?string $title = '用户分组';
protected static ?string $modelLabel = '分组';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('分组名称')
->required()
->maxLength(255),
Forms\Components\Textarea::make('description')
->label('分组描述')
->rows(3)
->maxLength(65535),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID')
->sortable(),
Tables\Columns\TextColumn::make('name')
->label('分组名称')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->label('分组描述')
->limit(50)
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->label('加入时间')
->dateTime('Y-m-d H:i:s')
->sortable(),
])
->filters([
//
])
->headerActions([
Tables\Actions\AttachAction::make()
->label('添加分组')
->preloadRecordSelect()
->modalHeading('添加用户到分组')
->modalSubmitActionLabel('添加')
->modalCancelActionLabel('取消'),
])
->actions([
Tables\Actions\DetachAction::make()
->label('移除')
->modalHeading('移除用户分组')
->modalDescription('确定要将此用户从该分组中移除吗?')
->modalSubmitActionLabel('确认移除')
->modalCancelActionLabel('取消'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make()
->label('批量移除')
->modalHeading('批量移除用户分组')
->modalDescription('确定要将此用户从选中的分组中移除吗?')
->modalSubmitActionLabel('确认移除')
->modalCancelActionLabel('取消'),
]),
]);
}
}