diff --git a/app/Filament/Pages/SearchPage.php b/app/Filament/Pages/SearchPage.php index b257820..16c5051 100644 --- a/app/Filament/Pages/SearchPage.php +++ b/app/Filament/Pages/SearchPage.php @@ -3,8 +3,8 @@ namespace App\Filament\Pages; use App\Models\Document; -use App\Models\Group; use App\Models\KnowledgeBase; +use App\Models\Station; use App\Services\DocumentSearchService; use App\Services\DocumentService; use Filament\Forms\Components\Select; @@ -19,7 +19,6 @@ 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; @@ -29,41 +28,25 @@ class SearchPage extends Page implements HasForms, HasTable 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 ?int $knowledgeBaseId = null; - - // 搜索结果 - public $searchResults = null; + public ?array $stationIds = []; + public ?array $knowledgeBaseIds = []; public bool $hasSearched = false; - /** - * 挂载页面时的初始化 - */ public function mount(): void { $this->form->fill([ 'searchQuery' => '', - 'documentType' => null, - 'groupId' => null, - 'knowledgeBaseId' => null, + 'stationIds' => [], + 'knowledgeBaseIds' => [], ]); } - /** - * 定义搜索表单 - */ public function form(Form $form): Form { return $form @@ -74,35 +57,25 @@ class SearchPage extends Page implements HasForms, HasTable ->required() ->maxLength(255), - Select::make('documentType') - ->label('文档类型') - ->placeholder('全部类型') - ->options([ - 'global' => '全局知识库', - 'dedicated' => '专用知识库', - ]) - ->native(false), - - Select::make('groupId') - ->label('所属分组') - ->placeholder('全部分组') - ->options(Group::pluck('name', 'id')) + Select::make('stationIds') + ->label('线站') + ->placeholder('全部线站') + ->options(Station::pluck('name', 'id')) + ->multiple() ->searchable() ->native(false), - Select::make('knowledgeBaseId') + Select::make('knowledgeBaseIds') ->label('知识库') ->placeholder('全部知识库') - ->options(KnowledgeBase::pluck('name', 'id')) + ->options(KnowledgeBase::where('status', 'active')->pluck('name', 'id')) + ->multiple() ->searchable() ->native(false), ]) - ->columns(4); + ->columns(3); } - /** - * 定义搜索结果表格 - */ public function table(Table $table): Table { return $table @@ -114,29 +87,12 @@ class SearchPage extends Page implements HasForms, HasTable ->sortable() ->limit(50), - TextColumn::make('markdown_preview') - ->label('内容片段') - ->limit(100) - ->wrap() - ->default('暂无内容预览'), + TextColumn::make('knowledgeBase.name') + ->label('所属知识库') + ->sortable(), - 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('无') + TextColumn::make('uploader.name') + ->label('上传者') ->sortable(), TextColumn::make('created_at') @@ -157,7 +113,7 @@ class SearchPage extends Page implements HasForms, HasTable ->modalSubmitAction(false) ->modalCancelActionLabel('关闭') ->visible(fn (Document $record) => $record->conversion_status === 'completed'), - + Action::make('download') ->label('下载') ->icon('heroicon-o-arrow-down-tray') @@ -165,11 +121,7 @@ class SearchPage extends Page implements HasForms, HasTable try { $documentService = app(DocumentService::class); $user = Auth::user(); - - // 记录下载日志 $documentService->logDownload($record, $user); - - // 返回文件下载响应 return $documentService->downloadDocument($record, $user); } catch (\Exception $e) { Notification::make() @@ -187,56 +139,39 @@ class SearchPage extends Page implements HasForms, HasTable ->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 (!empty($this->stationIds)) { + $filters['station_ids'] = $this->stationIds; } - if ($this->groupId) { - $filters['group_id'] = $this->groupId; - } - if ($this->knowledgeBaseId) { - $filters['knowledge_base_id'] = $this->knowledgeBaseId; + if (!empty($this->knowledgeBaseIds)) { + $filters['knowledge_base_ids'] = $this->knowledgeBaseIds; } - // 执行搜索 - $results = $searchService->search($this->searchQuery, $user, $filters); - - // 获取搜索结果的 ID 列表 + $accessibleStationIds = Auth::user()->getAccessibleStationIds(); + $results = $searchService->search($this->searchQuery, $accessibleStationIds, $filters); $documentIds = $results->pluck('id')->toArray(); - // 返回包含这些 ID 的查询构建器 if (empty($documentIds)) { return Document::query()->whereRaw('1 = 0'); } return Document::query() ->whereIn('id', $documentIds) - ->with(['group', 'uploader']); + ->with(['knowledgeBase', 'uploader']); } - /** - * 执行搜索 - */ public function search(): void { - // 验证表单 $data = $this->form->getState(); - // 检查搜索关键词是否为空 if (empty($data['searchQuery'])) { Notification::make() ->title('请输入搜索关键词') @@ -245,14 +180,11 @@ class SearchPage extends Page implements HasForms, HasTable return; } - // 更新搜索参数 $this->searchQuery = $data['searchQuery']; - $this->documentType = $data['documentType']; - $this->groupId = $data['groupId']; - $this->knowledgeBaseId = $data['knowledgeBaseId'] ?? null; + $this->stationIds = $data['stationIds'] ?? []; + $this->knowledgeBaseIds = $data['knowledgeBaseIds'] ?? []; $this->hasSearched = true; - // 重置表格分页 $this->resetTable(); Notification::make() @@ -261,22 +193,17 @@ class SearchPage extends Page implements HasForms, HasTable ->send(); } - /** - * 清空搜索 - */ public function clearSearch(): void { $this->form->fill([ 'searchQuery' => '', - 'documentType' => null, - 'groupId' => null, - 'knowledgeBaseId' => null, + 'stationIds' => [], + 'knowledgeBaseIds' => [], ]); $this->searchQuery = null; - $this->documentType = null; - $this->groupId = null; - $this->knowledgeBaseId = null; + $this->stationIds = []; + $this->knowledgeBaseIds = []; $this->hasSearched = false; $this->resetTable(); @@ -287,9 +214,6 @@ class SearchPage extends Page implements HasForms, HasTable ->send(); } - /** - * 获取页面头部操作 - */ protected function getHeaderActions(): array { return []; diff --git a/app/Filament/Resources/DocumentResource.php b/app/Filament/Resources/DocumentResource.php index c0a8c52..d54ca5a 100644 --- a/app/Filament/Resources/DocumentResource.php +++ b/app/Filament/Resources/DocumentResource.php @@ -40,11 +40,11 @@ class DocumentResource extends Resource public static function getEloquentQuery(): Builder { $query = parent::getEloquentQuery(); - - // 应用 accessibleBy 作用域,确保用户只能看到有权限的文档 $user = auth()->user(); - if ($user) { - $query->accessibleBy($user); + + if ($user && $user->hasStationRestriction()) { + $accessibleKbIds = \App\Models\KnowledgeBase::accessibleBy($user)->pluck('id'); + $query->whereIn('knowledge_base_id', $accessibleKbIds); } return $query; @@ -86,29 +86,13 @@ class DocumentResource extends Resource ->helperText('支持 .docx/.pptx/.xlsx/.pdf 格式,最大 50MB') ->columnSpanFull(), - Forms\Components\Select::make('type') - ->label('文档类型') + Forms\Components\Select::make('knowledge_base_id') + ->label('所属知识库') + ->relationship('knowledgeBase', 'name') ->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('专用知识库必须选择所属分组'), + ->helperText('选择文档所属的知识库'), ]); } @@ -129,26 +113,10 @@ class DocumentResource extends Resource 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('所属分组') + Tables\Columns\TextColumn::make('knowledgeBase.name') + ->label('所属知识库') ->searchable() - ->sortable() - ->placeholder('—') - ->toggleable(), + ->sortable(), Tables\Columns\TextColumn::make('uploader.name') ->label('上传者') @@ -195,20 +163,12 @@ class DocumentResource extends Resource ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - Tables\Filters\SelectFilter::make('type') - ->label('文档类型') - ->options([ - 'global' => '全局知识库', - 'dedicated' => '专用知识库', - ]) - ->placeholder('全部类型'), - - Tables\Filters\SelectFilter::make('group_id') - ->label('所属分组') - ->relationship('group', 'name') + Tables\Filters\SelectFilter::make('knowledge_base_id') + ->label('所属知识库') + ->relationship('knowledgeBase', 'name') ->searchable() ->preload() - ->placeholder('全部分组'), + ->placeholder('全部知识库'), Tables\Filters\SelectFilter::make('uploaded_by') ->label('上传者') diff --git a/app/Filament/Resources/DocumentResource/Pages/CreateDocument.php b/app/Filament/Resources/DocumentResource/Pages/CreateDocument.php index 3225506..8f91a7d 100644 --- a/app/Filament/Resources/DocumentResource/Pages/CreateDocument.php +++ b/app/Filament/Resources/DocumentResource/Pages/CreateDocument.php @@ -16,10 +16,6 @@ class CreateDocument extends CreateRecord { $data['uploaded_by'] = Auth::id(); - if ($data['type'] === 'global') { - $data['group_id'] = null; - } - if (isset($data['file'])) { $filePath = $data['file']; diff --git a/app/Filament/Resources/DocumentResource/Pages/EditDocument.php b/app/Filament/Resources/DocumentResource/Pages/EditDocument.php index f61b76f..a00d7aa 100644 --- a/app/Filament/Resources/DocumentResource/Pages/EditDocument.php +++ b/app/Filament/Resources/DocumentResource/Pages/EditDocument.php @@ -38,10 +38,6 @@ class EditDocument extends EditRecord protected function mutateFormDataBeforeSave(array $data): array { - if ($data['type'] === 'global') { - $data['group_id'] = null; - } - $currentFile = $data['file'] ?? null; // 检测文件是否变更:与填充时记录的原始路径比较 @@ -62,7 +58,6 @@ class EditDocument extends EditRecord // 重置转换状态,触发重新转换 $data['conversion_status'] = 'pending'; $data['markdown_path'] = null; - $data['markdown_preview'] = null; $data['conversion_error'] = null; } diff --git a/app/Filament/Resources/DocumentResource/Pages/ViewDocument.php b/app/Filament/Resources/DocumentResource/Pages/ViewDocument.php index e18f151..583a199 100644 --- a/app/Filament/Resources/DocumentResource/Pages/ViewDocument.php +++ b/app/Filament/Resources/DocumentResource/Pages/ViewDocument.php @@ -114,24 +114,9 @@ class ViewDocument extends ViewRecord ->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('knowledgeBase.name') + ->label('所属知识库'), + TextEntry::make('uploader.name') ->label('上传者'), diff --git a/app/Filament/Resources/GroupResource.php b/app/Filament/Resources/GroupResource.php deleted file mode 100644 index a1e4f6a..0000000 --- a/app/Filament/Resources/GroupResource.php +++ /dev/null @@ -1,124 +0,0 @@ -user()?->can('group.view') ?? false; - } - - 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'), - ]; - } -} diff --git a/app/Filament/Resources/GroupResource/Pages/CreateGroup.php b/app/Filament/Resources/GroupResource/Pages/CreateGroup.php deleted file mode 100644 index 3e5d48c..0000000 --- a/app/Filament/Resources/GroupResource/Pages/CreateGroup.php +++ /dev/null @@ -1,24 +0,0 @@ -getResource()::getUrl('index'); - } - - protected function getCreatedNotificationTitle(): ?string - { - return '分组创建成功'; - } -} diff --git a/app/Filament/Resources/GroupResource/Pages/EditGroup.php b/app/Filament/Resources/GroupResource/Pages/EditGroup.php deleted file mode 100644 index fee2a67..0000000 --- a/app/Filament/Resources/GroupResource/Pages/EditGroup.php +++ /dev/null @@ -1,31 +0,0 @@ -label('删除') - ->modalHeading('删除分组') - ->modalDescription('确定要删除此分组吗?此操作无法撤销。') - ->modalSubmitActionLabel('确认删除') - ->modalCancelActionLabel('取消'), - ]; - } - - protected function getSavedNotificationTitle(): ?string - { - return '分组更新成功'; - } -} diff --git a/app/Filament/Resources/GroupResource/Pages/ListGroups.php b/app/Filament/Resources/GroupResource/Pages/ListGroups.php deleted file mode 100644 index 9729ac0..0000000 --- a/app/Filament/Resources/GroupResource/Pages/ListGroups.php +++ /dev/null @@ -1,22 +0,0 @@ -label('创建分组'), - ]; - } -} diff --git a/app/Filament/Resources/GroupResource/RelationManagers/UsersRelationManager.php b/app/Filament/Resources/GroupResource/RelationManagers/UsersRelationManager.php deleted file mode 100644 index c906756..0000000 --- a/app/Filament/Resources/GroupResource/RelationManagers/UsersRelationManager.php +++ /dev/null @@ -1,88 +0,0 @@ -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('取消'), - ]), - ]); - } -} diff --git a/app/Filament/Resources/GuideResource.php b/app/Filament/Resources/GuideResource.php index 1c23a21..378b0ed 100644 --- a/app/Filament/Resources/GuideResource.php +++ b/app/Filament/Resources/GuideResource.php @@ -4,7 +4,6 @@ namespace App\Filament\Resources; use App\Filament\Resources\GuideResource\Pages; use App\Models\Guide; -use App\Models\Terminal; use Filament\Forms; use Filament\Forms\Form; use Filament\Resources\Resource; @@ -33,6 +32,18 @@ class GuideResource extends Resource return auth()->user()?->can('guide.view') ?? false; } + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + $user = auth()->user(); + + if ($user && $user->hasStationRestriction()) { + $query->accessibleBy($user); + } + + return $query; + } + public static function form(Form $form): Form { return $form @@ -80,17 +91,17 @@ class GuideResource extends Resource ]) ->columns(2), - Forms\Components\Section::make('关联终端') + Forms\Components\Section::make('关联线站') ->schema([ - Forms\Components\CheckboxList::make('terminals') - ->label('适用终端') - ->relationship('terminals', 'name') + Forms\Components\CheckboxList::make('stations') + ->label('适用线站') + ->relationship('stations', 'name') ->searchable() ->bulkToggleable() - ->helperText('选择此指引适用的终端,未关联终端的指引不会在终端显示') + ->helperText('选择此指引适用的线站,未关联线站的指引为全局指引') ->columns(3), ]) - ->description('配置此指引在哪些终端上可见'), + ->description('不关联任何线站则为全局指引,对所有终端可见'), ]); } @@ -147,13 +158,13 @@ class GuideResource extends Resource ->counts('pages') ->sortable(), - Tables\Columns\TextColumn::make('terminals_count') - ->label('关联终端') - ->counts('terminals') + Tables\Columns\TextColumn::make('stations_count') + ->label('关联线站') + ->counts('stations') ->sortable() ->badge() - ->color(fn(int $state): string => $state > 0 ? 'success' : 'gray') - ->formatStateUsing(fn(int $state): string => $state > 0 ? "{$state} 个" : '未关联'), + ->color(fn(int $state): string => $state > 0 ? 'info' : 'success') + ->formatStateUsing(fn(int $state): string => $state > 0 ? "{$state} 个" : '全局'), Tables\Columns\TextColumn::make('created_at') ->label('创建时间') diff --git a/app/Filament/Resources/KnowledgeBaseResource.php b/app/Filament/Resources/KnowledgeBaseResource.php new file mode 100644 index 0000000..80c0ab5 --- /dev/null +++ b/app/Filament/Resources/KnowledgeBaseResource.php @@ -0,0 +1,153 @@ +user(); + + if ($user && $user->hasStationRestriction()) { + $query->accessibleBy($user); + } + + return $query; + } + + public static function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\TextInput::make('name') + ->label('知识库名称') + ->required() + ->maxLength(255) + ->placeholder('请输入知识库名称'), + + Forms\Components\Select::make('status') + ->label('状态') + ->options([ + 'active' => '启用', + 'inactive' => '停用', + ]) + ->default('active') + ->required(), + + Forms\Components\Textarea::make('description') + ->label('描述') + ->rows(3) + ->maxLength(65535) + ->placeholder('请输入知识库描述(可选)') + ->columnSpanFull(), + + Forms\Components\Select::make('stations') + ->label('关联线站') + ->relationship('stations', 'name') + ->multiple() + ->searchable() + ->preload() + ->helperText('选择知识库对哪些线站可用') + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label('知识库名称') + ->searchable() + ->sortable(), + + Tables\Columns\BadgeColumn::make('status') + ->label('状态') + ->colors([ + 'success' => 'active', + 'danger' => 'inactive', + ]) + ->formatStateUsing(fn(string $state): string => match ($state) { + 'active' => '启用', + 'inactive' => '停用', + default => $state, + }) + ->sortable(), + + Tables\Columns\TextColumn::make('stations_count') + ->label('关联线站') + ->counts('stations') + ->sortable(), + + Tables\Columns\TextColumn::make('documents_count') + ->label('文档数量') + ->counts('documents') + ->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([ + Tables\Filters\SelectFilter::make('status') + ->label('状态') + ->options([ + 'active' => '启用', + 'inactive' => '停用', + ]), + ]) + ->actions([ + 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 getPages(): array + { + return [ + 'index' => Pages\ListKnowledgeBases::route('/'), + 'create' => Pages\CreateKnowledgeBase::route('/create'), + 'edit' => Pages\EditKnowledgeBase::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/KnowledgeBaseResource/Pages/CreateKnowledgeBase.php b/app/Filament/Resources/KnowledgeBaseResource/Pages/CreateKnowledgeBase.php new file mode 100644 index 0000000..0a9a4e9 --- /dev/null +++ b/app/Filament/Resources/KnowledgeBaseResource/Pages/CreateKnowledgeBase.php @@ -0,0 +1,11 @@ +label('删除'), + ]; + } +} diff --git a/app/Filament/Resources/KnowledgeBaseResource/Pages/ListKnowledgeBases.php b/app/Filament/Resources/KnowledgeBaseResource/Pages/ListKnowledgeBases.php new file mode 100644 index 0000000..afdb0e1 --- /dev/null +++ b/app/Filament/Resources/KnowledgeBaseResource/Pages/ListKnowledgeBases.php @@ -0,0 +1,20 @@ +label('新建知识库'), + ]; + } +} diff --git a/app/Filament/Resources/StationResource.php b/app/Filament/Resources/StationResource.php new file mode 100644 index 0000000..e345ee7 --- /dev/null +++ b/app/Filament/Resources/StationResource.php @@ -0,0 +1,130 @@ +user()?->can('station.view') ?? false; + } + + public static function form(Form $form): Form + { + return $form + ->schema([ + Forms\Components\TextInput::make('name') + ->label('线站名称') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255) + ->placeholder('例如: BL02U1'), + + Forms\Components\Textarea::make('description') + ->label('线站描述') + ->rows(3) + ->maxLength(65535) + ->placeholder('请输入线站描述(可选)') + ->columnSpanFull(), + + Forms\Components\Select::make('users') + ->label('关联用户') + ->relationship('users', 'name') + ->multiple() + ->searchable() + ->preload() + ->helperText('关联到此线站的用户只能看到与本线站相关的资源') + ->columnSpanFull(), + + Forms\Components\Select::make('guides') + ->label('关联指引') + ->relationship('guides', 'name') + ->multiple() + ->searchable() + ->preload() + ->helperText('选择此线站可用的操作指引') + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label('线站名称') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('description') + ->label('描述') + ->limit(50) + ->toggleable(), + + Tables\Columns\TextColumn::make('users_count') + ->label('用户数量') + ->counts('users') + ->sortable(), + + Tables\Columns\TextColumn::make('terminals_count') + ->label('终端数量') + ->counts('terminals') + ->sortable(), + + Tables\Columns\TextColumn::make('knowledge_bases_count') + ->label('知识库数量') + ->counts('knowledgeBases') + ->sortable(), + + Tables\Columns\TextColumn::make('created_at') + ->label('创建时间') + ->dateTime('Y-m-d H:i:s') + ->sortable() + ->toggleable(), + ]) + ->actions([ + Tables\Actions\EditAction::make() + ->label('编辑'), + Tables\Actions\DeleteAction::make() + ->label('删除'), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make() + ->label('批量删除'), + ]), + ]) + ->defaultSort('name'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListStations::route('/'), + 'create' => Pages\CreateStation::route('/create'), + 'edit' => Pages\EditStation::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/StationResource/Pages/CreateStation.php b/app/Filament/Resources/StationResource/Pages/CreateStation.php new file mode 100644 index 0000000..b00ed27 --- /dev/null +++ b/app/Filament/Resources/StationResource/Pages/CreateStation.php @@ -0,0 +1,11 @@ +label('删除'), + ]; + } +} diff --git a/app/Filament/Resources/StationResource/Pages/ListStations.php b/app/Filament/Resources/StationResource/Pages/ListStations.php new file mode 100644 index 0000000..e5717f2 --- /dev/null +++ b/app/Filament/Resources/StationResource/Pages/ListStations.php @@ -0,0 +1,20 @@ +label('新建线站'), + ]; + } +} diff --git a/app/Filament/Resources/TerminalResource.php b/app/Filament/Resources/TerminalResource.php index a6a57c5..58cc71f 100644 --- a/app/Filament/Resources/TerminalResource.php +++ b/app/Filament/Resources/TerminalResource.php @@ -35,6 +35,18 @@ class TerminalResource extends Resource return auth()->user()?->can('terminal.view') ?? false; } + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + $user = auth()->user(); + + if ($user && $user->hasStationRestriction()) { + $query->whereIn('station_id', $user->getAccessibleStationIds()); + } + + return $query; + } + public static function form(Form $form): Form { return $form @@ -77,11 +89,13 @@ class TerminalResource extends Resource 'regex' => 'MAC地址格式不正确,应为 AA:BB:CC:DD:EE:FF', ]), - Forms\Components\TextInput::make('station_id') - ->label('线站ID') - ->maxLength(50) - ->placeholder('例如: BL02U1') - ->helperText('关联的光束线/线站标识'), + Forms\Components\Select::make('station_id') + ->label('所属线站') + ->relationship('station', 'name') + ->searchable() + ->preload() + ->placeholder('未绑定') + ->helperText('终端所属的线站'), ]) ->columns(2), @@ -131,44 +145,6 @@ class TerminalResource extends Resource ->columns(2) ->description('配置终端的语音唤醒能力'), - Forms\Components\Section::make('指引关联') - ->schema([ - Forms\Components\Repeater::make('guideAssociations') - ->label('关联指引') - ->relationship('guides') - ->schema([ - Forms\Components\Select::make('id') - ->label('指引') - ->options(\App\Models\Guide::where('status', 'published')->pluck('name', 'id')) - ->required() - ->searchable() - ->distinct() - ->disableOptionsWhenSelectedInSiblingRepeaterItems() - ->helperText('选择要关联的指引'), - - Forms\Components\TextInput::make('priority') - ->label('优先级') - ->numeric() - ->default(0) - ->required() - ->minValue(0) - ->helperText('数字越小优先级越高,0为最高优先级'), - ]) - ->columns(2) - ->reorderable() - ->reorderableWithButtons() - ->addActionLabel('添加指引') - ->reorderableWithDragAndDrop(false) - ->itemLabel( - fn(array $state): ?string => - \App\Models\Guide::find($state['id'])?->name ?? '未选择' - ) - ->collapsed() - ->collapsible() - ->helperText('可以关联多个指引,并设置优先级。拖动或使用按钮调整顺序。'), - ]) - ->description('配置终端可以访问的操作指引及其优先级'), - Forms\Components\Section::make('AI提示词配置') ->schema([ Forms\Components\Grid::make(3) @@ -177,21 +153,18 @@ class TerminalResource extends Resource ->label('提示词模板') ->language('markdown') ->fontSize('14px') - ->helperText('编辑AI提示词模板,支持使用占位符 {station_id}, {user}, {time}(由HMI端替换)') + ->helperText('编辑AI提示词模板,可用占位符: {station_name} {terminal_code} {terminal_name} {user} {time}') ->placeholderText('请输入AI提示词模板...') ->disablePreview() ->columnSpan(2), - Forms\Components\Grid::make(1) - ->schema([ - Forms\Components\Placeholder::make('variable_helper') - ->label('变量参考') - ->content(fn() => view('filament.components.prompt-variable-helper')), - ]) + Forms\Components\Placeholder::make('variable_helper') + ->label('可用占位符') + ->content('`{station_name}` 线站名称 · `{terminal_code}` 终端编码 · `{terminal_name}` 终端名称 · `{user}` 用户名称 · `{time}` 当前时间') ->columnSpan(1), ]), ]) - ->description('配置终端的AI提示词模板,占位符由HMI端替换') + ->description('配置终端的AI提示词模板') ->collapsible(), Forms\Components\Section::make('状态信息') @@ -243,8 +216,8 @@ class TerminalResource extends Resource ->copyable() ->placeholder('未设置'), - Tables\Columns\TextColumn::make('station_id') - ->label('线站ID') + Tables\Columns\TextColumn::make('station.name') + ->label('所属线站') ->sortable() ->placeholder('未绑定'), @@ -299,7 +272,7 @@ class TerminalResource extends Resource ]) ->defaultSort('created_at', 'desc') ->groups([ - Tables\Grouping\Group::make('station_id') + Tables\Grouping\Group::make('station.name') ->label('按线站分组') ->collapsible(), Tables\Grouping\Group::make('is_online') diff --git a/app/Filament/Resources/TerminalResource/Pages/ViewTerminal.php b/app/Filament/Resources/TerminalResource/Pages/ViewTerminal.php index 5dc3d53..5d97f8d 100644 --- a/app/Filament/Resources/TerminalResource/Pages/ViewTerminal.php +++ b/app/Filament/Resources/TerminalResource/Pages/ViewTerminal.php @@ -37,8 +37,8 @@ class ViewTerminal extends ViewRecord ->label('IP地址') ->copyable() ->placeholder('未设置'), - Infolists\Components\TextEntry::make('station_id') - ->label('线站ID') + Infolists\Components\TextEntry::make('station.name') + ->label('所属线站') ->placeholder('未绑定'), ]) ->columns(2), diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 6e29908..3489692 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -29,14 +29,27 @@ class UserResource extends Resource protected static ?string $navigationGroup = '权限管理'; - /** - * 控制导航菜单是否显示 - */ public static function shouldRegisterNavigation(): bool { return auth()->user()?->can('user.view') ?? false; } + public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder + { + $query = parent::getEloquentQuery(); + $user = auth()->user(); + + if ($user && $user->hasStationRestriction()) { + $stationIds = $user->getAccessibleStationIds(); + $query->where(function ($q) use ($stationIds) { + $q->whereDoesntHave('stations') + ->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $stationIds)); + }); + } + + return $query; + } + /** * 获取权限分组标签页 */ @@ -49,7 +62,6 @@ class UserResource extends Resource 'activity-log' => ['name' => '操作日志', 'icon' => 'heroicon-o-clipboard-document-list'], 'terminal' => ['name' => '终端管理', 'icon' => 'heroicon-o-computer-desktop'], 'guide' => ['name' => '操作指引', 'icon' => 'heroicon-o-book-open'], - 'group' => ['name' => '分组管理', 'icon' => 'heroicon-o-user-group'], 'user' => ['name' => '用户管理', 'icon' => 'heroicon-o-users'], 'role' => ['name' => '角色管理', 'icon' => 'heroicon-o-shield-check'], ]; @@ -140,15 +152,15 @@ class UserResource extends Resource ]) ->columns(2), - Forms\Components\Section::make('分组与角色') + Forms\Components\Section::make('线站与角色') ->schema([ - Forms\Components\Select::make('groups') - ->label('所属分组') + Forms\Components\Select::make('stations') + ->label('关联线站') ->multiple() - ->relationship('groups', 'name') + ->relationship('stations', 'name') ->preload() - ->placeholder('请选择用户所属的分组') - ->helperText('用户可以属于多个分组'), + ->placeholder('不关联线站则可访问全部资源') + ->helperText('关联线站后用户只能看到对应线站的资源'), Forms\Components\Select::make('roles') ->label('角色') ->multiple() @@ -172,7 +184,7 @@ class UserResource extends Resource ->dehydrateStateUsing(function ($state, $get) { // 收集所有模块的权限 $allPermissions = []; - $modules = ['document', 'system-setting', 'activity-log', 'terminal', 'guide', 'group', 'user', 'role']; + $modules = ['document', 'system-setting', 'activity-log', 'terminal', 'guide', 'user', 'role']; foreach ($modules as $module) { $modulePermissions = $get("permissions_{$module}") ?? []; @@ -222,7 +234,7 @@ class UserResource extends Resource }) ->searchable() ->toggleable(), - Tables\Columns\TextColumn::make('groups.name') + Tables\Columns\TextColumn::make('stations.name') ->label('所属分组') ->badge() ->searchable() @@ -300,7 +312,6 @@ class UserResource extends Resource public static function getRelations(): array { return [ - RelationManagers\GroupsRelationManager::class, ]; } diff --git a/app/Filament/Resources/UserResource/RelationManagers/GroupsRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/GroupsRelationManager.php deleted file mode 100644 index 94ed1b1..0000000 --- a/app/Filament/Resources/UserResource/RelationManagers/GroupsRelationManager.php +++ /dev/null @@ -1,87 +0,0 @@ -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('取消'), - ]), - ]); - } -} diff --git a/app/Filament/Widgets/KnowledgeBaseStatsWidget.php b/app/Filament/Widgets/KnowledgeBaseStatsWidget.php index d5a897f..e732268 100644 --- a/app/Filament/Widgets/KnowledgeBaseStatsWidget.php +++ b/app/Filament/Widgets/KnowledgeBaseStatsWidget.php @@ -3,7 +3,6 @@ namespace App\Filament\Widgets; use App\Models\Document; -use App\Models\Group; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; @@ -13,49 +12,29 @@ class KnowledgeBaseStatsWidget extends BaseWidget protected function getStats(): array { - // 统计文档数据 $totalDocuments = Document::count(); $completedDocuments = Document::where('conversion_status', 'completed')->count(); $failedDocuments = Document::where('conversion_status', 'failed')->count(); - $processingDocuments = Document::whereIn('conversion_status', ['pending', 'processing'])->count(); - - // 统计分组数据 - $totalGroups = Group::count(); - - // 计算转换成功率 - $conversionRate = $totalDocuments > 0 - ? round(($completedDocuments / $totalDocuments) * 100, 1) + + $conversionRate = $totalDocuments > 0 + ? round(($completedDocuments / $totalDocuments) * 100, 1) : 0; return [ Stat::make('文档总数', $totalDocuments) ->description('知识库中的文档总数') ->descriptionIcon('heroicon-m-document-text') - ->color('primary') - ->chart([7, 12, 15, 18, 22, 25, $totalDocuments]), - + ->color('primary'), + Stat::make('转换完成', $completedDocuments) ->description("成功率: {$conversionRate}%") ->descriptionIcon('heroicon-m-check-circle') - ->color('success') - ->chart([5, 10, 12, 15, 18, 20, $completedDocuments]), - + ->color('success'), + Stat::make('转换失败', $failedDocuments) ->description('需要重新处理') ->descriptionIcon('heroicon-m-x-circle') - ->color('danger') - ->url(route('filament.admin.resources.documents.index', ['tableFilters[conversion_status][value]' => 'failed'])), - - Stat::make('处理中', $processingDocuments) - ->description('等待转换或转换中') - ->descriptionIcon('heroicon-m-arrow-path') - ->color('warning'), - - Stat::make('知识库分组', $totalGroups) - ->description('专用知识库数量') - ->descriptionIcon('heroicon-m-folder') - ->color('info') - ->url(route('filament.admin.resources.groups.index')), + ->color('danger'), ]; } } diff --git a/app/Filament/Widgets/TerminalStatsWidget.php b/app/Filament/Widgets/TerminalStatsWidget.php index 7ff5383..267441a 100644 --- a/app/Filament/Widgets/TerminalStatsWidget.php +++ b/app/Filament/Widgets/TerminalStatsWidget.php @@ -2,8 +2,8 @@ namespace App\Filament\Widgets; +use App\Models\Station; use App\Models\Terminal; -use App\Models\TerminalPrompt; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; @@ -14,23 +14,20 @@ class TerminalStatsWidget extends BaseWidget protected function getStats(): array { // 统计终端数据 + $totalStations = Station::count(); $totalTerminals = Terminal::count(); $onlineTerminals = Terminal::where('is_online', true)->count(); - // 统计提示词 - $totalPrompts = TerminalPrompt::count(); - return [ + Stat::make('线站数量', $totalStations) + ->description('线站') + ->descriptionIcon('heroicon-m-building-office') + ->color('info'), + Stat::make('终端总数', $totalTerminals) ->description("{$onlineTerminals} 个在线") ->descriptionIcon('heroicon-m-computer-desktop') - ->color('primary') - ->url(route('filament.admin.resources.terminals.index')), - - Stat::make('提示词配置', $totalPrompts) - ->description('终端提示词总数') - ->descriptionIcon('heroicon-m-chat-bubble-left-right') - ->color('success'), + ->color('primary'), ]; } } diff --git a/app/Http/Controllers/Api/TerminalApiController.php b/app/Http/Controllers/Api/TerminalApiController.php index 1450614..7446249 100644 --- a/app/Http/Controllers/Api/TerminalApiController.php +++ b/app/Http/Controllers/Api/TerminalApiController.php @@ -22,20 +22,20 @@ class TerminalApiController extends Controller public function config(Request $request): JsonResponse { $terminal = $request->attributes->get('terminal'); - $terminal->load('prompt'); + $terminal->load(['prompt', 'station']); - // 返回原始提示词模板(占位符由HMI端替换) + // 返回原始提示词模板 $systemPrompt = $terminal->prompt?->prompt_template ?? ''; - // 获取终端关联的已发布指引数量 - $guideCount = $terminal->guides()->published()->count(); + // 获取终端所属线站的已发布指引数量(含全局指引) + $guideCount = $this->getTerminalGuides($terminal)->count(); return response()->json([ 'terminal' => [ 'id' => $terminal->id, - 'name' => $terminal->name, - 'code' => $terminal->code, - 'station_id' => $terminal->station_id, + 'terminal_name' => $terminal->name, + 'terminal_code' => $terminal->code, + 'station_name' => $terminal->station?->name, 'diagram_url' => $terminal->diagram_url, 'scada_data_url' => $terminal->scada_data_url, 'scada_tags_url' => $terminal->scada_tags_url, @@ -57,7 +57,8 @@ class TerminalApiController extends Controller 'query' => 'required|string|max:500', ]); - $result = $this->knowledgeService->search($request->input('query')); + $terminal = $request->attributes->get('terminal'); + $result = $this->knowledgeService->search($request->input('query'), $terminal); return response()->json($result); } @@ -69,7 +70,7 @@ class TerminalApiController extends Controller public function guides(Request $request): JsonResponse { $terminal = $request->attributes->get('terminal'); - $query = $terminal->guides()->published()->withCount('pages'); + $query = $this->getTerminalGuides($terminal)->withCount('pages'); if ($category = $request->input('category')) { $query->where('category', $category); @@ -99,7 +100,7 @@ class TerminalApiController extends Controller ]); $terminal = $request->attributes->get('terminal'); - $accessibleIds = $terminal->guides()->published()->pluck('guides.id')->toArray(); + $accessibleIds = $this->getTerminalGuides($terminal)->pluck('guides.id')->toArray(); $guideIds = $request->input('guide_ids'); $pages = []; @@ -199,6 +200,21 @@ class TerminalApiController extends Controller return $loads; } + /** + * 获取终端可见的指引(线站关联 + 全局) + */ + private function getTerminalGuides($terminal) + { + $stationId = $terminal->station_id; + + return Guide::published()->where(function ($q) use ($stationId) { + $q->whereDoesntHave('stations'); // 全局指引 + if ($stationId) { + $q->orWhereHas('stations', fn ($sq) => $sq->where('stations.id', $stationId)); + } + }); + } + /** * POST /api/terminal/heartbeat * 终端心跳上报 diff --git a/app/Models/Document.php b/app/Models/Document.php index 9272335..24c50cc 100644 --- a/app/Models/Document.php +++ b/app/Models/Document.php @@ -2,7 +2,6 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -24,25 +23,14 @@ class Document extends Model 'file_name', 'file_size', 'mime_type', - 'type', - 'group_id', 'uploaded_by', 'description', 'markdown_path', - 'markdown_preview', 'conversion_status', 'conversion_error', 'knowledge_base_id', ]; - /** - * 获取文档所属的分组 - */ - public function group(): BelongsTo - { - return $this->belongsTo(Group::class); - } - /** * 获取文档所属的知识库 */ @@ -67,57 +55,9 @@ class Document extends Model return $this->hasMany(DownloadLog::class); } - /** - * 查询作用域:获取用户可访问的文档 - * 包含全局文档和用户分组的专用文档,排除其他分组的专用文档 - * - * @param Builder $query - * @param User $user - * @return Builder - */ - public function scopeAccessibleBy(Builder $query, User $user): Builder - { - // 获取用户所属的所有分组 ID - $userGroupIds = $user->groups()->pluck('groups.id')->toArray(); - - return $query->where(function (Builder $query) use ($userGroupIds) { - // 包含所有全局文档 - $query->where('type', 'global') - // 或者包含用户所属分组的专用文档 - ->orWhere(function (Builder $query) use ($userGroupIds) { - $query->where('type', 'dedicated') - ->whereIn('group_id', $userGroupIds); - }); - }); - } - - /** - * 查询作用域:仅获取全局文档 - * - * @param Builder $query - * @return Builder - */ - public function scopeGlobal(Builder $query): Builder - { - return $query->where('type', 'global'); - } - - /** - * 查询作用域:仅获取专用文档 - * - * @param Builder $query - * @return Builder - */ - public function scopeDedicated(Builder $query): Builder - { - return $query->where('type', 'dedicated'); - } - /** * 获取可搜索的数组数据 * 用于 Meilisearch 索引 - * - * @return array */ public function toSearchableArray(): array { @@ -127,8 +67,6 @@ class Document extends Model 'file_name' => $this->file_name, 'description' => $this->description, 'markdown_content' => $this->getMarkdownContent(), - 'type' => $this->type, - 'group_id' => $this->group_id, 'knowledge_base_id' => $this->knowledge_base_id, 'uploaded_by' => $this->uploaded_by, 'created_at' => $this->created_at?->timestamp, @@ -138,8 +76,6 @@ class Document extends Model /** * 判断文档是否应该被索引 * 只有转换完成的文档才会被索引 - * - * @return bool */ public function shouldBeSearchable(): bool { @@ -149,8 +85,6 @@ class Document extends Model /** * 获取完整的 Markdown 内容 * 从文件系统读取 Markdown 文件 - * - * @return string|null */ public function getMarkdownContent(): ?string { @@ -163,7 +97,6 @@ class Document extends Model return Storage::disk('markdown')->get($this->markdown_path); } } catch (\Exception $e) { - // 记录错误但不抛出异常 \Log::warning('Failed to read markdown content', [ 'document_id' => $this->id, 'markdown_path' => $this->markdown_path, @@ -176,8 +109,6 @@ class Document extends Model /** * 检查文档是否已转换为 Markdown - * - * @return bool */ public function hasMarkdown(): bool { diff --git a/app/Models/DownloadLog.php b/app/Models/DownloadLog.php index ba474b2..ca03106 100644 --- a/app/Models/DownloadLog.php +++ b/app/Models/DownloadLog.php @@ -2,13 +2,11 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class DownloadLog extends Model { - use HasFactory; /** * 表示模型不使用 created_at 和 updated_at 时间戳 * 因为我们使用自定义的 downloaded_at 字段 diff --git a/app/Models/Group.php b/app/Models/Group.php deleted file mode 100644 index 00e6895..0000000 --- a/app/Models/Group.php +++ /dev/null @@ -1,53 +0,0 @@ - - */ - protected $fillable = [ - 'name', - 'description', - ]; - - /** - * 模型的启动方法 - * 注册模型事件监听器 - */ - protected static function boot() - { - parent::boot(); - - // 监听分组删除事件 - static::deleting(function (Group $group) { - // 将该分组的所有专用文档的 group_id 设置为 null(孤立状态) - $group->documents()->update(['group_id' => null]); - }); - } - - /** - * 获取分组的所有用户 - */ - public function users(): BelongsToMany - { - return $this->belongsToMany(User::class); - } - - /** - * 获取分组的所有文档 - */ - public function documents(): HasMany - { - return $this->hasMany(Document::class); - } -} diff --git a/app/Models/Guide.php b/app/Models/Guide.php index 5f80f9a..5b5a404 100644 --- a/app/Models/Guide.php +++ b/app/Models/Guide.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -47,12 +48,9 @@ class Guide extends Model return $this->belongsTo(User::class, 'created_by'); } - public function terminals() + public function stations() { - return $this->belongsToMany(Terminal::class, 'terminal_guides') - ->withPivot('priority') - ->withTimestamps() - ->orderBy('priority'); + return $this->belongsToMany(Station::class); } public function scopePublished($query) @@ -65,6 +63,23 @@ class Guide extends Model return $query->where('category', $category); } + /** + * 按用户线站过滤:全局 Guide(无线站关联)+ 用户线站关联的 Guide + */ + public function scopeAccessibleBy(Builder $query, User $user): Builder + { + if (!$user->hasStationRestriction()) { + return $query; + } + + $stationIds = $user->getAccessibleStationIds(); + + return $query->where(function (Builder $q) use ($stationIds) { + $q->whereDoesntHave('stations') + ->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $stationIds)); + }); + } + public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() diff --git a/app/Models/KnowledgeBase.php b/app/Models/KnowledgeBase.php index c27ef29..4d20420 100644 --- a/app/Models/KnowledgeBase.php +++ b/app/Models/KnowledgeBase.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -30,4 +31,31 @@ class KnowledgeBase extends Model { return $this->hasMany(Document::class); } + + /** + * 获取知识库关联的线站 + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function stations() + { + return $this->belongsToMany(Station::class); + } + + /** + * 按用户线站过滤:全局 KB(无线站关联)+ 用户线站关联的 KB + */ + public function scopeAccessibleBy(Builder $query, User $user): Builder + { + if (!$user->hasStationRestriction()) { + return $query; + } + + $stationIds = $user->getAccessibleStationIds(); + + return $query->where(function (Builder $q) use ($stationIds) { + $q->whereDoesntHave('stations') + ->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $stationIds)); + }); + } } diff --git a/app/Models/Station.php b/app/Models/Station.php new file mode 100644 index 0000000..0bebeab --- /dev/null +++ b/app/Models/Station.php @@ -0,0 +1,48 @@ +terminals()->update(['station_id' => null]); + }); + } + + public function terminals(): HasMany + { + return $this->hasMany(Terminal::class); + } + + public function knowledgeBases(): BelongsToMany + { + return $this->belongsToMany(KnowledgeBase::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class); + } + + public function guides(): BelongsToMany + { + return $this->belongsToMany(Guide::class); + } +} diff --git a/app/Models/Terminal.php b/app/Models/Terminal.php index 6f96ac4..df763b8 100644 --- a/app/Models/Terminal.php +++ b/app/Models/Terminal.php @@ -47,16 +47,13 @@ class Terminal extends Model } /** - * 获取终端关联的指引 + * 获取终端所属的线站 * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function guides() + public function station() { - return $this->belongsToMany(Guide::class, 'terminal_guides') - ->withPivot('priority') - ->withTimestamps() - ->orderBy('priority'); + return $this->belongsTo(Station::class); } /** diff --git a/app/Models/TerminalPrompt.php b/app/Models/TerminalPrompt.php index df1ea63..ca9e75b 100644 --- a/app/Models/TerminalPrompt.php +++ b/app/Models/TerminalPrompt.php @@ -2,14 +2,13 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\LogOptions; class TerminalPrompt extends Model { - use HasFactory, LogsActivity; + use LogsActivity; /** * 可批量赋值的属性 * diff --git a/app/Models/User.php b/app/Models/User.php index 06b0a56..389fd6f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -50,11 +50,28 @@ class User extends Authenticatable } /** - * 获取用户所属的所有分组 + * 获取用户关联的线站 */ - public function groups(): BelongsToMany + public function stations(): BelongsToMany { - return $this->belongsToMany(Group::class); + return $this->belongsToMany(Station::class); + } + + /** + * 获取用户可访问的线站 IDs + * 空数组表示无限制(管理员) + */ + public function getAccessibleStationIds(): array + { + return $this->stations()->pluck('stations.id')->toArray(); + } + + /** + * 用户是否受线站限制 + */ + public function hasStationRestriction(): bool + { + return $this->stations()->exists(); } /** diff --git a/app/Observers/DocumentObserver.php b/app/Observers/DocumentObserver.php index 7ce4aa6..84890b0 100644 --- a/app/Observers/DocumentObserver.php +++ b/app/Observers/DocumentObserver.php @@ -38,7 +38,7 @@ class DocumentObserver if ($document->wasChanged('conversion_status') && $document->conversion_status === 'completed') { // 转换完成,创建或更新索引 $this->searchService->indexDocument($document); - } elseif ($document->wasChanged(['title', 'description', 'markdown_path', 'type', 'group_id'])) { + } elseif ($document->wasChanged(['title', 'description', 'markdown_path', 'knowledge_base_id'])) { // 其他重要字段更新时,也更新索引 $this->searchService->updateDocumentIndex($document); } diff --git a/app/Policies/DocumentPolicy.php b/app/Policies/DocumentPolicy.php index 40da146..4361816 100644 --- a/app/Policies/DocumentPolicy.php +++ b/app/Policies/DocumentPolicy.php @@ -49,39 +49,7 @@ class DocumentPolicy */ public function view(User $user, Document $document): bool { - // 首先检查用户是否有查看文档的权限 - if (!$user->can('document.view')) { - $this->securityLogger->logUnauthorizedAccess($user, $document, 'view'); - return false; - } - - // 如果是全局文档,所有用户都可以查看 - if ($document->type === 'global') { - return true; - } - - // 如果是专用文档,检查用户是否属于该文档的分组 - if ($document->type === 'dedicated') { - // 如果文档没有关联分组,拒绝访问 - if (!$document->group_id) { - $this->securityLogger->logUnauthorizedAccess($user, $document, 'view'); - return false; - } - - // 检查用户是否属于该文档的分组 - $hasAccess = $user->groups()->where('groups.id', $document->group_id)->exists(); - - // 如果没有权限,记录未授权访问尝试 - if (!$hasAccess) { - $this->securityLogger->logUnauthorizedAccess($user, $document, 'view'); - } - - return $hasAccess; - } - - // 其他情况拒绝访问 - $this->securityLogger->logUnauthorizedAccess($user, $document, 'view'); - return false; + return $user->can('document.view'); } /** @@ -169,36 +137,7 @@ class DocumentPolicy */ public function download(User $user, Document $document): bool { - // 首先检查用户是否有下载文档的权限 - if (!$user->can('document.download')) { - $this->securityLogger->logUnauthorizedAccess($user, $document, 'download'); - return false; - } - - // 下载权限与查看权限相同(但不需要 document.view 权限) - // 如果是全局文档,所有用户都可以下载 - if ($document->type === 'global') { - return true; - } - - // 如果是专用文档,检查用户是否属于该文档的分组 - if ($document->type === 'dedicated') { - if (!$document->group_id) { - $this->securityLogger->logUnauthorizedAccess($user, $document, 'download'); - return false; - } - - $hasAccess = $user->groups()->where('groups.id', $document->group_id)->exists(); - - if (!$hasAccess) { - $this->securityLogger->logUnauthorizedAccess($user, $document, 'download'); - } - - return $hasAccess; - } - - $this->securityLogger->logUnauthorizedAccess($user, $document, 'download'); - return false; + return $user->can('document.download'); } /** diff --git a/app/Policies/GroupPolicy.php b/app/Policies/GroupPolicy.php deleted file mode 100644 index d405342..0000000 --- a/app/Policies/GroupPolicy.php +++ /dev/null @@ -1,72 +0,0 @@ -can('group.view'); - } - - /** - * 查看分组详情 - */ - public function view(User $user, Group $group): bool - { - return $user->can('group.view'); - } - - /** - * 创建分组 - */ - public function create(User $user): bool - { - return $user->can('group.create'); - } - - /** - * 更新分组 - */ - public function update(User $user, Group $group): bool - { - return $user->can('group.update'); - } - - /** - * 删除分组 - */ - public function delete(User $user, Group $group): bool - { - // 首先检查权限 - if (!$user->can('group.delete')) { - return false; - } - - // 检查是否有关联文档 - if ($group->documents()->count() > 0) { - return false; - } - - // 检查是否有关联用户 - if ($group->users()->count() > 0) { - return false; - } - - return true; - } - - /** - * 批量删除分组 - */ - public function deleteAny(User $user): bool - { - return $user->can('group.delete'); - } -} diff --git a/app/Policies/StationPolicy.php b/app/Policies/StationPolicy.php new file mode 100644 index 0000000..332063d --- /dev/null +++ b/app/Policies/StationPolicy.php @@ -0,0 +1,39 @@ +can('station.view'); + } + + public function view(User $user, Station $station): bool + { + return $user->can('station.view'); + } + + public function create(User $user): bool + { + return $user->can('station.create'); + } + + public function update(User $user, Station $station): bool + { + return $user->can('station.update'); + } + + public function delete(User $user, Station $station): bool + { + return $user->can('station.delete'); + } + + public function deleteAny(User $user): bool + { + return $user->can('station.delete'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4df46ae..0b3898b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -39,6 +39,6 @@ class AppServiceProvider extends ServiceProvider Gate::policy(\App\Models\User::class, \App\Policies\UserPolicy::class); Gate::policy(\App\Models\SystemSetting::class, \App\Policies\SystemSettingPolicy::class); Gate::policy(\Spatie\Activitylog\Models\Activity::class, \App\Policies\ActivityLogPolicy::class); - Gate::policy(\App\Models\Group::class, \App\Policies\GroupPolicy::class); + Gate::policy(\App\Models\Station::class, \App\Policies\StationPolicy::class); } } diff --git a/app/Services/DocumentConversionService.php b/app/Services/DocumentConversionService.php index 6ab3890..f12b332 100644 --- a/app/Services/DocumentConversionService.php +++ b/app/Services/DocumentConversionService.php @@ -110,7 +110,6 @@ class DocumentConversionService $document->update([ 'markdown_path' => $markdownPath, - 'markdown_preview' => $preview, 'conversion_status' => 'completed', 'conversion_error' => null, ]); diff --git a/app/Services/DocumentSearchService.php b/app/Services/DocumentSearchService.php index bc71b2e..876c956 100644 --- a/app/Services/DocumentSearchService.php +++ b/app/Services/DocumentSearchService.php @@ -3,100 +3,103 @@ namespace App\Services; use App\Models\Document; -use App\Models\User; +use App\Models\KnowledgeBase; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Log; -/** - * 文档搜索服务 - * 负责处理文档的全文搜索和 Meilisearch 索引管理 - */ class DocumentSearchService { /** * 搜索文档 - * 使用 Laravel Scout 和 Meilisearch 进行全文搜索 * * @param string $query 搜索关键词 - * @param User $user 当前用户 - * @param array $filters 额外的筛选条件 + * @param array $accessibleStationIds 权限边界的线站 IDs(空=不限制) + * @param array $filters 用户主动筛选: + * - station_ids: int[] 线站 ID + * - knowledge_base_ids: int[] 知识库 ID + * - uploaded_by: int 上传者 ID * @return Collection */ - public function search(string $query, User $user, array $filters = []): Collection + public function search(string $query, array $accessibleStationIds = [], array $filters = []): Collection { try { - // 使用 Scout 进行搜索 + $kbIds = $this->resolveKnowledgeBaseIds($accessibleStationIds, $filters); + $searchBuilder = Document::search($query); - // 应用额外的筛选条件 - if (!empty($filters['type'])) { - $searchBuilder->where('type', $filters['type']); - } - - if (!empty($filters['group_id'])) { - $searchBuilder->where('group_id', $filters['group_id']); - } - if (!empty($filters['uploaded_by'])) { $searchBuilder->where('uploaded_by', $filters['uploaded_by']); } - if (!empty($filters['knowledge_base_id'])) { - $searchBuilder->where('knowledge_base_id', $filters['knowledge_base_id']); + if (!empty($kbIds)) { + $searchBuilder->query(fn ($q) => $q->whereIn('knowledge_base_id', $kbIds)); } - // 执行搜索并获取结果 - $results = $searchBuilder->get(); - - // 应用用户权限过滤 - return $this->filterByUserPermissions($results, $user); + return $searchBuilder->get(); } catch (\Exception $e) { Log::error('文档搜索失败', [ 'query' => $query, - 'user_id' => $user->id, 'filters' => $filters, 'error' => $e->getMessage(), ]); - // 搜索失败时返回空集合 return new Collection(); } } /** - * 根据用户权限过滤搜索结果 - * 确保用户只能看到有权限访问的文档 + * 解析最终可搜索的知识库 ID 列表 * - * @param Collection $results 搜索结果 - * @param User $user 当前用户 - * @return Collection + * 1. 从 accessibleStationIds 得到可访问 KB(全局 KB + 关联 station 的 KB) + * 2. 从 filters 得到用户指定的 KB(station_ids + knowledge_base_ids 并集) + * 3. 取交集(若有 filter 指定),否则用可访问范围 */ - public function filterByUserPermissions(Collection $results, User $user): Collection + private function resolveKnowledgeBaseIds(array $accessibleStationIds, array $filters): array { - // 获取用户所属的所有分组 ID - $userGroupIds = $user->groups()->pluck('groups.id')->toArray(); + // 权限边界 + $accessibleKbIds = null; + if (!empty($accessibleStationIds)) { + $accessibleKbIds = KnowledgeBase::where(function ($q) use ($accessibleStationIds) { + $q->whereDoesntHave('stations') + ->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $accessibleStationIds)); + })->pluck('id')->toArray(); + } - return $results->filter(function (Document $document) use ($userGroupIds) { - // 全局文档对所有用户可见 - if ($document->type === 'global') { - return true; - } + // 用户主动筛选 + $kbIdsFromStations = []; + if (!empty($filters['station_ids'])) { + $kbIdsFromStations = KnowledgeBase::where(function ($q) use ($filters) { + $q->whereDoesntHave('stations') + ->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $filters['station_ids'])); + })->pluck('id')->toArray(); + } - // 专用文档只对所属分组的用户可见 - if ($document->type === 'dedicated') { - return in_array($document->group_id, $userGroupIds); - } + $kbIdsFromFilter = $filters['knowledge_base_ids'] ?? []; - return false; - }); + if (!empty($kbIdsFromStations) && !empty($kbIdsFromFilter)) { + $filterKbIds = array_values(array_intersect($kbIdsFromStations, $kbIdsFromFilter)); + } elseif (!empty($kbIdsFromStations)) { + $filterKbIds = $kbIdsFromStations; + } elseif (!empty($kbIdsFromFilter)) { + $filterKbIds = $kbIdsFromFilter; + } else { + $filterKbIds = []; + } + + // 合并 + if ($accessibleKbIds === null) { + return $filterKbIds; // 无权限限制 + } + + if (empty($filterKbIds)) { + return $accessibleKbIds; // 无主动筛选,用权限范围 + } + + return array_values(array_intersect($filterKbIds, $accessibleKbIds)); // 取交集 } /** * 准备文档的可搜索数据 - * 包含完整的 Markdown 内容用于索引 - * - * @param Document $document 文档模型 - * @return array */ public function prepareSearchableData(Document $document): array { @@ -105,8 +108,6 @@ class DocumentSearchService 'title' => $document->title, 'description' => $document->description, 'markdown_content' => $document->getMarkdownContent(), - 'type' => $document->type, - 'group_id' => $document->group_id, 'knowledge_base_id' => $document->knowledge_base_id, 'uploaded_by' => $document->uploaded_by, 'created_at' => $document->created_at?->timestamp, @@ -114,99 +115,37 @@ class DocumentSearchService ]; } - /** - * 索引文档到 Meilisearch - * 读取 Markdown 文件并创建搜索索引 - * - * @param Document $document 文档模型 - * @return void - */ public function indexDocument(Document $document): void { try { - // 只索引已完成转换的文档 if (!$document->shouldBeSearchable()) { - Log::info('文档未完成转换,跳过索引', [ - 'document_id' => $document->id, - 'conversion_status' => $document->conversion_status, - ]); return; } - - // 使用 Scout 的 searchable 方法进行索引 $document->searchable(); - - Log::info('文档索引成功', [ - 'document_id' => $document->id, - 'title' => $document->title, - ]); } catch (\Exception $e) { - Log::error('文档索引失败', [ - 'document_id' => $document->id, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - // 索引失败不影响文档的正常使用,只记录错误 + Log::error('文档索引失败', ['document_id' => $document->id, 'error' => $e->getMessage()]); } } - /** - * 更新文档在 Meilisearch 中的索引 - * - * @param Document $document 文档模型 - * @return void - */ public function updateDocumentIndex(Document $document): void { try { - // 如果文档应该被索引,则更新索引 if ($document->shouldBeSearchable()) { $document->searchable(); - - Log::info('文档索引更新成功', [ - 'document_id' => $document->id, - 'title' => $document->title, - ]); } else { - // 如果文档不应该被索引(例如转换失败),则从索引中移除 $this->removeDocumentFromIndex($document); } } catch (\Exception $e) { - Log::error('文档索引更新失败', [ - 'document_id' => $document->id, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - // 索引更新失败不影响文档的正常使用,只记录错误 + Log::error('文档索引更新失败', ['document_id' => $document->id, 'error' => $e->getMessage()]); } } - /** - * 从 Meilisearch 中移除文档索引 - * - * @param Document $document 文档模型 - * @return void - */ public function removeDocumentFromIndex(Document $document): void { try { - // 使用 Scout 的 unsearchable 方法移除索引 $document->unsearchable(); - - Log::info('文档索引移除成功', [ - 'document_id' => $document->id, - 'title' => $document->title, - ]); } catch (\Exception $e) { - Log::error('文档索引移除失败', [ - 'document_id' => $document->id, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - // 索引移除失败不影响文档的正常删除,只记录错误 + Log::error('文档索引移除失败', ['document_id' => $document->id, 'error' => $e->getMessage()]); } } } diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index dddcfc4..298b452 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -15,16 +15,7 @@ class DocumentService */ public function validateDocumentAccess(Document $document, User $user): bool { - if ($document->type === 'global') { - return true; - } - - if ($document->type === 'dedicated') { - $userGroupIds = $user->groups()->pluck('groups.id')->toArray(); - return in_array($document->group_id, $userGroupIds); - } - - return false; + return true; } /** diff --git a/app/Services/KnowledgeContextService.php b/app/Services/KnowledgeContextService.php index 3c90782..5f09de5 100644 --- a/app/Services/KnowledgeContextService.php +++ b/app/Services/KnowledgeContextService.php @@ -2,80 +2,34 @@ namespace App\Services; -use App\Models\Document; -use App\Models\KnowledgeBase; +use App\Models\Terminal; use Illuminate\Support\Facades\Log; class KnowledgeContextService { private const MAX_CONTEXT_LENGTH = 2000; - private const TOP_K = 5; + + public function __construct( + private DocumentSearchService $searchService, + ) {} /** - * 搜索所有知识库中的文档 - * - * @param string $query - * @return array{context: string, sources: array} + * 搜索知识库中的文档,返回 RAG 上下文 */ - public function search(string $query): array + public function search(string $query, ?Terminal $terminal = null): array { - $knowledgeBaseIds = KnowledgeBase::where('status', 'active')->pluck('id')->toArray(); + $stationIds = $terminal?->station_id ? [$terminal->station_id] : []; - if (empty($knowledgeBaseIds)) { - return [ - 'context' => '', - 'sources' => [], - ]; + $results = $this->searchService->search($query, $stationIds); + + if ($results->isEmpty()) { + return ['context' => '', 'sources' => []]; } - try { - // 使用 Scout/Meilisearch 搜索并获取排名分数 - $rawResults = Document::search($query, function ($meilisearch, $query, $options) use ($knowledgeBaseIds) { - $options['showRankingScore'] = true; - $filter = collect($knowledgeBaseIds) - ->map(fn($id) => "knowledge_base_id = {$id}") - ->implode(' OR '); - $options['filter'] = $filter; - $options['limit'] = self::TOP_K; - return $meilisearch->search($query, $options); - })->raw(); - - $hits = $rawResults['hits'] ?? []; - } catch (\Exception $e) { - Log::warning('Knowledge search failed', [ - 'query' => $query, - 'error' => $e->getMessage(), - ]); - - return [ - 'context' => '', - 'sources' => [], - ]; - } - - if (empty($hits)) { - return [ - 'context' => '', - 'sources' => [], - ]; - } - - // 取出文档 ID 并加载 Eloquent 模型 - $hitIds = collect($hits)->pluck('id')->toArray(); - $documents = Document::whereIn('id', $hitIds)->get()->keyBy('id'); - - // 构建排名分数映射 - $rankingScores = collect($hits)->pluck('_rankingScore', 'id'); - $context = ''; $sources = []; - foreach ($hits as $hit) { - $document = $documents->get($hit['id']); - if (!$document) { - continue; - } - + foreach ($results as $document) { $snippet = $this->extractSnippet($document); if (mb_strlen($context) + mb_strlen($snippet) > self::MAX_CONTEXT_LENGTH) { @@ -86,7 +40,6 @@ class KnowledgeContextService $sources[] = [ 'title' => $document->title, 'id' => 'kb-doc-' . str_pad($document->id, 3, '0', STR_PAD_LEFT), - 'relevance' => round($rankingScores->get($document->id, 0), 2), ]; } @@ -96,12 +49,9 @@ class KnowledgeContextService ]; } - /** - * 从文档中提取摘要片段 - */ private function extractSnippet($document): string { - $content = $document->markdown_preview ?? $document->description ?? ''; + $content = $document->getMarkdownContent() ?? $document->description ?? ''; if (mb_strlen($content) <= 500) { return "【{$document->title}】\n{$content}"; diff --git a/app/Services/SecurityLogger.php b/app/Services/SecurityLogger.php index 59ad235..46233ac 100644 --- a/app/Services/SecurityLogger.php +++ b/app/Services/SecurityLogger.php @@ -38,8 +38,7 @@ class SecurityLogger 'user_email' => $user->email, 'document_id' => $document->id, 'document_title' => $document->title, - 'document_type' => $document->type, - 'document_group_id' => $document->group_id, + 'document_knowledge_base_id' => $document->knowledge_base_id, 'ip_address' => $ipAddress, 'timestamp' => now()->toIso8601String(), 'user_agent' => request()->userAgent(), diff --git a/config/prompt_variables.php b/config/prompt_variables.php deleted file mode 100644 index e582476..0000000 --- a/config/prompt_variables.php +++ /dev/null @@ -1,39 +0,0 @@ - [ - [ - 'name' => 'station_id', - 'label' => '线站ID', - 'description' => '终端所在的线站标识,由HMI从 /config 接口获取', - 'example' => 'BL02U1', - 'source' => 'KMS /config', - 'replaced_by' => 'HMI', - ], - [ - 'name' => 'user', - 'label' => '用户名称', - 'description' => '当前登录用户的姓名,由HMI从登录信息或固定值获取', - 'example' => '张三', - 'source' => '登录信息或固定值', - 'replaced_by' => 'HMI', - ], - [ - 'name' => 'time', - 'label' => '当前时间', - 'description' => '当前的日期和时间,由HMI通过 QDateTime::currentDateTime() 获取', - 'example' => '2026-03-23 14:30:00', - 'source' => 'QDateTime::currentDateTime()', - 'replaced_by' => 'HMI', - ], - ], -]; diff --git a/config/scout.php b/config/scout.php index a856036..a119171 100644 --- a/config/scout.php +++ b/config/scout.php @@ -141,10 +141,10 @@ return [ 'key' => env('MEILISEARCH_KEY'), 'index-settings' => [ 'documents' => [ - 'filterableAttributes' => ['type', 'group_id', 'knowledge_base_id', 'uploaded_by', 'conversion_status'], + 'filterableAttributes' => ['knowledge_base_id', 'uploaded_by', 'conversion_status'], 'sortableAttributes' => ['created_at', 'title', 'updated_at'], 'searchableAttributes' => ['title', 'description', 'markdown_content'], - 'displayedAttributes' => ['id', 'title', 'description', 'type', 'group_id', 'knowledge_base_id', 'uploaded_by', 'created_at', 'updated_at'], + 'displayedAttributes' => ['id', 'title', 'description', 'knowledge_base_id', 'uploaded_by', 'created_at', 'updated_at'], ], ], ], diff --git a/database/factories/DocumentFactory.php b/database/factories/DocumentFactory.php index e3db4f7..86a67ad 100644 --- a/database/factories/DocumentFactory.php +++ b/database/factories/DocumentFactory.php @@ -3,7 +3,7 @@ namespace Database\Factories; use App\Models\Document; -use App\Models\Group; +use App\Models\KnowledgeBase; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -12,40 +12,21 @@ use Illuminate\Database\Eloquent\Factories\Factory; */ class DocumentFactory extends Factory { - /** - * The name of the factory's corresponding model. - * - * @var string - */ protected $model = Document::class; - /** - * Define the model's default state. - * - * @return array - */ public function definition(): array { - // 使用中文 Faker 生成器 $faker = \Faker\Factory::create('zh_CN'); - - // 生成中文文档标题 + $titles = [ - '项目管理规范', - '技术文档模板', - '员工手册', - '产品需求文档', - '系统设计方案', - '测试报告', - '会议纪要', - '培训资料', - '操作指南', - '年度总结报告', + '项目管理规范', '技术文档模板', '员工手册', '产品需求文档', + '系统设计方案', '测试报告', '会议纪要', '培训资料', + '操作指南', '年度总结报告', ]; - + $title = $faker->randomElement($titles) . ' - ' . $faker->word(); $fileName = $faker->word() . '_' . date('Ymd') . '.docx'; - + return [ 'title' => $title, 'description' => $faker->paragraph(3), @@ -53,38 +34,15 @@ class DocumentFactory extends Factory 'file_name' => $fileName, 'file_size' => fake()->numberBetween(10000, 5000000), 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'type' => fake()->randomElement(['global', 'dedicated']), - 'group_id' => null, + 'knowledge_base_id' => KnowledgeBase::factory(), 'uploaded_by' => User::factory(), 'markdown_path' => null, - 'markdown_preview' => null, + 'conversion_status' => 'pending', 'conversion_error' => null, ]; } - /** - * 指定文档为全局类型 - */ - public function global(): static - { - return $this->state(fn (array $attributes) => [ - 'type' => 'global', - 'group_id' => null, - ]); - } - - /** - * 指定文档为专用类型 - */ - public function dedicated(?int $groupId = null): static - { - return $this->state(fn (array $attributes) => [ - 'type' => 'dedicated', - 'group_id' => $groupId ?? Group::factory(), - ]); - } - /** * 指定文档已完成转换 */ @@ -92,10 +50,10 @@ class DocumentFactory extends Factory { $faker = \Faker\Factory::create('zh_CN'); $uuid = fake()->uuid(); - + return $this->state(fn (array $attributes) => [ 'markdown_path' => 'markdown/' . date('Y/m/d') . '/' . $uuid . '.md', - 'markdown_preview' => $faker->text(500), + 'conversion_status' => 'completed', 'conversion_error' => null, ]); @@ -108,7 +66,7 @@ class DocumentFactory extends Factory { return $this->state(fn (array $attributes) => [ 'markdown_path' => null, - 'markdown_preview' => null, + 'conversion_status' => 'failed', 'conversion_error' => 'Failed to convert document: Invalid file format', ]); @@ -121,7 +79,7 @@ class DocumentFactory extends Factory { return $this->state(fn (array $attributes) => [ 'markdown_path' => null, - 'markdown_preview' => null, + 'conversion_status' => 'processing', 'conversion_error' => null, ]); diff --git a/database/factories/DownloadLogFactory.php b/database/factories/DownloadLogFactory.php deleted file mode 100644 index 54069bc..0000000 --- a/database/factories/DownloadLogFactory.php +++ /dev/null @@ -1,70 +0,0 @@ - - */ -class DownloadLogFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var string - */ - protected $model = DownloadLog::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'document_id' => Document::factory(), - 'user_id' => User::factory(), - 'downloaded_at' => fake()->dateTimeBetween('-1 year', 'now'), - 'ip_address' => fake()->ipv4(), - ]; - } - - /** - * 指定下载日志使用特定的文档 - */ - public function forDocument(Document|int $document): static - { - $documentId = $document instanceof Document ? $document->id : $document; - - return $this->state(fn (array $attributes) => [ - 'document_id' => $documentId, - ]); - } - - /** - * 指定下载日志使用特定的用户 - */ - public function forUser(User|int $user): static - { - $userId = $user instanceof User ? $user->id : $user; - - return $this->state(fn (array $attributes) => [ - 'user_id' => $userId, - ]); - } - - /** - * 指定下载日志使用最近的时间 - */ - public function recent(): static - { - return $this->state(fn (array $attributes) => [ - 'downloaded_at' => fake()->dateTimeBetween('-7 days', 'now'), - ]); - } -} diff --git a/database/factories/GroupFactory.php b/database/factories/GroupFactory.php deleted file mode 100644 index d9c65d4..0000000 --- a/database/factories/GroupFactory.php +++ /dev/null @@ -1,49 +0,0 @@ - - */ -class GroupFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var string - */ - protected $model = Group::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - // 使用中文 Faker 生成器 - $faker = \Faker\Factory::create('zh_CN'); - - // 生成中文分组名称(使用公司名或部门名) - $groupNames = [ - '技术部', - '市场部', - '人力资源部', - '财务部', - '运营部', - '产品部', - '设计部', - '客服部', - '研发中心', - '销售部', - ]; - - return [ - 'name' => $faker->randomElement($groupNames) . ' - ' . $faker->company(), - 'description' => $faker->sentence(10), - ]; - } -} diff --git a/database/factories/StationFactory.php b/database/factories/StationFactory.php new file mode 100644 index 0000000..23298fe --- /dev/null +++ b/database/factories/StationFactory.php @@ -0,0 +1,22 @@ + + */ +class StationFactory extends Factory +{ + protected $model = Station::class; + + public function definition(): array + { + return [ + 'name' => 'BL' . fake()->unique()->numerify('##') . fake()->randomElement(['U', 'B', 'W', 'HB']), + 'description' => null, + ]; + } +} diff --git a/database/factories/TerminalFactory.php b/database/factories/TerminalFactory.php index 9a99f5c..eaa5258 100644 --- a/database/factories/TerminalFactory.php +++ b/database/factories/TerminalFactory.php @@ -28,7 +28,7 @@ class TerminalFactory extends Factory 'name' => fake()->randomElement(['生产线A', '生产线B', '生产线C', '质检站', '包装站']) . '-' . fake()->randomElement(['工位1', '工位2', '工位3']), 'code' => 'TERM-' . fake()->unique()->numerify('####'), 'ip_address' => fake()->localIpv4(), - 'station_id' => null, // 需要关联实际的线站ID + 'station_id' => null, 'diagram_url' => fake()->imageUrl(1920, 1080, 'diagram', true), 'voice_wakeup_enabled' => false, 'voice_wakeup_word' => null, diff --git a/database/factories/TerminalPromptFactory.php b/database/factories/TerminalPromptFactory.php deleted file mode 100644 index fc68f6b..0000000 --- a/database/factories/TerminalPromptFactory.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -class TerminalPromptFactory extends Factory -{ - protected $model = TerminalPrompt::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'terminal_id' => Terminal::factory(), - 'prompt_template' => '你是{station_id}光束线的AI助手。当前时间是{time}。请根据用户{user}的问题提供帮助。', - 'variables' => [], - ]; - } -} diff --git a/database/migrations/2025_12_03_071716_create_group_user_table.php b/database/migrations/2025_12_03_071716_create_group_user_table.php deleted file mode 100644 index 8845fa4..0000000 --- a/database/migrations/2025_12_03_071716_create_group_user_table.php +++ /dev/null @@ -1,32 +0,0 @@ -id(); - $table->foreignId('group_id')->constrained()->onDelete('cascade'); - $table->foreignId('user_id')->constrained()->onDelete('cascade'); - $table->timestamps(); - - // 添加唯一索引,确保同一用户不会重复加入同一分组 - $table->unique(['group_id', 'user_id']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('group_user'); - } -}; diff --git a/database/migrations/2026_03_09_022236_create_knowledge_bases_table.php b/database/migrations/2025_12_03_071900_create_knowledge_bases_table.php similarity index 100% rename from database/migrations/2026_03_09_022236_create_knowledge_bases_table.php rename to database/migrations/2025_12_03_071900_create_knowledge_bases_table.php diff --git a/database/migrations/2025_12_03_072004_create_documents_table.php b/database/migrations/2025_12_03_072004_create_documents_table.php index 58811c7..056c5e0 100644 --- a/database/migrations/2025_12_03_072004_create_documents_table.php +++ b/database/migrations/2025_12_03_072004_create_documents_table.php @@ -6,9 +6,6 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { Schema::create('documents', function (Blueprint $table) { @@ -19,21 +16,19 @@ return new class extends Migration $table->string('file_name'); $table->bigInteger('file_size'); $table->string('mime_type', 100); - $table->enum('type', ['global', 'dedicated']); - $table->foreignId('group_id')->nullable()->constrained()->onDelete('set null'); - $table->foreignId('uploaded_by')->constrained('users')->onDelete('cascade'); + $table->foreignId('knowledge_base_id')->constrained('knowledge_bases')->cascadeOnDelete(); + $table->foreignId('uploaded_by')->constrained('users')->cascadeOnDelete(); + $table->string('markdown_path', 500)->nullable(); + $table->enum('conversion_status', ['pending', 'processing', 'completed', 'failed'])->default('pending'); + $table->text('conversion_error')->nullable(); $table->timestamps(); - - // 添加索引以优化查询性能 - $table->index('type'); - $table->index('group_id'); + + $table->index('knowledge_base_id'); $table->index('uploaded_by'); + $table->index('conversion_status'); }); } - /** - * Reverse the migrations. - */ public function down(): void { Schema::dropIfExists('documents'); diff --git a/database/migrations/2025_12_04_040936_add_markdown_fields_to_documents_table.php b/database/migrations/2025_12_04_040936_add_markdown_fields_to_documents_table.php deleted file mode 100644 index 3e724ef..0000000 --- a/database/migrations/2025_12_04_040936_add_markdown_fields_to_documents_table.php +++ /dev/null @@ -1,52 +0,0 @@ -string('markdown_path', 500)->nullable()->after('description'); - - // 添加 Markdown 内容摘要字段(用于快速预览) - $table->text('markdown_preview')->nullable()->after('markdown_path'); - - // 添加转换状态字段 - $table->enum('conversion_status', ['pending', 'processing', 'completed', 'failed']) - ->default('pending') - ->after('markdown_preview'); - - // 添加转换错误信息字段 - $table->text('conversion_error')->nullable()->after('conversion_status'); - - // 添加索引以优化查询性能 - $table->index('conversion_status'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('documents', function (Blueprint $table) { - // 删除索引 - $table->dropIndex(['conversion_status']); - - // 删除字段 - $table->dropColumn([ - 'markdown_path', - 'markdown_preview', - 'conversion_status', - 'conversion_error' - ]); - }); - } -}; diff --git a/database/migrations/2026_03_02_014442_create_activity_log_table.php b/database/migrations/2026_03_02_014442_create_activity_log_table.php index 7c05bc8..d22f7b7 100644 --- a/database/migrations/2026_03_02_014442_create_activity_log_table.php +++ b/database/migrations/2026_03_02_014442_create_activity_log_table.php @@ -13,8 +13,10 @@ class CreateActivityLogTable extends Migration $table->string('log_name')->nullable(); $table->text('description'); $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); $table->nullableMorphs('causer', 'causer'); $table->json('properties')->nullable(); + $table->uuid('batch_uuid')->nullable(); $table->timestamps(); $table->index('log_name'); }); diff --git a/database/migrations/2026_03_02_014443_add_event_column_to_activity_log_table.php b/database/migrations/2026_03_02_014443_add_event_column_to_activity_log_table.php deleted file mode 100644 index 7b797fd..0000000 --- a/database/migrations/2026_03_02_014443_add_event_column_to_activity_log_table.php +++ /dev/null @@ -1,22 +0,0 @@ -table(config('activitylog.table_name'), function (Blueprint $table) { - $table->string('event')->nullable()->after('subject_type'); - }); - } - - public function down() - { - Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { - $table->dropColumn('event'); - }); - } -} diff --git a/database/migrations/2026_03_02_014444_add_batch_uuid_column_to_activity_log_table.php b/database/migrations/2026_03_02_014444_add_batch_uuid_column_to_activity_log_table.php deleted file mode 100644 index 8f7db66..0000000 --- a/database/migrations/2026_03_02_014444_add_batch_uuid_column_to_activity_log_table.php +++ /dev/null @@ -1,22 +0,0 @@ -table(config('activitylog.table_name'), function (Blueprint $table) { - $table->uuid('batch_uuid')->nullable()->after('properties'); - }); - } - - public function down() - { - Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { - $table->dropColumn('batch_uuid'); - }); - } -} diff --git a/database/migrations/2025_12_03_071443_create_groups_table.php b/database/migrations/2026_03_02_014623_create_stations_table.php similarity index 62% rename from database/migrations/2025_12_03_071443_create_groups_table.php rename to database/migrations/2026_03_02_014623_create_stations_table.php index 1cc652d..2debb8d 100644 --- a/database/migrations/2025_12_03_071443_create_groups_table.php +++ b/database/migrations/2026_03_02_014623_create_stations_table.php @@ -6,24 +6,18 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { - Schema::create('groups', function (Blueprint $table) { + Schema::create('stations', function (Blueprint $table) { $table->id(); - $table->string('name'); + $table->string('name')->unique(); $table->text('description')->nullable(); $table->timestamps(); }); } - /** - * Reverse the migrations. - */ public function down(): void { - Schema::dropIfExists('groups'); + Schema::dropIfExists('stations'); } }; diff --git a/database/migrations/2026_03_02_014623_create_terminals_table.php b/database/migrations/2026_03_02_014624_create_terminals_table.php similarity index 69% rename from database/migrations/2026_03_02_014623_create_terminals_table.php rename to database/migrations/2026_03_02_014624_create_terminals_table.php index a4b37b1..4d98847 100644 --- a/database/migrations/2026_03_02_014623_create_terminals_table.php +++ b/database/migrations/2026_03_02_014624_create_terminals_table.php @@ -6,9 +6,6 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { Schema::create('terminals', function (Blueprint $table) { @@ -16,27 +13,22 @@ return new class extends Migration $table->string('name')->comment('终端名称'); $table->string('code', 100)->unique()->comment('终端编码'); $table->string('ip_address', 45)->nullable()->comment('IP地址'); - $table->string('mac_address', 17)->nullable()->unique()->comment('MAC地址 (AA:BB:CC:DD:EE:FF)'); - $table->string('station_id', 50)->nullable()->comment('线站ID'); + $table->string('mac_address', 17)->nullable()->unique()->comment('MAC地址'); + $table->foreignId('station_id')->nullable()->constrained()->nullOnDelete(); $table->string('diagram_url', 500)->nullable()->comment('组态界面地址'); $table->string('scada_data_url', 500)->nullable()->comment('网关数据查询地址'); $table->string('scada_tags_url', 500)->nullable()->comment('网关点位定义查询地址'); - $table->boolean('voice_wakeup_enabled')->default(false)->comment('语音唤醒是否启用'); - $table->string('voice_wakeup_word', 100)->nullable()->comment('语音唤醒词'); - $table->boolean('is_online')->default(false)->comment('在线状态'); - $table->timestamp('last_online_at')->nullable()->comment('最后在线时间'); + $table->boolean('voice_wakeup_enabled')->default(false); + $table->string('voice_wakeup_word', 100)->nullable(); + $table->boolean('is_online')->default(false); + $table->timestamp('last_online_at')->nullable(); $table->timestamps(); $table->softDeletes(); - // 添加索引 - $table->index('station_id', 'idx_station'); $table->index('is_online', 'idx_online'); }); } - /** - * Reverse the migrations. - */ public function down(): void { Schema::dropIfExists('terminals'); diff --git a/database/migrations/2026_03_02_014625_create_station_user_table.php b/database/migrations/2026_03_02_014625_create_station_user_table.php new file mode 100644 index 0000000..1d52517 --- /dev/null +++ b/database/migrations/2026_03_02_014625_create_station_user_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('station_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['station_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('station_user'); + } +}; diff --git a/database/migrations/2026_03_02_015103_create_terminal_knowledge_bases_table.php b/database/migrations/2026_03_02_015103_create_terminal_knowledge_bases_table.php deleted file mode 100644 index 6eff3b5..0000000 --- a/database/migrations/2026_03_02_015103_create_terminal_knowledge_bases_table.php +++ /dev/null @@ -1,39 +0,0 @@ -id(); - $table->unsignedBigInteger('terminal_id')->comment('终端ID'); - $table->unsignedBigInteger('knowledge_base_id')->comment('知识库ID'); - $table->integer('priority')->default(0)->comment('优先级'); - $table->timestamps(); - - // 添加外键约束 - $table->foreign('terminal_id') - ->references('id') - ->on('terminals') - ->onDelete('cascade'); - - // 添加唯一索引,确保同一终端不会重复关联同一知识库 - $table->unique(['terminal_id', 'knowledge_base_id'], 'uk_terminal_kb'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('terminal_knowledge_bases'); - } -}; diff --git a/database/migrations/2026_03_09_022236_create_knowledge_base_station_table.php b/database/migrations/2026_03_09_022236_create_knowledge_base_station_table.php new file mode 100644 index 0000000..5e41450 --- /dev/null +++ b/database/migrations/2026_03_09_022236_create_knowledge_base_station_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('station_id')->constrained()->cascadeOnDelete(); + $table->foreignId('knowledge_base_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['station_id', 'knowledge_base_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('knowledge_base_station'); + } +}; diff --git a/database/migrations/2026_03_12_051332_update_permissions_naming.php b/database/migrations/2026_03_12_051332_update_permissions_naming.php deleted file mode 100644 index fa7bf8e..0000000 --- a/database/migrations/2026_03_12_051332_update_permissions_naming.php +++ /dev/null @@ -1,109 +0,0 @@ - 新名称) - $permissionMapping = [ - // 文档管理 - 'document.viewAny' => 'document.view', - - // 系统设置 - 'system-setting.viewAny' => 'system-setting.view', - - // 操作日志 - 'activity-log.viewAny' => 'activity-log.view', - - // 终端管理 - 'terminal.viewAny' => 'terminal.view', - - // 操作指引 - 'guide.viewAny' => 'guide.view', - - // 分组管理 - 'group.viewAny' => 'group.view', - - // 用户管理 - 'user.viewAny' => 'user.view', - - // 角色管理 - 'role.viewAny' => 'role.view', - ]; - - foreach ($permissionMapping as $oldName => $newName) { - $oldPermission = Permission::where('name', $oldName)->first(); - - if ($oldPermission) { - // 检查新权限是否已存在 - $newPermission = Permission::where('name', $newName)->first(); - - if (!$newPermission) { - // 如果新权限不存在,直接重命名 - $oldPermission->name = $newName; - $oldPermission->save(); - } else { - // 如果新权限已存在,需要合并权限关联 - // 将旧权限的所有角色关联转移到新权限 - $roles = $oldPermission->roles; - foreach ($roles as $role) { - if (!$role->hasPermissionTo($newName)) { - $role->givePermissionTo($newName); - } - } - - // 将旧权限的所有用户关联转移到新权限 - $users = $oldPermission->users; - foreach ($users as $user) { - if (!$user->hasPermissionTo($newName)) { - $user->givePermissionTo($newName); - } - } - - // 删除旧权限 - $oldPermission->delete(); - } - } - } - - // 删除不再需要的详情查看权限 - $detailPermissions = [ - 'document.view', - 'system-setting.view', - 'activity-log.view', - 'terminal.view', - 'guide.view', - 'group.view', - 'user.view', - 'role.view', - ]; - - foreach ($detailPermissions as $permName) { - // 只删除描述为"查看xxx详情"的权限(如果存在重复) - $permissions = Permission::where('name', $permName)->get(); - if ($permissions->count() > 1) { - // 保留第一个,删除其他 - $permissions->skip(1)->each(function ($permission) { - $permission->delete(); - }); - } - } - - // 清除权限缓存 - app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - // 不支持回滚,因为权限合并后无法准确还原 - } -}; diff --git a/database/migrations/2026_03_13_010100_create_guides_table.php b/database/migrations/2026_03_13_010100_create_guides_table.php index 1b5402b..5d02195 100644 --- a/database/migrations/2026_03_13_010100_create_guides_table.php +++ b/database/migrations/2026_03_13_010100_create_guides_table.php @@ -40,20 +40,19 @@ return new class extends Migration $table->index(['guide_id', 'sort_order']); }); - Schema::create('terminal_guides', function (Blueprint $table) { + Schema::create('guide_station', function (Blueprint $table) { $table->id(); - $table->foreignId('terminal_id')->constrained()->cascadeOnDelete(); + $table->foreignId('station_id')->constrained()->cascadeOnDelete(); $table->foreignId('guide_id')->constrained()->cascadeOnDelete(); - $table->integer('priority')->default(0); $table->timestamps(); - $table->unique(['terminal_id', 'guide_id'], 'uk_terminal_guide'); + $table->unique(['station_id', 'guide_id']); }); } public function down(): void { - Schema::dropIfExists('terminal_guides'); + Schema::dropIfExists('guide_station'); Schema::dropIfExists('guide_pages'); Schema::dropIfExists('guides'); } diff --git a/database/migrations/2026_03_13_060000_add_knowledge_base_id_to_documents_table.php b/database/migrations/2026_03_13_060000_add_knowledge_base_id_to_documents_table.php deleted file mode 100644 index a37d9a6..0000000 --- a/database/migrations/2026_03_13_060000_add_knowledge_base_id_to_documents_table.php +++ /dev/null @@ -1,29 +0,0 @@ -foreignId('knowledge_base_id') - ->nullable() - ->after('group_id') - ->constrained('knowledge_bases') - ->nullOnDelete(); - - $table->index('knowledge_base_id'); - }); - } - - public function down(): void - { - Schema::table('documents', function (Blueprint $table) { - $table->dropForeign(['knowledge_base_id']); - $table->dropColumn('knowledge_base_id'); - }); - } -}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 12b24d1..9a7d9f8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,8 +2,7 @@ namespace Database\Seeders; -use App\Models\Document; -use App\Models\Group; +use App\Models\Station; use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -13,231 +12,79 @@ class DatabaseSeeder extends Seeder { use WithoutModelEvents; - /** - * Seed the application's database. - */ public function run(): void { $this->command->info('开始生成演示数据...'); - // 0. 创建权限和角色(必须最先执行) + // 0. 权限和角色 $this->call(PermissionSeeder::class); - // 1. 创建系统设置 + // 1. 系统设置 $this->call(SystemSettingSeeder::class); - // 2. 创建管理员用户 - $this->command->info('创建管理员用户...'); + // 2. 创建用户 + + $pass = 'TRG}E^5BvPcbyErc'; + + $this->command->info('创建用户...'); $admin = User::factory()->create([ 'name' => '系统管理员', 'email' => 'admin@example.com', - 'password' => Hash::make('TRG}E^5BvPcbyErc'), + 'password' => Hash::make($pass), ]); - - // 为管理员分配 super-admin 角色 $admin->assignRole('super-admin'); - // 3. 创建普通用户 - $this->command->info('创建普通用户...'); $user1 = User::factory()->create([ 'name' => '张三', 'email' => 'zhangsan@example.com', - 'password' => Hash::make('TRG}E^5BvPcbyErc'), + 'password' => Hash::make($pass), ]); $user1->assignRole('user'); $user2 = User::factory()->create([ 'name' => '李四', 'email' => 'lisi@example.com', - 'password' => Hash::make('TRG}E^5BvPcbyErc'), + 'password' => Hash::make($pass), ]); $user2->assignRole('user'); $user3 = User::factory()->create([ 'name' => '王五', 'email' => 'wangwu@example.com', - 'password' => Hash::make('TRG}E^5BvPcbyErc'), + 'password' => Hash::make($pass), ]); $user3->assignRole('admin'); $user4 = User::factory()->create([ 'name' => '赵六', 'email' => 'zhaoliu@example.com', - 'password' => Hash::make('TRG}E^5BvPcbyErc'), + 'password' => Hash::make($pass), ]); $user4->assignRole('user'); - // 3. 创建分组(按照光束线) - $this->command->info('创建光束线分组...'); - $beamlines = [ - 'BL02U1', - 'BL07U', - 'BL08U', - 'BL13HB', - 'BL13U', - 'BL14B', - 'BL14W', - 'BL15U', - 'BL16B', - 'BL16U1', - ]; - - $groups = []; - foreach ($beamlines as $beamline) { - $groups[$beamline] = Group::factory()->create([ - 'name' => $beamline, - 'description' => null, - ]); - } - - // 4. 建立用户和分组的关联关系 - $this->command->info('建立用户和分组的关联关系...'); - // 管理员属于所有光束线分组 - $admin->groups()->attach(array_column($groups, 'id')); - - // 张三和李四属于 BL02U1 - $user1->groups()->attach($groups['BL02U1']->id); - $user2->groups()->attach($groups['BL02U1']->id); - - // 王五属于 BL07U - $user3->groups()->attach($groups['BL07U']->id); - - // 赵六属于 BL08U - $user4->groups()->attach($groups['BL08U']->id); - - // 5. 创建全局文档(仅元数据,不创建实际文件) - $this->command->info('创建全局文档...'); - Document::create([ - 'title' => '公司员工手册', - 'description' => '包含公司规章制度、员工福利、考勤制度等重要信息', - 'file_path' => 'documents/' . date('Y/m/d') . '/' . fake()->uuid() . '.docx', - 'file_name' => '员工手册_2024.docx', - 'file_size' => fake()->numberBetween(100000, 500000), - 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'type' => 'global', - 'group_id' => null, - 'uploaded_by' => $admin->id, - 'conversion_status' => 'pending', - ]); - - Document::create([ - 'title' => '办公室使用规范', - 'description' => '办公室设施使用规范和注意事项', - 'file_path' => 'documents/' . date('Y/m/d') . '/' . fake()->uuid() . '.docx', - 'file_name' => '办公室规范.docx', - 'file_size' => fake()->numberBetween(100000, 500000), - 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'type' => 'global', - 'group_id' => null, - 'uploaded_by' => $admin->id, - 'conversion_status' => 'pending', - ]); - - Document::create([ - 'title' => '公司年度总结报告', - 'description' => '2024年度公司发展总结和2025年规划', - 'file_path' => 'documents/' . date('Y/m/d') . '/' . fake()->uuid() . '.docx', - 'file_name' => '年度总结_2024.docx', - 'file_size' => fake()->numberBetween(100000, 500000), - 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'type' => 'global', - 'group_id' => null, - 'uploaded_by' => $admin->id, - 'conversion_status' => 'pending', - ]); - - Document::create([ - 'title' => '安全管理制度', - 'description' => '公司信息安全和物理安全管理制度', - 'file_path' => 'documents/' . date('Y/m/d') . '/' . fake()->uuid() . '.docx', - 'file_name' => '安全管理制度.docx', - 'file_size' => fake()->numberBetween(100000, 500000), - 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'type' => 'global', - 'group_id' => null, - 'uploaded_by' => $admin->id, - 'conversion_status' => 'pending', - ]); - - // 6. 创建 BL02U1 光束线专用文档 - $this->command->info('创建 BL02U1 光束线专用文档...'); - Document::create([ - 'title' => 'BL02U1 操作手册', - 'description' => 'BL02U1 光束线设备操作规程和注意事项', - 'file_path' => 'documents/' . date('Y/m/d') . '/' . fake()->uuid() . '.docx', - 'file_name' => 'BL02U1_操作手册.docx', - 'file_size' => fake()->numberBetween(100000, 500000), - 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'type' => 'dedicated', - 'group_id' => $groups['BL02U1']->id, - 'uploaded_by' => $user1->id, - 'conversion_status' => 'pending', - ]); - - Document::create([ - 'title' => 'BL02U1 维护记录', - 'description' => 'BL02U1 光束线设备维护和检修记录', - 'file_path' => 'documents/' . date('Y/m/d') . '/' . fake()->uuid() . '.docx', - 'file_name' => 'BL02U1_维护记录.docx', - 'file_size' => fake()->numberBetween(100000, 500000), - 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'type' => 'dedicated', - 'group_id' => $groups['BL02U1']->id, - 'uploaded_by' => $user2->id, - 'conversion_status' => 'pending', - ]); - - // 7. 创建 BL07U 光束线专用文档 - $this->command->info('创建 BL07U 光束线专用文档...'); - Document::create([ - 'title' => 'BL07U 操作手册', - 'description' => 'BL07U 光束线设备操作规程和注意事项', - 'file_path' => 'documents/' . date('Y/m/d') . '/' . fake()->uuid() . '.docx', - 'file_name' => 'BL07U_操作手册.docx', - 'file_size' => fake()->numberBetween(100000, 500000), - 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'type' => 'dedicated', - 'group_id' => $groups['BL07U']->id, - 'uploaded_by' => $user3->id, - 'conversion_status' => 'pending', - ]); - - // 8. 创建 BL08U 光束线专用文档 - $this->command->info('创建 BL08U 光束线专用文档...'); - Document::create([ - 'title' => 'BL08U 操作手册', - 'description' => 'BL08U 光束线设备操作规程和注意事项', - 'file_path' => 'documents/' . date('Y/m/d') . '/' . fake()->uuid() . '.docx', - 'file_name' => 'BL08U_操作手册.docx', - 'file_size' => fake()->numberBetween(100000, 500000), - 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'type' => 'dedicated', - 'group_id' => $groups['BL08U']->id, - 'uploaded_by' => $user4->id, - 'conversion_status' => 'pending', - ]); - - $this->command->info('演示数据生成完成!'); - - // 9. 创建终端数据 + // 3. 创建线站、知识库、终端 $this->call(TerminalSeeder::class); - // 10. 创建操作指引数据 + // 4. 用户关联线站(管理员不关联 = 可访问全部) + $this->command->info('建立用户-线站关联...'); + $bl02u1 = Station::where('name', 'BL02U1')->first(); + $bl07u = Station::where('name', 'BL07U')->first(); + $bl08u = Station::where('name', 'BL08U')->first(); + + $user1->stations()->attach($bl02u1); + $user2->stations()->attach($bl02u1); + $user3->stations()->attach($bl07u); + $user4->stations()->attach($bl08u); + + // 5. 操作指引 $this->call(GuideSeeder::class); - $this->command->newLine(); - $this->command->info('=== 生成的数据摘要 ==='); - $this->command->info('用户数量: ' . User::count()); - $this->command->info('分组数量: ' . Group::count()); - $this->command->info('文档数量: ' . Document::count()); - $this->command->info(' - 全局文档: ' . Document::global()->count()); - $this->command->info(' - 专用文档: ' . Document::dedicated()->count()); $this->command->newLine(); $this->command->info('=== 测试账号信息 ==='); - $this->command->info('超级管理员: admin@example.com / TRG}E^5BvPcbyErc (super-admin)'); - $this->command->info('张三(BL02U1): zhangsan@example.com / TRG}E^5BvPcbyErc (user)'); - $this->command->info('李四(BL02U1): lisi@example.com / TRG}E^5BvPcbyErc (user)'); - $this->command->info('王五(BL07U): wangwu@example.com / TRG}E^5BvPcbyErc (admin)'); - $this->command->info('赵六(BL08U): zhaoliu@example.com / TRG}E^5BvPcbyErc (user)'); + $this->command->info("{$admin->name} : {$admin->email} / $pass"); + $this->command->info("{$user1->name}({$bl02u1->name}): {$user1->email} / $pass"); + $this->command->info("{$user2->name}({$bl02u1->name}): {$user2->email} / $pass"); + $this->command->info("{$user3->name}({$bl07u->name}): {$user3->email} / $pass"); + $this->command->info("{$user4->name}({$bl08u->name}): {$user4->email} / $pass"); } } diff --git a/database/seeders/GuideSeeder.php b/database/seeders/GuideSeeder.php index 636094b..144fe0e 100644 --- a/database/seeders/GuideSeeder.php +++ b/database/seeders/GuideSeeder.php @@ -4,7 +4,7 @@ namespace Database\Seeders; use App\Models\Guide; use App\Models\GuidePage; -use App\Models\Terminal; +use App\Models\Station; use App\Models\User; use Illuminate\Database\Seeder; @@ -20,7 +20,7 @@ class GuideSeeder extends Seeder $this->command->info('开始创建操作指引数据...'); $admin = User::where('email', 'admin@example.com')->first(); - $terminals = Terminal::all(); + $stations = Station::all(); // 1. 如何用光(带分支) $guide1 = $this->createHowToUseBeamGuide($admin); @@ -31,20 +31,17 @@ class GuideSeeder extends Seeder // 3. 漏水报警处理 $guide3 = $this->createWaterLeakAlarmGuide($admin); - // 将所有指引关联到所有终端 - $this->command->info('关联指引到所有终端...'); - foreach ($terminals as $terminal) { - $terminal->guides()->attach([ - $guide1->id => ['priority' => 1], - $guide2->id => ['priority' => 2], - $guide3->id => ['priority' => 3], - ]); - } + // 将指引关联到所有线站(非全局,关联所有线站 = 各线站均可见) + $this->command->info('关联指引到所有线站...'); + $stationIds = $stations->pluck('id')->toArray(); + $guide1->stations()->attach($stationIds); + $guide2->stations()->attach($stationIds); + $guide3->stations()->attach($stationIds); $this->command->info('操作指引数据创建完成!'); $this->command->info(' - 指引数量: ' . Guide::count()); $this->command->info(' - 指引页面数量: ' . GuidePage::count()); - $this->command->info(' - 关联终端数量: ' . $terminals->count()); + $this->command->info(' - 关联线站数量: ' . $stations->count()); } private function createHowToUseBeamGuide(User $admin): Guide diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index ea09395..bd6b011 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -46,11 +46,11 @@ class PermissionSeeder extends Seeder 'guide.publish' => '发布指引', 'guide.archive' => '归档指引', - // 分组管理权限 - 'group.view' => '查看分组', - 'group.create' => '创建分组', - 'group.update' => '编辑分组', - 'group.delete' => '删除分组', + // 线站管理权限 + 'station.view' => '查看线站', + 'station.create' => '创建线站', + 'station.update' => '编辑线站', + 'station.delete' => '删除线站', // 用户管理权限 'user.view' => '查看用户', @@ -126,6 +126,12 @@ class PermissionSeeder extends Seeder 'terminal.update', 'terminal.delete', + // 线站管理 + 'station.view', + 'station.create', + 'station.update', + 'station.delete', + // 操作指引 'guide.view', 'guide.create', @@ -134,12 +140,6 @@ class PermissionSeeder extends Seeder 'guide.publish', 'guide.archive', - // 分组管理 - 'group.view', - 'group.create', - 'group.update', - 'group.delete', - // 用户管理 'user.view', 'user.create', @@ -167,14 +167,14 @@ class PermissionSeeder extends Seeder 'document.create', 'document.download', - // 终端管理(仅查看) + // 终端查看 'terminal.view', - // 操作指引(仅查看) - 'guide.view', + // 线站查看 + 'station.view', - // 分组管理(仅查看) - 'group.view', + // 操作指引查看 + 'guide.view', ]; $role->givePermissionTo($permissions); diff --git a/database/seeders/TerminalSeeder.php b/database/seeders/TerminalSeeder.php index 0f7d3cd..946fef7 100644 --- a/database/seeders/TerminalSeeder.php +++ b/database/seeders/TerminalSeeder.php @@ -2,20 +2,18 @@ namespace Database\Seeders; +use App\Models\KnowledgeBase; +use App\Models\Station; use App\Models\Terminal; use App\Models\TerminalPrompt; use Illuminate\Database\Seeder; class TerminalSeeder extends Seeder { - /** - * Run the database seeds. - */ public function run(): void { - $this->command->info('开始创建终端数据...'); + $this->command->info('开始创建线站、知识库和终端...'); - // 光束线列表 $beamlines = [ 'BL02U1' => '192.168.1.21', 'BL07U' => '192.168.1.27', @@ -29,19 +27,32 @@ class TerminalSeeder extends Seeder 'BL16U1' => '192.168.1.39', ]; - // 默认提示词模板(占位符由HMI端替换) $defaultPrompt = <<<'PROMPT' -你是{station_id}光束线的AI助手。当前时间是{time}。请根据用户{user}的问题,提供准确的光束线操作指导、实验支持和技术咨询。你可以回答关于光束线参数、实验流程、设备状态、安全规范等方面的问题。 +你是{station_name}光束线的AI助手(终端: {terminal_name} / {terminal_code})。当前时间是{time}。请根据用户{user}的问题,提供准确的光束线操作指导、实验支持和技术咨询。你可以回答关于光束线参数、实验流程、设备状态、安全规范等方面的问题。 PROMPT; - // 为每条光束线创建智慧屏终端 - $this->command->info('创建光束线智慧屏终端...'); + // 创建通用知识库(全局,不关联线站) + $this->command->info('创建通用知识库...'); + KnowledgeBase::create(['name' => '通用知识库', 'description' => '全站通用的规章制度和管理文档', 'status' => 'active']); + foreach ($beamlines as $beamline => $ipAddress) { + // 创建线站 + $station = Station::firstOrCreate(['name' => $beamline]); + + // 创建线站专属知识库并关联 + $kb = KnowledgeBase::create([ + 'name' => "{$beamline} 知识库", + 'description' => "{$beamline} 光束线专用文档", + 'status' => 'active', + ]); + $station->knowledgeBases()->attach($kb); + + // 创建终端 $terminal = Terminal::create([ 'name' => "{$beamline} 智慧屏", 'code' => "SCREEN-{$beamline}", 'ip_address' => $ipAddress, - 'station_id' => $beamline, + 'station_id' => $station->id, 'diagram_url' => 'https://ssrf.9z.work/scada/demo.html', 'is_online' => in_array($beamline, ['BL02U1', 'BL07U', 'BL08U', 'BL13U', 'BL15U']), 'last_online_at' => in_array($beamline, ['BL02U1', 'BL07U', 'BL08U', 'BL13U', 'BL15U']) @@ -49,7 +60,6 @@ PROMPT; : now()->subHours(rand(1, 24)), ]); - // 为每个终端创建提示词 TerminalPrompt::create([ 'terminal_id' => $terminal->id, 'prompt_template' => $defaultPrompt, @@ -57,12 +67,9 @@ PROMPT; ]); } - $this->command->info('终端数据创建完成!'); - $this->command->newLine(); - $this->command->info('=== 生成的终端摘要 ==='); - $this->command->info('总终端数量: ' . Terminal::count()); - $this->command->info(' - 在线终端: ' . Terminal::where('is_online', true)->count()); - $this->command->info(' - 离线终端: ' . Terminal::where('is_online', false)->count()); - $this->command->info(' - 配置提示词的终端: ' . TerminalPrompt::count()); + $this->command->info('线站/知识库/终端创建完成!'); + $this->command->info(' - 线站: ' . Station::count()); + $this->command->info(' - 知识库: ' . KnowledgeBase::count()); + $this->command->info(' - 终端: ' . Terminal::count()); } } diff --git a/resources/views/filament/components/prompt-variable-helper.blade.php b/resources/views/filament/components/prompt-variable-helper.blade.php deleted file mode 100644 index 44889ad..0000000 --- a/resources/views/filament/components/prompt-variable-helper.blade.php +++ /dev/null @@ -1,46 +0,0 @@ -
-

- 可用占位符 -

- -
- 以下占位符由 HMI端 在运行时替换 -
- - @php - $variables = config('prompt_variables.variables', []); - @endphp - -
- @foreach($variables as $variable) -
- - {{'{'}}{{ $variable['name'] }}{{'}'}} - -
-
- {{ $variable['label'] }} -
-
- {{ $variable['description'] }} -
-
- 示例: {{ $variable['example'] }} -
-
- 来源: {{ $variable['source'] }} -
-
-
- @endforeach -
- -
-

使用示例

-
-
你是{{'{'}}station_id{{'}'}}光束线的AI助手。
-
当前时间是 {{'{'}}time{{'}'}}。
-
请根据用户{{'{'}}user{{'}'}}的问题提供帮助。
-
-
-
diff --git a/tests/Feature/DocumentAccessScopePropertyTest.php b/tests/Feature/DocumentAccessScopePropertyTest.php deleted file mode 100644 index 99f0f10..0000000 --- a/tests/Feature/DocumentAccessScopePropertyTest.php +++ /dev/null @@ -1,138 +0,0 @@ -count($groupCount)->create(); - - // 创建一个测试用户 - $user = User::factory()->create(); - - // 随机将用户分配到一些分组(0-3个) - $userGroupCount = rand(0, min(3, $groupCount)); - $userGroups = $groups->random(min($userGroupCount, $groupCount)); - $user->groups()->attach($userGroups->pluck('id')); - - // 创建随机数量的全局文档(1-10个) - $globalDocCount = rand(1, 10); - $globalDocs = Document::factory()->count($globalDocCount)->global()->create(); - - // 为每个分组创建随机数量的专用文档(0-5个) - $dedicatedDocs = collect(); - foreach ($groups as $group) { - $docCount = rand(0, 5); - $docs = Document::factory()->count($docCount)->dedicated($group->id)->create(); - $dedicatedDocs = $dedicatedDocs->merge($docs); - } - - // 执行查询:获取用户可访问的文档 - $accessibleDocs = Document::accessibleBy($user)->get(); - - // 验证:所有全局文档都应该在结果中 - foreach ($globalDocs as $globalDoc) { - expect($accessibleDocs->contains('id', $globalDoc->id)) - ->toBeTrue("全局文档 {$globalDoc->id} 应该对用户可见"); - } - - // 验证:用户所属分组的专用文档都应该在结果中 - $userGroupIds = $userGroups->pluck('id')->toArray(); - $userDedicatedDocs = $dedicatedDocs->whereIn('group_id', $userGroupIds); - foreach ($userDedicatedDocs as $doc) { - expect($accessibleDocs->contains('id', $doc->id)) - ->toBeTrue("用户分组的专用文档 {$doc->id} 应该对用户可见"); - } - - // 验证:其他分组的专用文档不应该在结果中 - $otherGroupDocs = $dedicatedDocs->whereNotIn('group_id', $userGroupIds); - foreach ($otherGroupDocs as $doc) { - expect($accessibleDocs->contains('id', $doc->id)) - ->toBeFalse("其他分组的专用文档 {$doc->id} 不应该对用户可见"); - } - - // 验证:结果数量应该等于全局文档数 + 用户分组的专用文档数 - $expectedCount = $globalDocCount + $userDedicatedDocs->count(); - expect($accessibleDocs->count()) - ->toBe($expectedCount, "可访问文档数量应该是 {$expectedCount}"); - - // 清理数据以便下一次迭代 - Document::query()->delete(); - User::query()->delete(); - Group::query()->delete(); - } -})->group('property-based-test'); - -/** - * Feature: knowledge-base-system, Property 8: 其他分组文档隔离 - * - * 对于任何用户和任何不属于该用户分组的专用知识库文档, - * 该文档不应该出现在用户的可访问文档列表中 - * - * Validates: Requirements 3.4 - */ -test('property 8: 其他分组的专用文档应该被隔离,不出现在用户的可访问列表中', function () { - // 运行 100 次迭代 - for ($i = 0; $i < 100; $i++) { - // 创建至少 2 个分组 - $groupCount = rand(2, 5); - $groups = Group::factory()->count($groupCount)->create(); - - // 创建一个测试用户 - $user = User::factory()->create(); - - // 将用户分配到部分分组(至少留一个分组不分配) - $userGroupCount = rand(1, $groupCount - 1); - $userGroups = $groups->random($userGroupCount); - $user->groups()->attach($userGroups->pluck('id')); - - // 获取用户不属于的分组 - $otherGroups = $groups->diff($userGroups); - - // 为其他分组创建专用文档 - $otherGroupDocs = collect(); - foreach ($otherGroups as $group) { - $docCount = rand(1, 5); - $docs = Document::factory()->count($docCount)->dedicated($group->id)->create(); - $otherGroupDocs = $otherGroupDocs->merge($docs); - } - - // 执行查询:获取用户可访问的文档 - $accessibleDocs = Document::accessibleBy($user)->get(); - - // 验证:其他分组的所有专用文档都不应该在结果中 - foreach ($otherGroupDocs as $doc) { - expect($accessibleDocs->contains('id', $doc->id)) - ->toBeFalse("其他分组的专用文档 {$doc->id}(分组 {$doc->group_id})不应该对用户可见"); - } - - // 额外验证:确保结果中没有任何其他分组的文档 - $otherGroupIds = $otherGroups->pluck('id')->toArray(); - $leakedDocs = $accessibleDocs->filter(function ($doc) use ($otherGroupIds) { - return $doc->type === 'dedicated' && in_array($doc->group_id, $otherGroupIds); - }); - - expect($leakedDocs->count()) - ->toBe(0, "不应该有任何其他分组的专用文档泄露到用户的可访问列表中"); - - // 清理数据以便下一次迭代 - Document::query()->delete(); - User::query()->delete(); - Group::query()->delete(); - } -})->group('property-based-test'); diff --git a/tests/Feature/DocumentPolicyTest.php b/tests/Feature/DocumentPolicyTest.php index 364d6b3..ba95369 100644 --- a/tests/Feature/DocumentPolicyTest.php +++ b/tests/Feature/DocumentPolicyTest.php @@ -1,81 +1,40 @@ create(); - + expect($user->can('viewAny', Document::class))->toBeTrue(); }); - test('view 允许所有用户查看全局文档', function () { + test('view 允许有权限的用户查看文档', function () { $user = User::factory()->create(); - $document = Document::factory()->create([ - 'type' => 'global', - 'group_id' => null, - ]); - + $document = Document::factory()->create(); + expect($user->can('view', $document))->toBeTrue(); }); - test('view 允许分组成员查看该分组的专用文档', function () { - $group = Group::factory()->create(); + test('create 允许有权限的用户创建文档', function () { $user = User::factory()->create(); - $user->groups()->attach($group); - - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group->id, - ]); - - expect($user->can('view', $document))->toBeTrue(); - }); - test('view 拒绝非分组成员查看专用文档', function () { - $group = Group::factory()->create(); - $user = User::factory()->create(); - // 用户不属于该分组 - - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group->id, - ]); - - expect($user->can('view', $document))->toBeFalse(); - }); - - test('view 拒绝访问没有分组的专用文档', function () { - $user = User::factory()->create(); - - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => null, - ]); - - expect($user->can('view', $document))->toBeFalse(); - }); - - test('create 允许所有已认证用户创建文档', function () { - $user = User::factory()->create(); - expect($user->can('create', Document::class))->toBeTrue(); }); test('update 只允许文档上传者更新文档', function () { $uploader = User::factory()->create(); $otherUser = User::factory()->create(); - + $document = Document::factory()->create([ 'uploaded_by' => $uploader->id, ]); - + expect($uploader->can('update', $document))->toBeTrue(); expect($otherUser->can('update', $document))->toBeFalse(); }); @@ -83,92 +42,19 @@ describe('DocumentPolicy', function () { test('delete 只允许文档上传者删除文档', function () { $uploader = User::factory()->create(); $otherUser = User::factory()->create(); - + $document = Document::factory()->create([ 'uploaded_by' => $uploader->id, ]); - + expect($uploader->can('delete', $document))->toBeTrue(); expect($otherUser->can('delete', $document))->toBeFalse(); }); - test('download 允许所有用户下载全局文档', function () { + test('download 允许有权限的用户下载文档', function () { $user = User::factory()->create(); - $document = Document::factory()->create([ - 'type' => 'global', - 'group_id' => null, - ]); - + $document = Document::factory()->create(); + expect($user->can('download', $document))->toBeTrue(); }); - - test('download 允许分组成员下载该分组的专用文档', function () { - $group = Group::factory()->create(); - $user = User::factory()->create(); - $user->groups()->attach($group); - - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group->id, - ]); - - expect($user->can('download', $document))->toBeTrue(); - }); - - test('download 拒绝非分组成员下载专用文档', function () { - $group = Group::factory()->create(); - $user = User::factory()->create(); - // 用户不属于该分组 - - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group->id, - ]); - - expect($user->can('download', $document))->toBeFalse(); - }); - - test('用户从分组移除后失去访问权限', function () { - $group = Group::factory()->create(); - $user = User::factory()->create(); - $user->groups()->attach($group); - - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group->id, - ]); - - // 用户在分组中时可以访问 - expect($user->can('view', $document))->toBeTrue(); - - // 从分组中移除用户 - $user->groups()->detach($group); - - // 刷新用户关系 - $user->refresh(); - - // 用户不再能访问该文档 - expect($user->can('view', $document))->toBeFalse(); - }); - - test('用户属于多个分组时可以访问所有分组的专用文档', function () { - $group1 = Group::factory()->create(); - $group2 = Group::factory()->create(); - $user = User::factory()->create(); - - $user->groups()->attach([$group1->id, $group2->id]); - - $document1 = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group1->id, - ]); - - $document2 = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group2->id, - ]); - - expect($user->can('view', $document1))->toBeTrue(); - expect($user->can('view', $document2))->toBeTrue(); - }); }); diff --git a/tests/Feature/DocumentPreviewServiceTest.php b/tests/Feature/DocumentPreviewServiceTest.php index 27301d8..fcb453e 100644 --- a/tests/Feature/DocumentPreviewServiceTest.php +++ b/tests/Feature/DocumentPreviewServiceTest.php @@ -3,7 +3,6 @@ namespace Tests\Feature; use App\Models\Document; -use App\Models\Group; use App\Models\User; use App\Services\DocumentPreviewService; use Illuminate\Foundation\Testing\RefreshDatabase; diff --git a/tests/Feature/DocumentPreviewTest.php b/tests/Feature/DocumentPreviewTest.php index 0893a0c..71cbccc 100644 --- a/tests/Feature/DocumentPreviewTest.php +++ b/tests/Feature/DocumentPreviewTest.php @@ -1,7 +1,6 @@ create(); $document = Document::factory()->create([ - 'type' => 'global', 'conversion_status' => 'completed', 'markdown_path' => 'markdown/test.md', ]); - // 创建 Markdown 文件 Storage::disk('local')->put($document->markdown_path, '# 测试标题\n\n这是测试内容。'); - // 访问预览页面 $response = $this->actingAs($user)->get(route('documents.preview', $document)); - // 验证响应 $response->assertStatus(200); $response->assertSee($document->title); $response->assertSee('测试标题'); }); -test('用户可以预览有权限的专用文档', function () { - // 创建分组和用户 - $group = Group::factory()->create(); - $user = User::factory()->create(); - $user->groups()->attach($group); - - // 创建专用文档 - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group->id, - 'conversion_status' => 'completed', - 'markdown_path' => 'markdown/test.md', - ]); - - // 创建 Markdown 文件 - Storage::disk('local')->put($document->markdown_path, '# 专用文档\n\n这是专用内容。'); - - // 访问预览页面 - $response = $this->actingAs($user)->get(route('documents.preview', $document)); - - // 验证响应 - $response->assertStatus(200); - $response->assertSee($document->title); - $response->assertSee('专用文档'); -}); - -test('用户无法预览无权限的专用文档', function () { - // 创建两个分组 - $group1 = Group::factory()->create(); - $group2 = Group::factory()->create(); - - // 用户属于分组1 - $user = User::factory()->create(); - $user->groups()->attach($group1); - - // 文档属于分组2 - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group2->id, - 'conversion_status' => 'completed', - ]); - - // 尝试访问预览页面 - $response = $this->actingAs($user)->get(route('documents.preview', $document)); - - // 验证被拒绝 - $response->assertStatus(403); -}); - test('预览页面正确处理 Markdown 内容为空的情况', function () { - // 创建用户和文档(没有 Markdown 内容) $user = User::factory()->create(); $document = Document::factory()->create([ - 'type' => 'global', 'conversion_status' => 'completed', 'markdown_path' => null, ]); - // 访问预览页面 $response = $this->actingAs($user)->get(route('documents.preview', $document)); - // 验证响应 $response->assertStatus(200); $response->assertSee('Markdown 内容为空'); $response->assertSee('下载原始文档'); }); test('预览页面显示下载按钮', function () { - // 创建用户和文档 $user = User::factory()->create(); $document = Document::factory()->create([ - 'type' => 'global', 'conversion_status' => 'completed', 'markdown_path' => 'markdown/test.md', ]); - // 创建 Markdown 文件 Storage::disk('local')->put($document->markdown_path, '# 测试'); - // 访问预览页面 $response = $this->actingAs($user)->get(route('documents.preview', $document)); - // 验证下载按钮存在 $response->assertStatus(200); $response->assertSee('下载原文档'); $response->assertSee(route('documents.download', $document)); diff --git a/tests/Feature/GroupDeletionTest.php b/tests/Feature/GroupDeletionTest.php deleted file mode 100644 index b733f1b..0000000 --- a/tests/Feature/GroupDeletionTest.php +++ /dev/null @@ -1,98 +0,0 @@ -create(); - $group = Group::factory()->create(['name' => '测试分组']); - - // 创建属于该分组的专用文档 - $document1 = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group->id, - 'uploaded_by' => $user->id, - 'title' => '专用文档1', - ]); - - $document2 = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group->id, - 'uploaded_by' => $user->id, - 'title' => '专用文档2', - ]); - - // 创建全局文档(不应受影响) - $globalDocument = Document::factory()->create([ - 'type' => 'global', - 'group_id' => null, - 'uploaded_by' => $user->id, - 'title' => '全局文档', - ]); - - // 验证文档初始状态 - expect($document1->fresh()->group_id)->toBe($group->id); - expect($document2->fresh()->group_id)->toBe($group->id); - expect($globalDocument->fresh()->group_id)->toBeNull(); - - // 删除分组 - $group->delete(); - - // 验证专用文档的 group_id 已被设置为 null - expect($document1->fresh()->group_id)->toBeNull(); - expect($document2->fresh()->group_id)->toBeNull(); - - // 验证全局文档不受影响 - expect($globalDocument->fresh()->group_id)->toBeNull(); - - // 验证文档本身没有被删除 - expect(Document::find($document1->id))->not->toBeNull(); - expect(Document::find($document2->id))->not->toBeNull(); - expect(Document::find($globalDocument->id))->not->toBeNull(); -}); - -test('删除分组不影响其他分组的文档', function () { - // 创建测试数据 - $user = User::factory()->create(); - $group1 = Group::factory()->create(['name' => '分组1']); - $group2 = Group::factory()->create(['name' => '分组2']); - - // 创建属于分组1的文档 - $document1 = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group1->id, - 'uploaded_by' => $user->id, - ]); - - // 创建属于分组2的文档 - $document2 = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group2->id, - 'uploaded_by' => $user->id, - ]); - - // 删除分组1 - $group1->delete(); - - // 验证分组1的文档变为孤立状态 - expect($document1->fresh()->group_id)->toBeNull(); - - // 验证分组2的文档不受影响 - expect($document2->fresh()->group_id)->toBe($group2->id); -}); - -test('删除没有文档的分组不会出错', function () { - // 创建一个没有文档的分组 - $group = Group::factory()->create(['name' => '空分组']); - - // 删除分组应该成功且不抛出异常 - expect(fn() => $group->delete())->not->toThrow(Exception::class); - - // 验证分组已被删除 - expect(Group::find($group->id))->toBeNull(); -}); diff --git a/tests/Feature/SecurityLoggerTest.php b/tests/Feature/SecurityLoggerTest.php index c13ebab..62077d5 100644 --- a/tests/Feature/SecurityLoggerTest.php +++ b/tests/Feature/SecurityLoggerTest.php @@ -3,7 +3,6 @@ namespace Tests\Feature; use App\Models\Document; -use App\Models\Group; use App\Models\User; use App\Services\SecurityLogger; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -22,11 +21,7 @@ class SecurityLoggerTest extends TestCase { // 创建测试数据 $user = User::factory()->create(); - $group = Group::factory()->create(); - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $group->id, - ]); + $document = Document::factory()->create(); // 模拟日志记录 Log::shouldReceive('channel') @@ -56,11 +51,7 @@ class SecurityLoggerTest extends TestCase { // 创建测试数据 $user = User::factory()->create(); - $otherGroup = Group::factory()->create(); - $document = Document::factory()->create([ - 'type' => 'dedicated', - 'group_id' => $otherGroup->id, - ]); + $document = Document::factory()->create(); // 模拟日志记录 Log::shouldReceive('channel') @@ -156,11 +147,8 @@ class SecurityLoggerTest extends TestCase 'name' => '测试用户', 'email' => 'test@example.com', ]); - $group = Group::factory()->create(); $document = Document::factory()->create([ 'title' => '测试文档', - 'type' => 'dedicated', - 'group_id' => $group->id, ]); // 模拟日志记录并验证上下文 @@ -171,7 +159,7 @@ class SecurityLoggerTest extends TestCase Log::shouldReceive('warning') ->once() - ->with('未授权访问尝试', \Mockery::on(function ($context) use ($user, $document, $group) { + ->with('未授权访问尝试', \Mockery::on(function ($context) use ($user, $document) { return $context['event'] === 'unauthorized_access' && $context['action'] === 'view' && $context['user_id'] === $user->id @@ -179,8 +167,7 @@ class SecurityLoggerTest extends TestCase && $context['user_email'] === 'test@example.com' && $context['document_id'] === $document->id && $context['document_title'] === '测试文档' - && $context['document_type'] === 'dedicated' - && $context['document_group_id'] === $group->id + && isset($context['document_knowledge_base_id']) && isset($context['ip_address']) && isset($context['timestamp']) && isset($context['user_agent']); diff --git a/tests/Feature/TerminalPromptFormTest.php b/tests/Feature/TerminalPromptFormTest.php index b14e015..449bf77 100644 --- a/tests/Feature/TerminalPromptFormTest.php +++ b/tests/Feature/TerminalPromptFormTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Filament\Resources\TerminalResource; +use App\Models\Station; use App\Models\Terminal; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -26,11 +27,13 @@ class TerminalPromptFormTest extends TestCase { $this->actingAs($this->user); + $station = Station::factory()->create(); + $terminalData = [ 'name' => '测试终端', 'code' => 'TEST-001', 'ip_address' => '192.168.1.100', - 'station_id' => 1, + 'station_id' => $station->id, 'prompt' => [ 'prompt_template' => '你是一个智能助手,当前用户是 {user}。', ], diff --git a/tests/Feature/TerminalResourceTest.php b/tests/Feature/TerminalResourceTest.php index 2a14edb..b777322 100644 --- a/tests/Feature/TerminalResourceTest.php +++ b/tests/Feature/TerminalResourceTest.php @@ -6,6 +6,7 @@ use App\Filament\Resources\TerminalResource; use App\Filament\Resources\TerminalResource\Pages\CreateTerminal; use App\Filament\Resources\TerminalResource\Pages\EditTerminal; use App\Filament\Resources\TerminalResource\Pages\ListTerminals; +use App\Models\Station; use App\Models\Terminal; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -57,11 +58,13 @@ class TerminalResourceTest extends TestCase /** @test */ public function it_can_create_terminal(): void { + $station = Station::factory()->create(); + $newData = [ 'name' => '测试终端', 'code' => 'TEST-0001', 'ip_address' => '192.168.1.100', - 'station_id' => 1, + 'station_id' => $station->id, 'diagram_url' => 'https://example.com/diagram.html', ]; @@ -161,11 +164,13 @@ class TerminalResourceTest extends TestCase { $terminal = Terminal::factory()->create(); + $station = Station::factory()->create(); + $newData = [ 'name' => '更新后的终端', 'code' => $terminal->code, 'ip_address' => '192.168.1.200', - 'station_id' => 2, + 'station_id' => $station->id, ]; Livewire::test(EditTerminal::class, ['record' => $terminal->getRouteKey()]) @@ -239,10 +244,12 @@ class TerminalResourceTest extends TestCase } /** @test */ - public function it_can_group_by_station(): void + public function it_can_group_by_group(): void { - Terminal::factory()->count(3)->create(['station_id' => 1]); - Terminal::factory()->count(2)->create(['station_id' => 2]); + $station1 = Station::factory()->create(); + $station2 = Station::factory()->create(); + Terminal::factory()->count(3)->create(['station_id' => $station1->id]); + Terminal::factory()->count(2)->create(['station_id' => $station2->id]); // 测试分组功能是否可用 $component = Livewire::test(ListTerminals::class);