refactor: kb & station & terminal
This commit is contained in:
@@ -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 [];
|
||||
|
||||
@@ -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('上传者')
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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('上传者'),
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\GroupResource\Pages;
|
||||
use App\Filament\Resources\GroupResource\RelationManagers;
|
||||
use App\Models\Group;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class GroupResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Group::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-user-group';
|
||||
|
||||
protected static ?string $navigationLabel = '分组管理';
|
||||
|
||||
protected static ?string $modelLabel = '分组';
|
||||
|
||||
protected static ?string $pluralModelLabel = '分组';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
protected static ?string $navigationGroup = '权限管理';
|
||||
|
||||
/**
|
||||
* 控制导航菜单是否显示
|
||||
*/
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return auth()->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GroupResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateGroup extends CreateRecord
|
||||
{
|
||||
protected static string $resource = GroupResource::class;
|
||||
|
||||
protected static ?string $title = '创建分组';
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getCreatedNotificationTitle(): ?string
|
||||
{
|
||||
return '分组创建成功';
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GroupResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditGroup extends EditRecord
|
||||
{
|
||||
protected static string $resource = GroupResource::class;
|
||||
|
||||
protected static ?string $title = '编辑分组';
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->label('删除')
|
||||
->modalHeading('删除分组')
|
||||
->modalDescription('确定要删除此分组吗?此操作无法撤销。')
|
||||
->modalSubmitActionLabel('确认删除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getSavedNotificationTitle(): ?string
|
||||
{
|
||||
return '分组更新成功';
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GroupResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListGroups extends ListRecords
|
||||
{
|
||||
protected static string $resource = GroupResource::class;
|
||||
|
||||
protected static ?string $title = '分组列表';
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('创建分组'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GroupResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class UsersRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'users';
|
||||
|
||||
protected static ?string $title = '分组成员';
|
||||
|
||||
protected static ?string $modelLabel = '用户';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('用户名称')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('email')
|
||||
->label('邮箱')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('ID')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('用户名称')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('email')
|
||||
->label('邮箱')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('加入时间')
|
||||
->dateTime('Y-m-d H:i:s')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\AttachAction::make()
|
||||
->label('添加成员')
|
||||
->preloadRecordSelect()
|
||||
->modalHeading('添加分组成员')
|
||||
->modalSubmitActionLabel('添加')
|
||||
->modalCancelActionLabel('取消'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\DetachAction::make()
|
||||
->label('移除')
|
||||
->modalHeading('移除分组成员')
|
||||
->modalDescription('确定要将此用户从分组中移除吗?')
|
||||
->modalSubmitActionLabel('确认移除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DetachBulkAction::make()
|
||||
->label('批量移除')
|
||||
->modalHeading('批量移除分组成员')
|
||||
->modalDescription('确定要将选中的用户从分组中移除吗?')
|
||||
->modalSubmitActionLabel('确认移除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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('创建时间')
|
||||
|
||||
153
app/Filament/Resources/KnowledgeBaseResource.php
Normal file
153
app/Filament/Resources/KnowledgeBaseResource.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\KnowledgeBaseResource\Pages;
|
||||
use App\Models\KnowledgeBase;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class KnowledgeBaseResource extends Resource
|
||||
{
|
||||
protected static ?string $model = KnowledgeBase::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-book-open';
|
||||
|
||||
protected static ?string $navigationLabel = '知识库管理';
|
||||
|
||||
protected static ?string $modelLabel = '知识库';
|
||||
|
||||
protected static ?string $pluralModelLabel = '知识库';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $navigationGroup = '知识管理';
|
||||
|
||||
public static function getEloquentQuery(): \Illuminate\Database\Eloquent\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
|
||||
->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\KnowledgeBaseResource\Pages;
|
||||
|
||||
use App\Filament\Resources\KnowledgeBaseResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateKnowledgeBase extends CreateRecord
|
||||
{
|
||||
protected static string $resource = KnowledgeBaseResource::class;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\KnowledgeBaseResource\Pages;
|
||||
|
||||
use App\Filament\Resources\KnowledgeBaseResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditKnowledgeBase extends EditRecord
|
||||
{
|
||||
protected static string $resource = KnowledgeBaseResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->label('删除'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\KnowledgeBaseResource\Pages;
|
||||
|
||||
use App\Filament\Resources\KnowledgeBaseResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListKnowledgeBases extends ListRecords
|
||||
{
|
||||
protected static string $resource = KnowledgeBaseResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('新建知识库'),
|
||||
];
|
||||
}
|
||||
}
|
||||
130
app/Filament/Resources/StationResource.php
Normal file
130
app/Filament/Resources/StationResource.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\StationResource\Pages;
|
||||
use App\Models\Station;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class StationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Station::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-building-office';
|
||||
|
||||
protected static ?string $navigationLabel = '线站管理';
|
||||
|
||||
protected static ?string $modelLabel = '线站';
|
||||
|
||||
protected static ?string $pluralModelLabel = '线站';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $navigationGroup = '业务管理';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return auth()->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\StationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\StationResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateStation extends CreateRecord
|
||||
{
|
||||
protected static string $resource = StationResource::class;
|
||||
}
|
||||
20
app/Filament/Resources/StationResource/Pages/EditStation.php
Normal file
20
app/Filament/Resources/StationResource/Pages/EditStation.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\StationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\StationResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditStation extends EditRecord
|
||||
{
|
||||
protected static string $resource = StationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->label('删除'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\StationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\StationResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListStations extends ListRecords
|
||||
{
|
||||
protected static string $resource = StationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('新建线站'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class GroupsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'groups';
|
||||
|
||||
protected static ?string $title = '用户分组';
|
||||
|
||||
protected static ?string $modelLabel = '分组';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('分组名称')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label('分组描述')
|
||||
->rows(3)
|
||||
->maxLength(65535),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('ID')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('分组名称')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('description')
|
||||
->label('分组描述')
|
||||
->limit(50)
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('加入时间')
|
||||
->dateTime('Y-m-d H:i:s')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\AttachAction::make()
|
||||
->label('添加分组')
|
||||
->preloadRecordSelect()
|
||||
->modalHeading('添加用户到分组')
|
||||
->modalSubmitActionLabel('添加')
|
||||
->modalCancelActionLabel('取消'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\DetachAction::make()
|
||||
->label('移除')
|
||||
->modalHeading('移除用户分组')
|
||||
->modalDescription('确定要将此用户从该分组中移除吗?')
|
||||
->modalSubmitActionLabel('确认移除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DetachBulkAction::make()
|
||||
->label('批量移除')
|
||||
->modalHeading('批量移除用户分组')
|
||||
->modalDescription('确定要将此用户从选中的分组中移除吗?')
|
||||
->modalSubmitActionLabel('确认移除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 终端心跳上报
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 字段
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Group extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
/**
|
||||
* 可批量赋值的属性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
48
app/Models/Station.php
Normal file
48
app/Models/Station.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Station extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::deleting(function (Station $station) {
|
||||
$station->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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
* 可批量赋值的属性
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
|
||||
class GroupPolicy
|
||||
{
|
||||
/**
|
||||
* 查看分组列表
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->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');
|
||||
}
|
||||
}
|
||||
39
app/Policies/StationPolicy.php
Normal file
39
app/Policies/StationPolicy.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Station;
|
||||
use App\Models\User;
|
||||
|
||||
class StationPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ class DocumentConversionService
|
||||
|
||||
$document->update([
|
||||
'markdown_path' => $markdownPath,
|
||||
'markdown_preview' => $preview,
|
||||
'conversion_status' => 'completed',
|
||||
'conversion_error' => null,
|
||||
]);
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user