refactor: kb & station & terminal

This commit is contained in:
2026-03-23 20:17:17 +08:00
parent 63ea2686e1
commit b74ba1a3f8
81 changed files with 1016 additions and 2492 deletions

View File

@@ -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 [];

View File

@@ -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('上传者')

View File

@@ -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'];

View 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;
}

View File

@@ -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('上传者'),

View File

@@ -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'),
];
}
}

View File

@@ -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 '分组创建成功';
}
}

View File

@@ -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 '分组更新成功';
}
}

View File

@@ -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('创建分组'),
];
}
}

View File

@@ -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('取消'),
]),
]);
}
}

View File

@@ -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('创建时间')

View 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'),
];
}
}

View File

@@ -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;
}

View File

@@ -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('删除'),
];
}
}

View File

@@ -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('新建知识库'),
];
}
}

View 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'),
];
}
}

View File

@@ -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;
}

View 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('删除'),
];
}
}

View File

@@ -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('新建线站'),
];
}
}

View File

@@ -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')

View File

@@ -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),

View File

@@ -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,
];
}

View File

@@ -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('取消'),
]),
]);
}
}

View File

@@ -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'),
];
}
}

View File

@@ -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'),
];
}
}

View File

@@ -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
* 终端心跳上报

View File

@@ -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
{

View File

@@ -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 字段

View File

@@ -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);
}
}

View File

@@ -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()

View File

@@ -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
View 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);
}
}

View File

@@ -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);
}
/**

View File

@@ -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;
/**
* 可批量赋值的属性
*

View File

@@ -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();
}
/**

View File

@@ -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);
}

View File

@@ -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');
}
/**

View File

@@ -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');
}
}

View 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');
}
}

View File

@@ -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);
}
}

View File

@@ -110,7 +110,6 @@ class DocumentConversionService
$document->update([
'markdown_path' => $markdownPath,
'markdown_preview' => $preview,
'conversion_status' => 'completed',
'conversion_error' => null,
]);

View File

@@ -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 得到用户指定的 KBstation_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()]);
}
}
}

View File

@@ -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;
}
/**

View File

@@ -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}";

View File

@@ -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(),

View File

@@ -1,39 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| AI提示词占位符
|--------------------------------------------------------------------------
|
| 以下占位符由HMI端在运行时替换KMS仅存储含占位符的原始模板
|
*/
'variables' => [
[
'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',
],
],
];

View File

@@ -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'],
],
],
],

View File

@@ -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<string, mixed>
*/
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,
]);

View File

@@ -1,70 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Document;
use App\Models\DownloadLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\DownloadLog>
*/
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<string, mixed>
*/
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'),
]);
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Group;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Group>
*/
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<string, mixed>
*/
public function definition(): array
{
// 使用中文 Faker 生成器
$faker = \Faker\Factory::create('zh_CN');
// 生成中文分组名称(使用公司名或部门名)
$groupNames = [
'技术部',
'市场部',
'人力资源部',
'财务部',
'运营部',
'产品部',
'设计部',
'客服部',
'研发中心',
'销售部',
];
return [
'name' => $faker->randomElement($groupNames) . ' - ' . $faker->company(),
'description' => $faker->sentence(10),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\Station;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Station>
*/
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,
];
}
}

View File

@@ -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,

View File

@@ -1,29 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Terminal;
use App\Models\TerminalPrompt;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TerminalPrompt>
*/
class TerminalPromptFactory extends Factory
{
protected $model = TerminalPrompt::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'terminal_id' => Terminal::factory(),
'prompt_template' => '你是{station_id}光束线的AI助手。当前时间是{time}。请根据用户{user}的问题提供帮助。',
'variables' => [],
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('group_user', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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');

View File

@@ -1,52 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('documents', function (Blueprint $table) {
// 添加 Markdown 文件路径字段
$table->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'
]);
});
}
};

View File

@@ -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');
});

View File

@@ -1,22 +0,0 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddEventColumnToActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->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');
});
}
}

View File

@@ -1,22 +0,0 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddBatchUuidColumnToActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->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');
});
}
}

View File

@@ -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');
}
};

View File

@@ -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');

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('station_user', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -1,39 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('terminal_knowledge_bases', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('knowledge_base_station', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -1,109 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 权限名称映射(旧名称 => 新名称)
$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
{
// 不支持回滚,因为权限合并后无法准确还原
}
};

View File

@@ -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');
}

View File

@@ -1,29 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('documents', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -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");
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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());
}
}

View File

@@ -1,46 +0,0 @@
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<h3 class="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100">
可用占位符
</h3>
<div class="text-sm text-gray-600 dark:text-gray-400 mb-4">
以下占位符由 <strong>HMI端</strong> 在运行时替换
</div>
@php
$variables = config('prompt_variables.variables', []);
@endphp
<div class="space-y-2">
@foreach($variables as $variable)
<div class="flex items-start gap-2 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50">
<code class="px-2 py-1 bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 rounded text-xs font-mono whitespace-nowrap">
{{'{'}}{{ $variable['name'] }}{{'}'}}
</code>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm text-gray-900 dark:text-gray-100">
{{ $variable['label'] }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $variable['description'] }}
</div>
<div class="text-xs text-gray-400 dark:text-gray-500 mt-1">
示例: <span class="font-mono">{{ $variable['example'] }}</span>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
来源: {{ $variable['source'] }}
</div>
</div>
</div>
@endforeach
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">使用示例</h4>
<div class="bg-gray-50 dark:bg-gray-900 rounded p-3 text-xs font-mono">
<div class="text-gray-600 dark:text-gray-400">你是{{'{'}}station_id{{'}'}}光束线的AI助手。</div>
<div class="text-gray-600 dark:text-gray-400">当前时间是 {{'{'}}time{{'}'}}</div>
<div class="text-gray-600 dark:text-gray-400">请根据用户{{'{'}}user{{'}'}}的问题提供帮助。</div>
</div>
</div>
</div>

View File

@@ -1,138 +0,0 @@
<?php
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Feature: knowledge-base-system, Property 7: 用户文档列表权限过滤
*
* 对于任何用户,当查询其可访问的文档列表时,返回的结果应该只包含:
* (1) 所有全局知识库文档,以及 (2) 该用户所属分组的专用知识库文档
*
* Validates: Requirements 3.1, 3.2, 3.3
*/
test('property 7: 用户可访问的文档列表应该包含所有全局文档和用户分组的专用文档', function () {
// 运行 100 次迭代
for ($i = 0; $i < 100; $i++) {
// 创建随机数量的分组1-5个
$groupCount = rand(1, 5);
$groups = Group::factory()->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');

View File

@@ -1,81 +1,40 @@
<?php
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('DocumentPolicy', function () {
test('viewAny 允许所有已认证用户查看文档列表', function () {
test('viewAny 允许有权限的用户查看文档列表', function () {
$user = User::factory()->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();
});
});

View File

@@ -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;

View File

@@ -1,7 +1,6 @@
<?php
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
@@ -9,114 +8,50 @@ use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
// 设置存储磁盘
Storage::fake('local');
});
test('用户可以预览有权限的全局文档', function () {
// 创建用户和文档
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, '# 测试标题\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));

View File

@@ -1,98 +0,0 @@
<?php
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('删除分组时将关联的专用文档设置为孤立状态', function () {
// 创建测试数据
$user = User::factory()->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();
});

View File

@@ -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']);

View File

@@ -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}。',
],

View File

@@ -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);