refactor: 修复知识库和操作指引

This commit is contained in:
2026-03-13 14:32:37 +08:00
parent bbe8e60646
commit 58f42de9df
88 changed files with 3387 additions and 2472 deletions

View File

@@ -65,7 +65,7 @@ class ActivityLogExport implements FromQuery, WithHeadings, WithMapping, WithSty
'Document' => '文档',
'Group' => '分组',
'Terminal' => '终端',
'SopTemplate' => 'SOP模板',
'Guide' => '操作指引',
default => $className,
};
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Filament\Actions;
use App\Models\SopTemplate;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
class ArchiveSopTemplateAction extends Action
{
public static function getDefaultName(): ?string
{
return 'archive';
}
protected function setUp(): void
{
parent::setUp();
$this->label('归档模板');
$this->icon('heroicon-o-archive-box');
$this->color('warning');
$this->requiresConfirmation();
$this->modalHeading('归档 SOP 模板');
$this->modalDescription('归档后,模板将不再对用户显示。确定要归档吗?');
$this->modalSubmitActionLabel('确认归档');
$this->visible(function ($record) {
return $record instanceof SopTemplate && $record->status === 'published';
});
$this->action(function (SopTemplate $record) {
$record->update([
'status' => 'archived',
]);
Notification::make()
->title('归档成功')
->body('SOP 模板已成功归档')
->success()
->send();
});
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace App\Filament\Actions;
use App\Models\SopTemplate;
use App\Services\SopTemplateService;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Response;
class ExportSopTemplateAction extends Action
{
public static function getDefaultName(): ?string
{
return 'export';
}
protected function setUp(): void
{
parent::setUp();
$this->label('导出模板');
$this->icon('heroicon-o-arrow-down-tray');
$this->color('info');
$this->action(function (SopTemplate $record) {
$service = app(SopTemplateService::class);
$json = $service->exportToJson($record);
$fileName = sprintf(
'sop_template_%s_%s.json',
$record->id,
now()->format('YmdHis')
);
Notification::make()
->title('导出成功')
->body('SOP 模板已成功导出')
->success()
->send();
return Response::streamDownload(function () use ($json) {
echo $json;
}, $fileName, [
'Content-Type' => 'application/json',
]);
});
}
}

View File

@@ -1,80 +0,0 @@
<?php
namespace App\Filament\Actions;
use App\Services\SopTemplateService;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Illuminate\Validation\ValidationException;
class ImportSopTemplateAction extends Action
{
public static function getDefaultName(): ?string
{
return 'import';
}
protected function setUp(): void
{
parent::setUp();
$this->label('导入模板');
$this->icon('heroicon-o-arrow-up-tray');
$this->color('success');
$this->modalHeading('导入 SOP 模板');
$this->modalDescription('从 JSON 文件导入 SOP 模板');
$this->modalSubmitActionLabel('导入');
$this->form([
Forms\Components\FileUpload::make('file')
->label('选择文件')
->acceptedFileTypes(['application/json'])
->required()
->maxSize(5120), // 5MB
]);
$this->action(function (array $data) {
try {
$filePath = storage_path('app/public/' . $data['file']);
$json = file_get_contents($filePath);
$service = app(SopTemplateService::class);
$template = $service->importFromJson($json);
// 删除临时文件
@unlink($filePath);
Notification::make()
->title('导入成功')
->body("SOP 模板「{$template->name}」已成功导入")
->success()
->send();
// 重定向到编辑页面
return redirect()->route('filament.admin.resources.sop-templates.edit', ['record' => $template]);
} catch (ValidationException $e) {
Notification::make()
->title('导入失败')
->body($e->getMessage())
->danger()
->send();
throw $e;
} catch (\Exception $e) {
Notification::make()
->title('导入失败')
->body('文件格式错误或数据无效')
->danger()
->send();
throw $e;
}
});
}
}

View File

@@ -1,90 +0,0 @@
<?php
namespace App\Filament\Actions;
use App\Models\SopTemplate;
use Filament\Actions\Action;
use Filament\Infolists;
use Filament\Infolists\Infolist;
class PreviewSopTemplateAction extends Action
{
public static function getDefaultName(): ?string
{
return 'preview';
}
protected function setUp(): void
{
parent::setUp();
$this->label('预览模板');
$this->icon('heroicon-o-eye');
$this->color('info');
$this->modalHeading('SOP 模板预览');
$this->modalWidth('7xl');
$this->modalSubmitAction(false);
$this->modalCancelActionLabel('关闭');
$this->infolist(function (SopTemplate $record): Infolist {
return Infolist::make()
->record($record)
->schema([
Infolists\Components\Section::make('模板信息')
->schema([
Infolists\Components\TextEntry::make('name')
->label('模板名称')
->size(Infolists\Components\TextEntry\TextEntrySize::Large)
->weight('bold'),
Infolists\Components\TextEntry::make('description')
->label('模板描述')
->columnSpanFull(),
Infolists\Components\TextEntry::make('category')
->label('分类')
->badge(),
Infolists\Components\TextEntry::make('version')
->label('版本'),
])
->columns(2),
Infolists\Components\Section::make('操作步骤')
->schema([
Infolists\Components\RepeatableEntry::make('steps')
->label('')
->schema([
Infolists\Components\TextEntry::make('step_number')
->label('步骤')
->badge()
->color('primary'),
Infolists\Components\TextEntry::make('title')
->label('标题')
->weight('bold'),
Infolists\Components\TextEntry::make('content')
->label('内容')
->html()
->columnSpanFull(),
Infolists\Components\TextEntry::make('is_required')
->label('必需')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? '是' : '否')
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
])
->columns(3)
->contained(false),
]),
]);
});
}
}

View File

@@ -1,85 +0,0 @@
<?php
namespace App\Filament\Actions;
use App\Models\SopTemplate;
use App\Models\SopTemplateVersion;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
class PublishSopTemplateAction extends Action
{
public static function getDefaultName(): ?string
{
return 'publish';
}
protected function setUp(): void
{
parent::setUp();
$this->label('发布模板');
$this->icon('heroicon-o-check-circle');
$this->color('success');
$this->requiresConfirmation();
$this->modalHeading('发布 SOP 模板');
$this->modalDescription('发布后,模板将对所有用户可见。确定要发布吗?');
$this->modalSubmitActionLabel('确认发布');
$this->form([
Forms\Components\Textarea::make('change_log')
->label('变更说明')
->placeholder('请描述本次发布的主要变更内容...')
->rows(3),
]);
$this->visible(function ($record) {
return $record instanceof SopTemplate && $record->status === 'draft';
});
$this->action(function (SopTemplate $record, array $data) {
// 验证模板是否有步骤
if ($record->steps()->count() === 0) {
Notification::make()
->title('发布失败')
->body('模板至少需要包含一个步骤才能发布')
->danger()
->send();
return;
}
// 创建版本快照
SopTemplateVersion::create([
'sop_template_id' => $record->id,
'version' => $record->version,
'change_log' => $data['change_log'] ?? '首次发布',
'content_snapshot' => [
'template' => $record->toArray(),
'steps' => $record->steps->toArray(),
],
'created_by' => auth()->id(),
'created_at' => now(),
]);
// 更新模板状态
$record->update([
'status' => 'published',
'published_at' => now(),
]);
Notification::make()
->title('发布成功')
->body('SOP 模板已成功发布')
->success()
->send();
});
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Pages;
use App\Models\Document;
use App\Models\Group;
use App\Models\KnowledgeBase;
use App\Services\DocumentSearchService;
use App\Services\DocumentService;
use Filament\Forms\Components\Select;
@@ -41,6 +42,7 @@ class SearchPage extends Page implements HasForms, HasTable
public ?string $searchQuery = null;
public ?string $documentType = null;
public ?int $groupId = null;
public ?int $knowledgeBaseId = null;
// 搜索结果
public $searchResults = null;
@@ -55,6 +57,7 @@ class SearchPage extends Page implements HasForms, HasTable
'searchQuery' => '',
'documentType' => null,
'groupId' => null,
'knowledgeBaseId' => null,
]);
}
@@ -86,8 +89,15 @@ class SearchPage extends Page implements HasForms, HasTable
->options(Group::pluck('name', 'id'))
->searchable()
->native(false),
Select::make('knowledgeBaseId')
->label('知识库')
->placeholder('全部知识库')
->options(KnowledgeBase::pluck('name', 'id'))
->searchable()
->native(false),
])
->columns(3);
->columns(4);
}
/**
@@ -198,6 +208,9 @@ class SearchPage extends Page implements HasForms, HasTable
if ($this->groupId) {
$filters['group_id'] = $this->groupId;
}
if ($this->knowledgeBaseId) {
$filters['knowledge_base_id'] = $this->knowledgeBaseId;
}
// 执行搜索
$results = $searchService->search($this->searchQuery, $user, $filters);
@@ -236,6 +249,7 @@ class SearchPage extends Page implements HasForms, HasTable
$this->searchQuery = $data['searchQuery'];
$this->documentType = $data['documentType'];
$this->groupId = $data['groupId'];
$this->knowledgeBaseId = $data['knowledgeBaseId'] ?? null;
$this->hasSearched = true;
// 重置表格分页
@@ -256,11 +270,13 @@ class SearchPage extends Page implements HasForms, HasTable
'searchQuery' => '',
'documentType' => null,
'groupId' => null,
'knowledgeBaseId' => null,
]);
$this->searchQuery = null;
$this->documentType = null;
$this->groupId = null;
$this->knowledgeBaseId = null;
$this->hasSearched = false;
$this->resetTable();

View File

@@ -99,7 +99,7 @@ class ActivityLogResource extends Resource
'Document' => '文档',
'Group' => '分组',
'Terminal' => '终端',
'SopTemplate' => 'SOP模板',
'Guide' => '操作指引',
default => $className,
};
})
@@ -195,7 +195,7 @@ class ActivityLogResource extends Resource
'App\\Models\\Document' => '文档',
'App\\Models\\Group' => '分组',
'App\\Models\\Terminal' => '终端',
'App\\Models\\SopTemplate' => 'SOP模板',
'App\\Models\\Guide' => '操作指引',
])
->placeholder('全部类型'),
])

View File

@@ -61,7 +61,7 @@ class ViewActivityLog extends ViewRecord
'Document' => '文档',
'Group' => '分组',
'Terminal' => '终端',
'SopTemplate' => 'SOP模板',
'Guide' => '操作指引',
default => $className,
};
}),

View File

@@ -0,0 +1,204 @@
<?php
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;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class GuideResource extends Resource
{
protected static ?string $model = Guide::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 = 3;
protected static ?string $navigationGroup = '业务管理';
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('guide.view') ?? false;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('基本信息')
->schema([
Forms\Components\TextInput::make('name')
->label('指引名称')
->required()
->maxLength(255)
->placeholder('例如: 如何用光'),
Forms\Components\Select::make('category')
->label('分类')
->required()
->options([
'operation' => '操作指引',
'fault_handling' => '故障处理',
'training' => '培训教程',
'safety' => '安全规范',
'maintenance' => '维护保养',
])
->default('operation'),
Forms\Components\Select::make('status')
->label('状态')
->required()
->options([
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
])
->default('draft'),
Forms\Components\TagsInput::make('tags')
->label('标签')
->placeholder('输入标签后回车')
->helperText('用于分类和搜索的关键词标签'),
Forms\Components\Textarea::make('description')
->label('描述')
->maxLength(1000)
->placeholder('简要描述此指引的用途')
->columnSpanFull(),
])
->columns(2),
Forms\Components\Section::make('关联终端')
->schema([
Forms\Components\CheckboxList::make('terminals')
->label('适用终端')
->relationship('terminals', 'name')
->searchable()
->bulkToggleable()
->helperText('选择此指引适用的终端,未关联终端的指引不会在终端显示')
->columns(3),
])
->description('配置此指引在哪些终端上可见'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('指引名称')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('category')
->label('分类')
->badge()
->formatStateUsing(fn(string $state): string => match ($state) {
'operation' => '操作指引',
'fault_handling' => '故障处理',
'training' => '培训教程',
'safety' => '安全规范',
'maintenance' => '维护保养',
default => $state,
})
->color(fn(string $state): string => match ($state) {
'operation' => 'primary',
'fault_handling' => 'danger',
'training' => 'info',
'safety' => 'warning',
'maintenance' => 'gray',
default => 'gray',
})
->sortable(),
Tables\Columns\TextColumn::make('status')
->label('状态')
->badge()
->formatStateUsing(fn(string $state): string => match ($state) {
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
default => $state,
})
->color(fn(string $state): string => match ($state) {
'draft' => 'gray',
'published' => 'success',
'archived' => 'warning',
default => 'gray',
})
->sortable(),
Tables\Columns\TextColumn::make('pages_count')
->label('页数')
->counts('pages')
->sortable(),
Tables\Columns\TextColumn::make('terminals_count')
->label('关联终端')
->counts('terminals')
->sortable()
->badge()
->color(fn(int $state): string => $state > 0 ? 'success' : 'gray')
->formatStateUsing(fn(int $state): string => $state > 0 ? "{$state}" : '未关联'),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('category')
->label('分类')
->options([
'operation' => '操作指引',
'fault_handling' => '故障处理',
'training' => '培训教程',
'safety' => '安全规范',
'maintenance' => '维护保养',
]),
Tables\Filters\SelectFilter::make('status')
->label('状态')
->options([
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
]),
])
->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\ListGuides::route('/'),
'create' => Pages\CreateGuide::route('/create'),
'edit' => Pages\EditGuide::route('/{record}/edit'),
'manage-pages' => Pages\ManageGuidePages::route('/{record}/manage-pages'),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\GuideResource\Pages;
use App\Filament\Resources\GuideResource;
use Filament\Resources\Pages\CreateRecord;
class CreateGuide extends CreateRecord
{
protected static string $resource = GuideResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by'] = auth()->id();
if ($data['status'] === 'published') {
$data['published_at'] = now();
}
return $data;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\GuideResource\Pages;
use App\Filament\Resources\GuideResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditGuide extends EditRecord
{
protected static string $resource = GuideResource::class;
protected function getHeaderActions(): array
{
return [
\Filament\Actions\Action::make('managePages')
->label('编辑指引')
->icon('heroicon-o-queue-list')
->url(fn() => GuideResource::getUrl('manage-pages', ['record' => $this->record])),
Actions\DeleteAction::make()->label('删除'),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
if ($data['status'] === 'published' && !$this->record->published_at) {
$data['published_at'] = now();
}
return $data;
}
}

View File

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

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Filament\Resources\GuideResource\Pages;
use App\Filament\Resources\GuideResource;
use App\Models\Guide;
use App\Models\GuidePage;
use Filament\Forms;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use SolutionForest\FilamentTree\Actions;
use SolutionForest\FilamentTree\Resources\Pages\TreePage;
class ManageGuidePages extends TreePage
{
protected static string $resource = GuideResource::class;
protected ?string $treeTitle = '指引页面';
protected bool $enableTreeTitle = true;
protected static string $model = GuidePage::class;
protected function getTreeQuery(): Builder
{
return GuidePage::query()
->where('guide_id', $this->getOwnerRecord()->id);
}
public function getTreeRecordTitle(?Model $record = null): string
{
if (!$record) {
return '';
}
$prefix = $record->branch_option ? "[{$record->branch_option}] " : '';
$suffix = !empty($record->options) ? ' 📋' : '';
return $prefix . $record->title . $suffix;
}
protected function getFormSchema(): array
{
return [
Forms\Components\TextInput::make('page_number')
->label('页码')
->numeric()
->minValue(1),
Forms\Components\TextInput::make('title')
->label('页面标题')
->required()
->maxLength(255),
Forms\Components\TextInput::make('html_url')
->label('HTML页面URL')
->required()
->url()
->maxLength(500),
Forms\Components\TagsInput::make('options')
->label('选项按钮')
->helperText('此页面展示的选项按钮,如"前门12"、"后门"。留空=无分支。'),
Forms\Components\TextInput::make('branch_option')
->label('所属分支选项')
->maxLength(100)
->helperText('此页面对应父页面的哪个选项值(根页面留空)'),
];
}
protected function getTreeActions(): array
{
return [
Actions\ViewAction::make(),
Actions\EditAction::make()->slideOver(),
Actions\DeleteAction::make(),
];
}
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['guide_id'] = $this->getOwnerRecord()->id;
return $data;
}
protected function getOwnerRecord(): Guide
{
return Guide::findOrFail(request()->route('record'));
}
}

View File

@@ -46,7 +46,7 @@ class RoleResource extends Resource
'system-setting' => ['name' => '系统设置', 'icon' => 'heroicon-o-cog-6-tooth'],
'activity-log' => ['name' => '操作日志', 'icon' => 'heroicon-o-clipboard-document-list'],
'terminal' => ['name' => '终端管理', 'icon' => 'heroicon-o-computer-desktop'],
'sop-template' => ['name' => 'SOP模板', 'icon' => 'heroicon-o-document-text'],
'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'],
@@ -156,7 +156,7 @@ class RoleResource extends Resource
->dehydrateStateUsing(function ($state, $get) {
// 收集所有模块的权限
$allPermissions = [];
$modules = ['document', 'system-setting', 'activity-log', 'terminal', 'sop-template', 'group', 'user', 'role'];
$modules = ['document', 'system-setting', 'activity-log', 'terminal', 'guide', 'group', 'user', 'role'];
foreach ($modules as $module) {
$modulePermissions = $get("permissions_{$module}") ?? [];

View File

@@ -80,7 +80,7 @@ class ViewRole extends ViewRecord
'system-setting' => '⚙️ 系统设置',
'activity-log' => '📋 操作日志',
'terminal' => '🖥️ 终端管理',
'sop-template' => '📝 SOP模板',
'guide' => '📖 操作指引',
'group' => '👥 分组管理',
'user' => '👤 用户管理',
'role' => '🛡️ 角色管理',

View File

@@ -1,269 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\SopTemplateResource\Pages;
use App\Models\SopTemplate;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class SopTemplateResource extends Resource
{
protected static ?string $model = SopTemplate::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static ?string $navigationLabel = 'SOP模板';
protected static ?string $modelLabel = 'SOP模板';
protected static ?string $pluralModelLabel = 'SOP模板';
protected static ?int $navigationSort = 1;
protected static ?string $navigationGroup = '业务管理';
/**
* 控制导航菜单是否显示
*/
public static function shouldRegisterNavigation(): bool
{
return auth()->user()?->can('sop-template.view') ?? false;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('基本信息')
->schema([
Forms\Components\TextInput::make('name')
->label('模板名称')
->required()
->maxLength(255),
Forms\Components\Textarea::make('description')
->label('模板描述')
->rows(3)
->maxLength(65535),
Forms\Components\TextInput::make('category')
->label('分类')
->maxLength(100)
->placeholder('例如:安全操作、设备维护、质量检查'),
Forms\Components\TagsInput::make('tags')
->label('标签')
->placeholder('添加标签')
->separator(','),
])
->columns(2),
Forms\Components\Section::make('适用范围')
->schema([
Forms\Components\TagsInput::make('applicable_departments')
->label('适用部门')
->placeholder('添加部门')
->separator(','),
Forms\Components\TagsInput::make('applicable_positions')
->label('适用岗位')
->placeholder('添加岗位')
->separator(','),
])
->columns(2),
Forms\Components\Section::make('版本管理')
->schema([
Forms\Components\TextInput::make('version')
->label('版本号')
->default('1.0.0')
->required()
->maxLength(50),
Forms\Components\Select::make('status')
->label('状态')
->options([
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
])
->default('draft')
->required(),
])
->columns(2)
->visible(fn ($livewire) => $livewire instanceof Pages\EditSopTemplate),
// 步骤编辑器 - 只在编辑页面显示
Forms\Components\Section::make('操作步骤')
->schema([
Forms\Components\Repeater::make('steps')
->label('')
->relationship('steps')
->schema([
Forms\Components\TextInput::make('step_number')
->label('步骤序号')
->numeric()
->required()
->default(fn ($get) => $get('../../steps') ? count($get('../../steps')) + 1 : 1),
Forms\Components\TextInput::make('title')
->label('步骤标题')
->required()
->maxLength(255)
->columnSpanFull(),
Forms\Components\RichEditor::make('content')
->label('步骤内容')
->toolbarButtons([
'bold',
'italic',
'underline',
'strike',
'bulletList',
'orderedList',
'h2',
'h3',
'link',
'blockquote',
'codeBlock',
])
->columnSpanFull(),
Forms\Components\Toggle::make('is_required')
->label('是否必需')
->default(true),
Forms\Components\Hidden::make('sort_order')
->default(fn ($get) => $get('step_number')),
])
->columns(2)
->reorderable('sort_order')
->reorderableWithButtons()
->collapsible()
->itemLabel(fn (array $state): ?string => $state['title'] ?? '新步骤')
->addActionLabel('添加步骤')
->defaultItems(0)
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
$data['sort_order'] = $data['step_number'];
return $data;
})
->mutateRelationshipDataBeforeSaveUsing(function (array $data): array {
$data['sort_order'] = $data['step_number'];
return $data;
}),
])
->visible(fn ($livewire) => $livewire instanceof Pages\EditSopTemplate),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('模板名称')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('category')
->label('分类')
->searchable()
->sortable()
->badge()
->color('info'),
Tables\Columns\TextColumn::make('version')
->label('版本')
->sortable(),
Tables\Columns\TextColumn::make('status')
->label('状态')
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'published' => 'success',
'archived' => 'warning',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
default => $state,
}),
Tables\Columns\TextColumn::make('steps_count')
->label('步骤数')
->counts('steps')
->sortable(),
Tables\Columns\TextColumn::make('creator.name')
->label('创建人')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('published_at')
->label('发布时间')
->dateTime('Y-m-d H:i')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('状态')
->options([
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
]),
Tables\Filters\SelectFilter::make('category')
->label('分类')
->options(function () {
return SopTemplate::query()
->whereNotNull('category')
->distinct()
->pluck('category', 'category')
->toArray();
}),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListSopTemplates::route('/'),
'create' => Pages\CreateSopTemplate::route('/create'),
'view' => Pages\ViewSopTemplate::route('/{record}'),
'edit' => Pages\EditSopTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Filament\Resources\SopTemplateResource\Pages;
use App\Filament\Resources\SopTemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateSopTemplate extends CreateRecord
{
protected static string $resource = SopTemplateResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by'] = auth()->id();
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('edit', ['record' => $this->getRecord()]);
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Filament\Resources\SopTemplateResource\Pages;
use App\Filament\Actions\ArchiveSopTemplateAction;
use App\Filament\Actions\ExportSopTemplateAction;
use App\Filament\Actions\PreviewSopTemplateAction;
use App\Filament\Actions\PublishSopTemplateAction;
use App\Filament\Resources\SopTemplateResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditSopTemplate extends EditRecord
{
protected static string $resource = SopTemplateResource::class;
protected function getHeaderActions(): array
{
return [
PreviewSopTemplateAction::make(),
ExportSopTemplateAction::make(),
PublishSopTemplateAction::make(),
ArchiveSopTemplateAction::make(),
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Filament\Resources\SopTemplateResource\Pages;
use App\Filament\Actions\ExportSopTemplateAction;
use App\Filament\Actions\ImportSopTemplateAction;
use App\Filament\Resources\SopTemplateResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListSopTemplates extends ListRecords
{
protected static string $resource = SopTemplateResource::class;
protected function getHeaderActions(): array
{
return [
ImportSopTemplateAction::make(),
Actions\CreateAction::make(),
];
}
}

View File

@@ -1,99 +0,0 @@
<?php
namespace App\Filament\Resources\SopTemplateResource\Pages;
use App\Filament\Resources\SopTemplateResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Filament\Infolists;
use Filament\Infolists\Infolist;
class ViewSopTemplate extends ViewRecord
{
protected static string $resource = SopTemplateResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\Section::make('基本信息')
->schema([
Infolists\Components\TextEntry::make('name')
->label('模板名称'),
Infolists\Components\TextEntry::make('description')
->label('模板描述')
->columnSpanFull(),
Infolists\Components\TextEntry::make('category')
->label('分类')
->badge()
->color('info'),
Infolists\Components\TextEntry::make('tags')
->label('标签')
->badge()
->separator(','),
Infolists\Components\TextEntry::make('version')
->label('版本号'),
Infolists\Components\TextEntry::make('status')
->label('状态')
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'published' => 'success',
'archived' => 'warning',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'draft' => '草稿',
'published' => '已发布',
'archived' => '已归档',
default => $state,
}),
])
->columns(2),
Infolists\Components\Section::make('适用范围')
->schema([
Infolists\Components\TextEntry::make('applicable_departments')
->label('适用部门')
->badge()
->separator(','),
Infolists\Components\TextEntry::make('applicable_positions')
->label('适用岗位')
->badge()
->separator(','),
])
->columns(2),
Infolists\Components\Section::make('其他信息')
->schema([
Infolists\Components\TextEntry::make('creator.name')
->label('创建人'),
Infolists\Components\TextEntry::make('published_at')
->label('发布时间')
->dateTime('Y-m-d H:i'),
Infolists\Components\TextEntry::make('created_at')
->label('创建时间')
->dateTime('Y-m-d H:i'),
Infolists\Components\TextEntry::make('updated_at')
->label('更新时间')
->dateTime('Y-m-d H:i'),
])
->columns(2),
]);
}
}

View File

@@ -68,11 +68,21 @@ class TerminalResource extends Resource
->placeholder('例如: 192.168.1.100')
->helperText('终端的IP地址'),
Forms\Components\TextInput::make('mac_address')
->label('MAC地址')
->maxLength(17)
->placeholder('AA:BB:CC:DD:EE:FF')
->helperText('终端的MAC地址用于自动识别终端')
->regex('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/')
->validationMessages([
'regex' => 'MAC地址格式不正确应为 AA:BB:CC:DD:EE:FF',
]),
Forms\Components\TextInput::make('station_id')
->label('线站ID')
->numeric()
->placeholder('请输入线站ID')
->helperText('关联的生产线站ID'),
->maxLength(50)
->placeholder('例如: BL02U1')
->helperText('关联的光束线/线站标识'),
])
->columns(2),
@@ -86,6 +96,24 @@ class TerminalResource extends Resource
->helperText('组态图的访问地址'),
]),
Forms\Components\Section::make('SCADA网关配置')
->schema([
Forms\Components\TextInput::make('scada_data_url')
->label('SCADA数据查询URL')
->url()
->maxLength(500)
->placeholder('http://gateway:8080/api/data')
->helperText('OPC UA HTTP网关的数据查询地址'),
Forms\Components\TextInput::make('scada_tags_url')
->label('SCADA点位定义URL')
->url()
->maxLength(500)
->placeholder('http://gateway:8080/api/tags')
->helperText('OPC UA HTTP网关的点位定义查询地址'),
])
->columns(2),
Forms\Components\Section::make('显示配置')
->schema([
Forms\Components\KeyValue::make('display_config')
@@ -130,7 +158,7 @@ class TerminalResource extends Resource
->reorderableWithButtons()
->addActionLabel('添加知识库')
->reorderableWithDragAndDrop(false)
->itemLabel(fn (array $state): ?string =>
->itemLabel(fn (array $state): ?string =>
\App\Models\KnowledgeBase::find($state['id'])?->name ?? '未选择'
)
->collapsed()
@@ -139,6 +167,43 @@ class TerminalResource extends Resource
])
->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)
@@ -204,6 +269,13 @@ class TerminalResource extends Resource
->copyable()
->tooltip('点击复制'),
Tables\Columns\TextColumn::make('mac_address')
->label('MAC地址')
->searchable()
->copyable()
->placeholder('未设置')
->toggleable(),
Tables\Columns\TextColumn::make('ip_address')
->label('IP地址')
->searchable()

View File

@@ -48,7 +48,7 @@ class UserResource extends Resource
'system-setting' => ['name' => '系统设置', 'icon' => 'heroicon-o-cog-6-tooth'],
'activity-log' => ['name' => '操作日志', 'icon' => 'heroicon-o-clipboard-document-list'],
'terminal' => ['name' => '终端管理', 'icon' => 'heroicon-o-computer-desktop'],
'sop-template' => ['name' => 'SOP模板', 'icon' => 'heroicon-o-document-text'],
'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'],
@@ -172,7 +172,7 @@ class UserResource extends Resource
->dehydrateStateUsing(function ($state, $get) {
// 收集所有模块的权限
$allPermissions = [];
$modules = ['document', 'system-setting', 'activity-log', 'terminal', 'sop-template', 'group', 'user', 'role'];
$modules = ['document', 'system-setting', 'activity-log', 'terminal', 'guide', 'group', 'user', 'role'];
foreach ($modules as $module) {
$modulePermissions = $get("permissions_{$module}") ?? [];

View File

@@ -90,7 +90,7 @@ class ViewUser extends ViewRecord
'system-setting' => '⚙️ 系统设置',
'activity-log' => '📋 操作日志',
'terminal' => '🖥️ 终端管理',
'sop-template' => '📝 SOP模板',
'guide' => '📖 操作指引',
'group' => '👥 分组管理',
'user' => '👤 用户管理',
'role' => '🛡️ 角色管理',
@@ -144,7 +144,7 @@ class ViewUser extends ViewRecord
'system-setting' => '⚙️ 系统设置',
'activity-log' => '📋 操作日志',
'terminal' => '🖥️ 终端管理',
'sop-template' => '📝 SOP模板',
'guide' => '📖 操作指引',
'group' => '👥 分组管理',
'user' => '👤 用户管理',
'role' => '🛡️ 角色管理',

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Guide;
use App\Models\GuidePage;
use App\Services\KnowledgeContextService;
use App\Services\PromptTemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TerminalApiController extends Controller
{
public function __construct(
private PromptTemplateService $promptService,
private KnowledgeContextService $knowledgeService,
) {}
/**
* GET /api/terminal/config
* 返回终端配置含渲染后的system prompt
*/
public function config(Request $request): JsonResponse
{
$terminal = $request->attributes->get('terminal');
$terminal->load(['prompt', 'knowledgeBases']);
// 渲染system prompt
$systemPrompt = '';
if ($terminal->prompt && $terminal->prompt->prompt_template) {
$systemPrompt = $this->promptService->replaceVariables(
$terminal->prompt->prompt_template,
$terminal
);
}
// 获取终端关联的已发布指引数量
$guideCount = $terminal->guides()->published()->count();
return response()->json([
'terminal' => [
'id' => $terminal->id,
'name' => $terminal->name,
'code' => $terminal->code,
'station_id' => $terminal->station_id,
'diagram_url' => $terminal->diagram_url,
'scada_data_url' => $terminal->scada_data_url,
'scada_tags_url' => $terminal->scada_tags_url,
'display_config' => $terminal->display_config,
],
'system_prompt' => $systemPrompt,
'guide_count' => $guideCount,
]);
}
/**
* GET /api/terminal/knowledge?query=xxx
* RAG知识搜索由AI tool_call触发
*/
public function knowledge(Request $request): JsonResponse
{
$request->validate([
'query' => 'required|string|max:500',
]);
$terminal = $request->attributes->get('terminal');
$terminal->load('knowledgeBases');
$result = $this->knowledgeService->search($terminal, $request->input('query'));
return response()->json($result);
}
/**
* GET /api/terminal/guides?category=operation
* 已发布的指引列表
*/
public function guides(Request $request): JsonResponse
{
$terminal = $request->attributes->get('terminal');
$query = $terminal->guides()->published()->withCount('pages');
if ($category = $request->input('category')) {
$query->where('category', $category);
}
$guides = $query->orderBy('name')->get()->map(fn(Guide $guide) => [
'id' => $guide->id,
'name' => $guide->name,
'description' => $guide->description,
'category' => $guide->category,
'tags' => $guide->tags,
'page_count' => $guide->pages_count,
]);
return response()->json(['guides' => $guides]);
}
/**
* POST /api/terminal/guides/pages
* 组合多个指引的页面,返回递归树形结构
*/
public function guidePages(Request $request): JsonResponse
{
$request->validate([
'guide_ids' => 'required|array|min:1',
'guide_ids.*' => 'integer|exists:guides,id',
]);
$terminal = $request->attributes->get('terminal');
$accessibleIds = $terminal->guides()->published()->pluck('guides.id')->toArray();
$guideIds = $request->input('guide_ids');
$pages = [];
foreach ($guideIds as $guideId) {
if (!in_array($guideId, $accessibleIds)) {
continue;
}
$guide = Guide::with(
$this->buildEagerLoadArray('trunkPages', 5)
)->find($guideId);
if (!$guide) {
continue;
}
foreach ($guide->trunkPages as $page) {
$pages = array_merge($pages, $this->flattenSequentialPages($page, $guide->name, $guide->id));
}
}
return response()->json([
'pages' => $pages,
'total_pages' => count($pages),
]);
}
/**
* 将树形页面结构展平:顺序节点(无 options平铺分支节点保留嵌套
*/
private function flattenSequentialPages(GuidePage $page, string $guideName, int $guideId): array
{
$data = [
'id' => $page->id,
'guide_id' => $guideId,
'guide_name' => $guideName,
'page_number' => $page->page_number,
'title' => $page->title,
'html_url' => $page->html_url,
];
if ($page->options && $page->branchChildren->isNotEmpty()) {
$data['options'] = $page->options;
$branches = [];
foreach ($page->branchChildren as $child) {
$branches[$child->branch_option][] =
$this->buildPageTree($child, $guideName, $guideId);
}
$data['branches'] = $branches;
return [$data];
}
if ($page->branchChildren->isNotEmpty()) {
$result = [$data];
foreach ($page->branchChildren as $child) {
$result = array_merge($result, $this->flattenSequentialPages($child, $guideName, $guideId));
}
return $result;
}
return [$data];
}
private function buildPageTree(GuidePage $page, string $guideName, int $guideId): array
{
$data = [
'id' => $page->id,
'guide_id' => $guideId,
'guide_name' => $guideName,
'page_number' => $page->page_number,
'title' => $page->title,
'html_url' => $page->html_url,
];
if ($page->options && $page->branchChildren->isNotEmpty()) {
$data['options'] = $page->options;
$branches = [];
foreach ($page->branchChildren as $child) {
$branches[$child->branch_option][] =
$this->buildPageTree($child, $guideName, $guideId);
}
$data['branches'] = $branches;
}
return $data;
}
private function buildEagerLoadArray(string $base, int $depth): array
{
$loads = [$base => fn($q) => $q->orderBy('sort_order')];
$current = $base;
for ($i = 0; $i < $depth; $i++) {
$current .= '.branchChildren';
$loads[$current] = fn($q) => $q->orderBy('sort_order');
}
return $loads;
}
/**
* POST /api/terminal/heartbeat
* 终端心跳上报
*/
public function heartbeat(Request $request): JsonResponse
{
$terminal = $request->attributes->get('terminal');
$terminal->update([
'is_online' => true,
'last_online_at' => now(),
]);
return response()->json(['status' => 'ok']);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use App\Models\Terminal;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class IdentifyTerminal
{
public function handle(Request $request, Closure $next): Response
{
$macHeader = $request->header('X-Terminal-MAC');
if (!$macHeader) {
return response()->json(['error' => 'Missing X-Terminal-MAC header'], 400);
}
// HMI sends comma-separated MACs for all active interfaces;
// match if any one corresponds to a registered terminal
$macs = array_map('trim', explode(',', $macHeader));
$terminal = Terminal::whereIn('mac_address', $macs)->first();
if (!$terminal) {
return response()->json(['error' => 'Terminal not registered'], 403);
}
$request->attributes->set('terminal', $terminal);
// Record IP address from header (for logging/diagnostics)
if ($ip = $request->header('X-Terminal-IP')) {
$request->attributes->set('terminal_ip', $ip);
}
return $next($request);
}
}

View File

@@ -32,6 +32,7 @@ class Document extends Model
'markdown_preview',
'conversion_status',
'conversion_error',
'knowledge_base_id',
];
/**
@@ -42,6 +43,14 @@ class Document extends Model
return $this->belongsTo(Group::class);
}
/**
* 获取文档所属的知识库
*/
public function knowledgeBase(): BelongsTo
{
return $this->belongsTo(KnowledgeBase::class);
}
/**
* 获取文档的上传者
*/
@@ -120,6 +129,7 @@ class Document extends Model
'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,
];

75
app/Models/Guide.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Guide extends Model
{
use HasFactory, SoftDeletes, LogsActivity;
protected $fillable = [
'name',
'description',
'category',
'tags',
'status',
'created_by',
'published_at',
];
protected function casts(): array
{
return [
'tags' => 'array',
'published_at' => 'datetime',
];
}
public function pages()
{
return $this->hasMany(GuidePage::class)->orderBy('sort_order');
}
public function trunkPages()
{
return $this->hasMany(GuidePage::class)
->where('parent_id', -1)
->orderBy('sort_order');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function terminals()
{
return $this->belongsToMany(Terminal::class, 'terminal_guides')
->withPivot('priority')
->withTimestamps()
->orderBy('priority');
}
public function scopePublished($query)
{
return $query->where('status', 'published');
}
public function scopeCategory($query, string $category)
{
return $query->where('category', $category);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'description', 'category', 'status'])
->logOnlyDirty()
->setDescriptionForEvent(fn(string $eventName) => "指引已{$eventName}");
}
}

58
app/Models/GuidePage.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use SolutionForest\FilamentTree\Concern\ModelTree;
class GuidePage extends Model
{
use ModelTree;
protected $fillable = [
'guide_id',
'page_number',
'title',
'html_url',
'sort_order',
'parent_id',
'options',
'branch_option',
];
protected $casts = [
'options' => 'array',
'parent_id' => 'int',
];
// filament-tree column name mapping
public function determineParentColumnName(): string
{
return 'parent_id';
}
public function determineOrderColumnName(): string
{
return 'sort_order';
}
public function determineTitleColumnName(): string
{
return 'title';
}
public function guide()
{
return $this->belongsTo(Guide::class);
}
public function branchChildren()
{
return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
}
public function parentPage()
{
return $this->belongsTo(self::class, 'parent_id');
}
}

View File

@@ -33,4 +33,14 @@ class KnowledgeBase extends Model
->withTimestamps()
->orderBy('priority');
}
/**
* 获取知识库下的文档
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function documents()
{
return $this->hasMany(Document::class);
}
}

View File

@@ -1,46 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SopInteractiveTask extends Model
{
/**
* 可批量赋值的属性
*
* @var array<string>
*/
protected $fillable = [
'sop_step_id',
'task_type',
'task_config',
'validation_rules',
'timeout_seconds',
'is_required',
];
/**
* 属性类型转换
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'task_config' => 'array',
'validation_rules' => 'array',
'is_required' => 'boolean',
];
}
/**
* 获取任务所属的步骤
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function step()
{
return $this->belongsTo(SopStep::class, 'sop_step_id');
}
}

View File

@@ -1,54 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SopStep extends Model
{
/**
* 可批量赋值的属性
*
* @var array<string>
*/
protected $fillable = [
'sop_template_id',
'step_number',
'title',
'content',
'sort_order',
'is_required',
];
/**
* 属性类型转换
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
];
}
/**
* 获取步骤所属的模板
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function template()
{
return $this->belongsTo(SopTemplate::class, 'sop_template_id');
}
/**
* 获取步骤的交互任务列表
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function interactiveTasks()
{
return $this->hasMany(SopInteractiveTask::class);
}
}

View File

@@ -1,90 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class SopTemplate extends Model
{
use HasFactory, SoftDeletes, LogsActivity;
/**
* 可批量赋值的属性
*
* @var array<string>
*/
protected $fillable = [
'name',
'description',
'category',
'tags',
'version',
'status',
'applicable_departments',
'applicable_positions',
'published_at',
'created_by',
];
/**
* 属性类型转换
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'tags' => 'array',
'applicable_departments' => 'array',
'applicable_positions' => 'array',
'published_at' => 'datetime',
];
}
/**
* 获取模板的步骤列表
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function steps()
{
return $this->hasMany(SopStep::class)->orderBy('sort_order');
}
/**
* 获取模板的版本历史
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function versions()
{
return $this->hasMany(SopTemplateVersion::class);
}
/**
* 获取模板的创建者
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 配置活动日志选项
*
* @return \Spatie\Activitylog\LogOptions
*/
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'description', 'category', 'status', 'version'])
->logOnlyDirty()
->setDescriptionForEvent(fn(string $eventName) => "SOP模板已{$eventName}");
}
}

View File

@@ -1,62 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SopTemplateVersion extends Model
{
/**
* 表示模型是否应该被打上时间戳
* 注意: 只有created_at字段,没有updated_at
*
* @var bool
*/
public $timestamps = false;
/**
* 可批量赋值的属性
*
* @var array<string>
*/
protected $fillable = [
'sop_template_id',
'version',
'change_log',
'content_snapshot',
'created_by',
];
/**
* 属性类型转换
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'content_snapshot' => 'array',
'created_at' => 'datetime',
];
}
/**
* 获取版本所属的模板
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function template()
{
return $this->belongsTo(SopTemplate::class, 'sop_template_id');
}
/**
* 获取版本的创建者
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@@ -21,8 +21,11 @@ class Terminal extends Model
'name',
'code',
'ip_address',
'mac_address',
'station_id',
'diagram_url',
'scada_data_url',
'scada_tags_url',
'display_config',
'is_online',
'last_online_at',
@@ -55,6 +58,19 @@ class Terminal extends Model
->orderBy('priority');
}
/**
* 获取终端关联的指引
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function guides()
{
return $this->belongsToMany(Guide::class, 'terminal_guides')
->withPivot('priority')
->withTimestamps()
->orderBy('priority');
}
/**
* 获取终端的提示词配置
*

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Policies;
use App\Models\Guide;
use App\Models\User;
class GuidePolicy
{
/**
* 查看指引列表
*/
public function viewAny(User $user): bool
{
return $user->can('guide.view');
}
/**
* 查看指引
*/
public function view(User $user, Guide $guide): bool
{
return $user->can('guide.view');
}
/**
* 创建指引
*/
public function create(User $user): bool
{
return $user->can('guide.create');
}
/**
* 更新指引
*/
public function update(User $user, Guide $guide): bool
{
return $user->can('guide.update');
}
/**
* 删除指引
*/
public function delete(User $user, Guide $guide): bool
{
return $user->can('guide.delete');
}
/**
* 发布指引
*/
public function publish(User $user, Guide $guide): bool
{
return $user->can('guide.publish');
}
/**
* 归档指引
*/
public function archive(User $user, Guide $guide): bool
{
return $user->can('guide.archive');
}
/**
* 恢复已删除的指引
*/
public function restore(User $user, Guide $guide): bool
{
return $user->can('guide.delete');
}
/**
* 永久删除指引
*/
public function forceDelete(User $user, Guide $guide): bool
{
return $user->can('guide.delete');
}
}

View File

@@ -1,95 +0,0 @@
<?php
namespace App\Policies;
use App\Models\SopTemplate;
use App\Models\User;
class SopTemplatePolicy
{
/**
* 查看任何 SOP 模板
*/
public function viewAny(User $user): bool
{
return $user->can('sop-template.view');
}
/**
* 查看 SOP 模板
*/
public function view(User $user, SopTemplate $sopTemplate): bool
{
return $user->can('sop-template.view');
}
/**
* 创建 SOP 模板
*/
public function create(User $user): bool
{
return $user->can('sop-template.create');
}
/**
* 更新 SOP 模板
*/
public function update(User $user, SopTemplate $sopTemplate): bool
{
// 首先检查权限
if (!$user->can('sop-template.update')) {
return false;
}
// 已发布的模板不能直接编辑
if ($sopTemplate->status === 'published') {
return false;
}
return true;
}
/**
* 删除 SOP 模板
*/
public function delete(User $user, SopTemplate $sopTemplate): bool
{
// 首先检查权限
if (!$user->can('sop-template.delete')) {
return false;
}
// 已发布的模板不能删除
if ($sopTemplate->status === 'published') {
return false;
}
return true;
}
/**
* 发布 SOP 模板
*/
public function publish(User $user, SopTemplate $sopTemplate): bool
{
// 首先检查权限
if (!$user->can('sop-template.publish')) {
return false;
}
return $sopTemplate->status === 'draft';
}
/**
* 归档 SOP 模板
*/
public function archive(User $user, SopTemplate $sopTemplate): bool
{
// 首先检查权限
if (!$user->can('sop-template.archive')) {
return false;
}
return $sopTemplate->status === 'published';
}
}

View File

@@ -3,9 +3,9 @@
namespace App\Providers;
use App\Models\Document;
use App\Models\SopTemplate;
use App\Models\Guide;
use App\Observers\DocumentObserver;
use App\Policies\SopTemplatePolicy;
use App\Policies\GuidePolicy;
use Carbon\Carbon;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
@@ -34,7 +34,7 @@ class AppServiceProvider extends ServiceProvider
// 注册策略
Gate::policy(\App\Models\Document::class, \App\Policies\DocumentPolicy::class);
Gate::policy(\App\Models\Terminal::class, \App\Policies\TerminalPolicy::class);
Gate::policy(SopTemplate::class, SopTemplatePolicy::class);
Gate::policy(Guide::class, GuidePolicy::class);
Gate::policy(\Spatie\Permission\Models\Role::class, \App\Policies\RolePolicy::class);
Gate::policy(\App\Models\User::class, \App\Policies\UserPolicy::class);
Gate::policy(\App\Models\SystemSetting::class, \App\Policies\SystemSettingPolicy::class);

View File

@@ -41,6 +41,10 @@ class DocumentSearchService
$searchBuilder->where('uploaded_by', $filters['uploaded_by']);
}
if (!empty($filters['knowledge_base_id'])) {
$searchBuilder->where('knowledge_base_id', $filters['knowledge_base_id']);
}
// 执行搜索并获取结果
$results = $searchBuilder->get();
@@ -103,6 +107,7 @@ class DocumentSearchService
'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,
'updated_at' => $document->updated_at?->timestamp,

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Services;
use App\Models\Terminal;
use App\Models\Document;
use Illuminate\Support\Facades\Log;
class KnowledgeContextService
{
private const MAX_CONTEXT_LENGTH = 2000;
private const TOP_K = 5;
/**
* 搜索终端关联知识库中的文档
*
* @param Terminal $terminal
* @param string $query
* @return array{context: string, sources: array}
*/
public function search(Terminal $terminal, string $query): array
{
$knowledgeBaseIds = $terminal->knowledgeBases->pluck('id')->toArray();
if (empty($knowledgeBaseIds)) {
return [
'context' => '',
'sources' => [],
];
}
try {
// 使用 Scout/Meilisearch 原生过滤(与 DocumentSearchService 一致)
$documents = Document::search($query)
->whereIn('knowledge_base_id', $knowledgeBaseIds)
->take(self::TOP_K)
->get();
} catch (\Exception $e) {
Log::warning('Knowledge search failed', [
'query' => $query,
'error' => $e->getMessage(),
]);
return [
'context' => '',
'sources' => [],
];
}
if ($documents->isEmpty()) {
return [
'context' => '',
'sources' => [],
];
}
$context = '';
$sources = [];
foreach ($documents as $document) {
$snippet = $this->extractSnippet($document);
if (mb_strlen($context) + mb_strlen($snippet) > self::MAX_CONTEXT_LENGTH) {
break;
}
$context .= $snippet . "\n\n";
$sources[] = [
'id' => $document->id,
'title' => $document->title,
'knowledge_base' => $document->knowledgeBase?->name,
];
}
return [
'context' => trim($context),
'sources' => $sources,
];
}
/**
* 从文档中提取摘要片段
*/
private function extractSnippet($document): string
{
$content = $document->markdown_preview ?? $document->description ?? '';
if (mb_strlen($content) <= 500) {
return "{$document->title}\n{$content}";
}
return "{$document->title}\n" . mb_substr($content, 0, 500) . '...';
}
}

View File

@@ -1,222 +0,0 @@
<?php
namespace App\Services;
use App\Models\SopTemplate;
use App\Models\SopTemplateVersion;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class SopTemplateService
{
/**
* 导出模板为 JSON 格式
*
* @param SopTemplate $template
* @return string
*/
public function exportToJson(SopTemplate $template): string
{
$data = [
'template' => [
'name' => $template->name,
'description' => $template->description,
'category' => $template->category,
'tags' => $template->tags,
'version' => $template->version,
'applicable_departments' => $template->applicable_departments,
'applicable_positions' => $template->applicable_positions,
],
'steps' => $template->steps->map(function ($step) {
return [
'step_number' => $step->step_number,
'title' => $step->title,
'content' => $step->content,
'sort_order' => $step->sort_order,
'is_required' => $step->is_required,
'interactive_tasks' => $step->interactiveTasks->map(function ($task) {
return [
'task_type' => $task->task_type,
'task_config' => $task->task_config,
'validation_rules' => $task->validation_rules,
'timeout_seconds' => $task->timeout_seconds,
'is_required' => $task->is_required,
];
})->toArray(),
];
})->toArray(),
'exported_at' => now()->toIso8601String(),
'exported_by' => auth()->user()?->name,
];
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
/**
* JSON 导入模板
*
* @param string $json
* @return SopTemplate
* @throws ValidationException
*/
public function importFromJson(string $json): SopTemplate
{
$data = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw ValidationException::withMessages([
'file' => ['无效的 JSON 格式'],
]);
}
// 验证数据结构
$validator = Validator::make($data, [
'template' => 'required|array',
'template.name' => 'required|string|max:255',
'template.version' => 'required|string|max:50',
'steps' => 'required|array|min:1',
'steps.*.step_number' => 'required|integer',
'steps.*.title' => 'required|string|max:255',
]);
if ($validator->fails()) {
throw new ValidationException($validator);
}
return DB::transaction(function () use ($data) {
// 创建模板
$template = SopTemplate::create([
'name' => $data['template']['name'],
'description' => $data['template']['description'] ?? null,
'category' => $data['template']['category'] ?? null,
'tags' => $data['template']['tags'] ?? [],
'version' => $data['template']['version'],
'status' => 'draft',
'applicable_departments' => $data['template']['applicable_departments'] ?? [],
'applicable_positions' => $data['template']['applicable_positions'] ?? [],
'created_by' => auth()->id(),
]);
// 创建步骤
foreach ($data['steps'] as $stepData) {
$step = $template->steps()->create([
'step_number' => $stepData['step_number'],
'title' => $stepData['title'],
'content' => $stepData['content'] ?? null,
'sort_order' => $stepData['sort_order'] ?? $stepData['step_number'],
'is_required' => $stepData['is_required'] ?? true,
]);
// 创建交互任务
if (!empty($stepData['interactive_tasks'])) {
foreach ($stepData['interactive_tasks'] as $taskData) {
$step->interactiveTasks()->create([
'task_type' => $taskData['task_type'],
'task_config' => $taskData['task_config'] ?? [],
'validation_rules' => $taskData['validation_rules'] ?? [],
'timeout_seconds' => $taskData['timeout_seconds'] ?? null,
'is_required' => $taskData['is_required'] ?? true,
]);
}
}
}
return $template;
});
}
/**
* 发布模板
*
* @param SopTemplate $template
* @param string|null $changeLog
* @return void
*/
public function publish(SopTemplate $template, ?string $changeLog = null): void
{
// 创建版本快照
$this->createVersion($template, $changeLog);
// 更新状态
$template->update([
'status' => 'published',
'published_at' => now(),
]);
}
/**
* 创建版本快照
*
* @param SopTemplate $template
* @param string|null $changeLog
* @return SopTemplateVersion
*/
public function createVersion(SopTemplate $template, ?string $changeLog = null): SopTemplateVersion
{
return SopTemplateVersion::create([
'sop_template_id' => $template->id,
'version' => $template->version,
'change_log' => $changeLog ?? '版本快照',
'content_snapshot' => [
'template' => $template->toArray(),
'steps' => $template->steps->map(function ($step) {
return array_merge($step->toArray(), [
'interactive_tasks' => $step->interactiveTasks->toArray(),
]);
})->toArray(),
],
'created_by' => auth()->id(),
'created_at' => now(),
]);
}
/**
* 归档模板
*
* @param SopTemplate $template
* @return void
*/
public function archive(SopTemplate $template): void
{
$template->update([
'status' => 'archived',
]);
}
/**
* 复制模板
*
* @param SopTemplate $template
* @param string $newName
* @return SopTemplate
*/
public function duplicate(SopTemplate $template, string $newName): SopTemplate
{
return DB::transaction(function () use ($template, $newName) {
// 复制模板
$newTemplate = $template->replicate();
$newTemplate->name = $newName;
$newTemplate->status = 'draft';
$newTemplate->published_at = null;
$newTemplate->created_by = auth()->id();
$newTemplate->save();
// 复制步骤
foreach ($template->steps as $step) {
$newStep = $step->replicate();
$newStep->sop_template_id = $newTemplate->id;
$newStep->save();
// 复制交互任务
foreach ($step->interactiveTasks as $task) {
$newTask = $task->replicate();
$newTask->sop_step_id = $newStep->id;
$newTask->save();
}
}
return $newTemplate;
});
}
}