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

View File

@@ -7,11 +7,14 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->alias([
'identify.terminal' => \App\Http\Middleware\IdentifyTerminal::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@@ -19,6 +19,7 @@
"maatwebsite/excel": "^3.1",
"meilisearch/meilisearch-php": "^1.16",
"phpoffice/phpword": "^1.4",
"solution-forest/filament-tree": "^2.0",
"spatie/laravel-activitylog": "^4.12",
"spatie/laravel-permission": "^6.24"
},

86
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "297bfa700eadd90d170fd39edd1c790d",
"content-hash": "316d29450fc96f2d79ed216639e6cfb2",
"packages": [
{
"name": "abdelhamiderrahmouni/filament-monaco-editor",
@@ -6092,6 +6092,76 @@
],
"time": "2025-02-25T09:09:36+00:00"
},
{
"name": "solution-forest/filament-tree",
"version": "2.1.8",
"source": {
"type": "git",
"url": "https://github.com/solutionforest/filament-tree.git",
"reference": "de8b27c7c58f1e8c8e1a3081dff2e477b4327301"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/solutionforest/filament-tree/zipball/de8b27c7c58f1e8c8e1a3081dff2e477b4327301",
"reference": "de8b27c7c58f1e8c8e1a3081dff2e477b4327301",
"shasum": ""
},
"require": {
"filament/filament": "^3.0",
"filament/support": "^3.0",
"php": "^8.1",
"spatie/laravel-package-tools": "^1.15.0"
},
"require-dev": {
"laravel/pint": "^1.0",
"nunomaduro/collision": "^7.9",
"nunomaduro/larastan": "^2.0.1",
"orchestra/testbench": "^8.0",
"pestphp/pest": "^2.0",
"pestphp/pest-plugin-arch": "^2.0",
"pestphp/pest-plugin-laravel": "^2.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"spatie/laravel-ray": "^1.26"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"SolutionForest\\FilamentTree\\FilamentTreeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SolutionForest\\FilamentTree\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Carly",
"email": "info@solutionforest.net",
"role": "Developer"
}
],
"description": "This is a tree layout plugin for Filament Admin",
"homepage": "https://github.com/solution-forest/filament-tree",
"keywords": [
"Solution Forest",
"filament-tree",
"laravel"
],
"support": {
"issues": "https://github.com/solution-forest/filament-tree/issues",
"source": "https://github.com/solution-forest/filament-tree"
},
"time": "2025-08-11T09:35:33+00:00"
},
{
"name": "spatie/color",
"version": "1.8.0",
@@ -11593,16 +11663,16 @@
},
{
"name": "laravel/pint",
"version": "v1.28.0",
"version": "v1.29.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
"reference": "1feae84bf9c1649d99ba8f7b8193bf0f09f04cc9"
"reference": "bdec963f53172c5e36330f3a400604c69bf02d39"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/1feae84bf9c1649d99ba8f7b8193bf0f09f04cc9",
"reference": "1feae84bf9c1649d99ba8f7b8193bf0f09f04cc9",
"url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39",
"reference": "bdec963f53172c5e36330f3a400604c69bf02d39",
"shasum": ""
},
"require": {
@@ -11619,8 +11689,8 @@
"laravel-zero/framework": "^12.0.5",
"mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest": "^3.8.5",
"shipfastlabs/agent-detector": "^1.0.2"
"pestphp/pest": "^3.8.6",
"shipfastlabs/agent-detector": "^1.1.0"
},
"bin": [
"builds/pint"
@@ -11657,7 +11727,7 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
"time": "2026-03-10T20:37:18+00:00"
"time": "2026-03-12T15:51:39+00:00"
},
{
"name": "laravel/sail",

View File

@@ -14,37 +14,78 @@ return [
[
'id' => 'general_assistant',
'name' => '通用助手',
'description' => '适用于一般性问答和操作指导的AI助手',
'description' => '同步辐射光束线站通用AI助手集成知识库检索和交互式操作引导',
'category' => 'general',
'content' => <<<'TEMPLATE'
# AI助手角色定义
# 角色
你是一个专业的工业生产助手,为 {company_name} 的员工提供帮助
你是{station_id}光束线站的AI助手运行在操作终端「{terminal_name}」上。你的使命是帮助用户安全、高效地完成光束线实验和操作
## 当前上下文
- 用户:{user}{user_role}
- 工作站{station}
- 终端{terminal_name}
## 当前会话上下文
- 用户{user}
- 光束线站{station_id}
- 操作终端:{terminal_name}{terminal_code}
- 时间:{time}
- 班次:{shift}
- 可用知识库:{knowledge_bases}
## 你的职责
1. 回答用户关于生产流程、设备操作的问题
2. 提供安全操作指导
3. 协助查找相关文档和资料
4. 记录和反馈异常情况
## 工具使用策略
## 知识库
你可以参考以下知识库:{knowledge_bases}
你有两个工具可以调用。**必须主动使用**,不要凭记忆回答专业问题。
## 回答原则
- 使用简洁、专业的语言
- 优先考虑安全性
- 如果不确定,建议咨询专业人员
- 保持友好和耐心的态度
### search_knowledge — 知识库检索
**何时调用**
- 用户询问操作规程、设备参数、技术指标、安全规范
- 需要确认具体数值(能量范围、分辨率、束斑尺寸等)
- 涉及标准流程或规章制度
- 你不确定某个专业细节时
**使用要点**
- 提取用户问题的核心概念作为搜索关键词,优先使用专业术语
- 如果首次搜索结果不理想,换用同义词或上下位概念重新搜索
- 回答时基于检索到的内容作答,注明信息来源
### show_guide — 交互式操作引导
**何时调用**
- 用户需要分步操作指导(如"怎么换样品""如何调节能量"
- 遇到故障需要排查流程
- 新用户需要入门引导
- 任何涉及多步骤、有安全风险的操作
**使用要点**
- 可以组合多个指引 ID 按执行顺序调用
- reason 中简要说明触发原因,帮助用户理解
- 指引完成后,根据用户的选择结果提供针对性的后续建议
- 如果用户在指引中选择了异常分支,主动追问详情并给出进一步处理建议
## 回答规范
### 安全准则(最高优先级)
- **辐射安全**:涉及进出实验大厅、打开光闸、联锁系统的操作,必须提醒安全要求
- **真空安全**:涉及破真空、换窗片、样品装卸时,必须确认真空状态和操作顺序
- **电气安全**:涉及高压设备、电源操作时,提醒断电和接地要求
- **危险操作拦截**:如果用户描述的操作可能导致设备损坏或人身伤害,先给出警告,建议联系线站负责人确认后再操作
- 如果你不确定某个操作是否安全,明确告知用户"建议联系线站工作人员确认"
### 对话风格
- 使用简洁专业的语言,避免冗长的铺垫
- 对操作类问题,给出明确的步骤而非笼统建议
- 对参数类问题,给出具体数值和单位
- 如果问题超出你的知识范围,坦诚告知并建议联系线站负责人
- 考虑用户角色:对经验丰富的操作员可以更简练,对访客和新用户需要更详细的解释
### 问题分类处理
1. **快速查询**(参数、状态、简单事实)→ 先调用 search_knowledge 获取准确信息,直接回答
2. **操作指导**(需要分步操作)→ 调用 show_guide 提供交互式引导
3. **故障排查**(设备异常、报警处理)→ 先调用 search_knowledge 了解可能原因,再用 show_guide 引导排查流程
4. **实验咨询**(方案设计、参数优化)→ 调用 search_knowledge 获取相关资料,结合专业知识给出建议
5. **闲聊或非业务问题** 简短友好地回应,引导回光束线相关话题
TEMPLATE
],
[
'id' => 'safety_focused',
'name' => '安全专员',
@@ -78,7 +119,7 @@ TEMPLATE
⚠️ 安全提示:如有任何疑问,请立即停止操作并联系安全主管!
TEMPLATE
],
[
'id' => 'troubleshooting',
'name' => '故障诊断',
@@ -115,7 +156,7 @@ TEMPLATE
💡 提示:详细描述故障现象有助于快速定位问题
TEMPLATE
],
[
'id' => 'training_coach',
'name' => '培训教练',
@@ -157,7 +198,7 @@ TEMPLATE
📚 学习提示:不要着急,每个人都有学习过程,慢慢来!
TEMPLATE
],
[
'id' => 'quality_inspector',
'name' => '质量检查',

View File

@@ -141,10 +141,10 @@ return [
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
'documents' => [
'filterableAttributes' => ['type', 'group_id', 'uploaded_by', 'conversion_status'],
'filterableAttributes' => ['type', 'group_id', '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', 'uploaded_by', 'created_at', 'updated_at'],
'displayedAttributes' => ['id', 'title', 'description', 'type', 'group_id', 'knowledge_base_id', 'uploaded_by', 'created_at', 'updated_at'],
],
],
],

View File

@@ -1,61 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\SopTemplate;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SopTemplate>
*/
class SopTemplateFactory extends Factory
{
protected $model = SopTemplate::class;
public function definition(): array
{
return [
'name' => fake()->randomElement([
'设备启动操作规程',
'产品质检标准流程',
'安全生产检查清单',
'设备维护保养流程',
'应急处理操作指南',
]) . '-' . fake()->numberBetween(1, 100),
'description' => fake()->sentence(20),
'category' => fake()->randomElement(['生产操作', '质量管理', '安全管理', '设备维护', '应急处理']),
'tags' => fake()->randomElements(['标准作业', '安全', '质量', '效率', '培训'], fake()->numberBetween(1, 3)),
'version' => '1.0.0',
'status' => fake()->randomElement(['draft', 'published', 'archived']),
'applicable_departments' => fake()->randomElements(['生产部', '质检部', '设备部', '安全部'], fake()->numberBetween(1, 2)),
'applicable_positions' => fake()->randomElements(['操作员', '质检员', '班组长', '技术员'], fake()->numberBetween(1, 2)),
'published_at' => fake()->optional(0.6)->dateTimeBetween('-6 months', 'now'),
'created_by' => User::factory(),
];
}
public function draft(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'draft',
'published_at' => null,
]);
}
public function published(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'published',
'published_at' => now(),
]);
}
public function archived(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'archived',
'published_at' => fake()->dateTimeBetween('-1 year', '-1 month'),
]);
}
}

View File

@@ -16,7 +16,7 @@ return new class extends Migration
$table->string('name')->comment('终端名称');
$table->string('code', 100)->unique()->comment('终端编码');
$table->string('ip_address', 45)->nullable()->comment('IP地址');
$table->unsignedBigInteger('station_id')->nullable()->comment('线站ID');
$table->string('station_id', 50)->nullable()->comment('线站ID');
$table->string('diagram_url', 500)->nullable()->comment('组态图URL');
$table->json('display_config')->nullable()->comment('显示配置');
$table->boolean('is_online')->default(false)->comment('在线状态');

View File

@@ -1,42 +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('sop_templates', function (Blueprint $table) {
$table->id();
$table->string('name')->comment('模板名称');
$table->text('description')->nullable()->comment('模板描述');
$table->string('category', 100)->nullable()->comment('分类');
$table->json('tags')->nullable()->comment('标签');
$table->string('version', 50)->default('1.0.0')->comment('版本号');
$table->enum('status', ['draft', 'published', 'archived'])->default('draft')->comment('状态');
$table->json('applicable_departments')->nullable()->comment('适用部门');
$table->json('applicable_positions')->nullable()->comment('适用岗位');
$table->timestamp('published_at')->nullable()->comment('发布时间');
$table->unsignedBigInteger('created_by')->nullable()->comment('创建人');
$table->timestamps();
$table->softDeletes();
// 添加索引
$table->index('status', 'idx_sop_templates_status');
$table->index('category', 'idx_sop_templates_category');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sop_templates');
}
};

View File

@@ -1,42 +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('sop_steps', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('sop_template_id')->comment('模板ID');
$table->integer('step_number')->comment('步骤序号');
$table->string('title')->comment('步骤标题');
$table->text('content')->nullable()->comment('步骤内容');
$table->integer('sort_order')->default(0)->comment('排序');
$table->boolean('is_required')->default(true)->comment('是否必需');
$table->timestamps();
// 添加外键约束
$table->foreign('sop_template_id')
->references('id')
->on('sop_templates')
->onDelete('cascade');
// 添加索引
$table->index(['sop_template_id', 'sort_order'], 'idx_template_sort');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sop_steps');
}
};

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('sop_interactive_tasks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('sop_step_id')->comment('步骤ID');
$table->enum('task_type', ['confirm', 'input', 'select', 'photo', 'scan'])->comment('任务类型');
$table->json('task_config')->nullable()->comment('任务配置');
$table->json('validation_rules')->nullable()->comment('验证规则');
$table->integer('timeout_seconds')->nullable()->comment('超时时间');
$table->boolean('is_required')->default(true)->comment('是否必需');
$table->timestamps();
// 添加外键约束
$table->foreign('sop_step_id')
->references('id')
->on('sop_steps')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sop_interactive_tasks');
}
};

View File

@@ -1,41 +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('sop_template_versions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('sop_template_id')->comment('模板ID');
$table->string('version', 50)->comment('版本号');
$table->text('change_log')->nullable()->comment('变更说明');
$table->json('content_snapshot')->nullable()->comment('内容快照');
$table->unsignedBigInteger('created_by')->nullable()->comment('创建人');
$table->timestamp('created_at')->nullable();
// 添加外键约束
$table->foreign('sop_template_id')
->references('id')
->on('sop_templates')
->onDelete('cascade');
// 添加索引
$table->index(['sop_template_id', 'version'], 'idx_template_version');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sop_template_versions');
}
};

View File

@@ -24,8 +24,8 @@ return new class extends Migration
// 终端管理
'terminal.viewAny' => 'terminal.view',
// SOP模板
'sop-template.viewAny' => 'sop-template.view',
// 操作指引
'guide.viewAny' => 'guide.view',
// 分组管理
'group.viewAny' => 'group.view',
@@ -78,7 +78,7 @@ return new class extends Migration
'system-setting.view',
'activity-log.view',
'terminal.view',
'sop-template.view',
'guide.view',
'group.view',
'user.view',
'role.view',

View File

@@ -0,0 +1,60 @@
<?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('guides', function (Blueprint $table) {
$table->id();
$table->string('name')->comment('指引名称');
$table->text('description')->nullable()->comment('指引描述');
$table->string('category', 50)->default('operation')->comment('分类: operation/fault_handling/training');
$table->json('tags')->nullable()->comment('标签');
$table->string('status', 20)->default('draft')->comment('状态: draft/published/archived');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index('category');
$table->index('status');
});
Schema::create('guide_pages', function (Blueprint $table) {
$table->id();
$table->foreignId('guide_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('page_number')->comment('页码');
$table->string('title')->comment('页面标题');
$table->string('html_url', 500)->comment('HTML页面链接');
$table->integer('parent_id')->default(-1);
$table->unsignedInteger('sort_order')->default(0)->comment('排序');
$table->json('options')->nullable();
$table->string('branch_option', 100)->nullable();
$table->timestamps();
$table->index('parent_id');
$table->index(['guide_id', 'sort_order']);
});
Schema::create('terminal_guides', function (Blueprint $table) {
$table->id();
$table->foreignId('terminal_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');
});
}
public function down(): void
{
Schema::dropIfExists('terminal_guides');
Schema::dropIfExists('guide_pages');
Schema::dropIfExists('guides');
}
};

View File

@@ -0,0 +1,27 @@
<?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('terminals', function (Blueprint $table) {
$table->string('mac_address', 17)->nullable()->unique()->after('ip_address')
->comment('MAC地址 (AA:BB:CC:DD:EE:FF)');
$table->string('scada_data_url', 500)->nullable()->after('diagram_url')
->comment('OPC UA网关数据查询地址');
$table->string('scada_tags_url', 500)->nullable()->after('scada_data_url')
->comment('OPC UA网关点位定义查询地址');
});
}
public function down(): void
{
Schema::table('terminals', function (Blueprint $table) {
$table->dropColumn(['mac_address', 'scada_data_url', 'scada_tags_url']);
});
}
};

View File

@@ -0,0 +1,29 @@
<?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

@@ -33,7 +33,7 @@ class DatabaseSeeder extends Seeder
'email' => 'admin@example.com',
'password' => Hash::make('TRG}E^5BvPcbyErc'),
]);
// 为管理员分配 super-admin 角色
$admin->assignRole('super-admin');
@@ -218,13 +218,13 @@ class DatabaseSeeder extends Seeder
]);
$this->command->info('演示数据生成完成!');
// 9. 创建终端数据
$this->call(TerminalSeeder::class);
// 10. 创建SOP模板数据
$this->call(SopTemplateSeeder::class);
// 10. 创建操作指引数据
$this->call(GuideSeeder::class);
$this->command->newLine();
$this->command->info('=== 生成的数据摘要 ===');
$this->command->info('用户数量: ' . User::count());

View File

@@ -0,0 +1,219 @@
<?php
namespace Database\Seeders;
use App\Models\Guide;
use App\Models\GuidePage;
use App\Models\Terminal;
use App\Models\User;
use Illuminate\Database\Seeder;
class GuideSeeder extends Seeder
{
private const BASE_URL = 'https://ssrf.9z.work/guides';
/**
* Run the database seeds.
*/
public function run(): void
{
$this->command->info('开始创建操作指引数据...');
$admin = User::where('email', 'admin@example.com')->first();
$terminals = Terminal::all();
// 1. 如何用光(带分支)
$guide1 = $this->createHowToUseBeamGuide($admin);
// 2. 真空阀门故障处理
$guide2 = $this->createVacuumValveIssueGuide($admin);
// 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('操作指引数据创建完成!');
$this->command->info(' - 指引数量: ' . Guide::count());
$this->command->info(' - 指引页面数量: ' . GuidePage::count());
$this->command->info(' - 关联终端数量: ' . $terminals->count());
}
private function createHowToUseBeamGuide(User $admin): Guide
{
$this->command->info('创建指引: 如何用光...');
$guide = Guide::create([
'name' => '如何用光',
'description' => '光束线用光操作完整流程指引包含前门12和后门两条路径',
'category' => 'operation',
'tags' => ['用光', '光闸', 'PS1', '光学棚屋'],
'status' => 'published',
'created_by' => $admin->id,
'published_at' => now(),
]);
$baseUrl = self::BASE_URL . '/how-to-use-beam';
// 步骤1: 打开光子光闸 PS1根节点
$step1 = GuidePage::create([
'guide_id' => $guide->id,
'page_number' => 1,
'title' => '打开光子光闸 PS1',
'html_url' => "{$baseUrl}/step-1.html",
'parent_id' => -1,
'sort_order' => 0,
]);
// 步骤2: 搜索光学棚屋带选项前门12 / 后门)
$step2 = GuidePage::create([
'guide_id' => $guide->id,
'page_number' => 2,
'title' => '搜索光学棚屋',
'html_url' => "{$baseUrl}/step-2.html",
'parent_id' => $step1->id,
'sort_order' => 1,
'options' => ['前门12', '后门'],
]);
// 步骤3a: 前门12路径 - 检查设备状态
$step3a = GuidePage::create([
'guide_id' => $guide->id,
'page_number' => 3,
'title' => '前门12路径 - 检查设备状态',
'html_url' => "{$baseUrl}/step-3a.html",
'parent_id' => $step2->id,
'sort_order' => 0,
'branch_option' => '前门12',
]);
// 步骤3b: 后门路径 - 安全确认
$step3b = GuidePage::create([
'guide_id' => $guide->id,
'page_number' => 3,
'title' => '后门路径 - 安全确认',
'html_url' => "{$baseUrl}/step-3b.html",
'parent_id' => $step2->id,
'sort_order' => 1,
'branch_option' => '后门',
]);
// 步骤4a: 前门12路径 - 打开实验站光闸
GuidePage::create([
'guide_id' => $guide->id,
'page_number' => 4,
'title' => '前门12路径 - 打开实验站光闸',
'html_url' => "{$baseUrl}/step-4a.html",
'parent_id' => $step3a->id,
'sort_order' => 0,
]);
// 步骤4b: 后门路径 - 设备检查
GuidePage::create([
'guide_id' => $guide->id,
'page_number' => 4,
'title' => '后门路径 - 设备检查',
'html_url' => "{$baseUrl}/step-4b.html",
'parent_id' => $step3b->id,
'sort_order' => 0,
]);
// 步骤5: 完成(根节点,最终汇合)
GuidePage::create([
'guide_id' => $guide->id,
'page_number' => 5,
'title' => '完成',
'html_url' => "{$baseUrl}/step-5.html",
'parent_id' => -1,
'sort_order' => 1,
]);
return $guide;
}
private function createVacuumValveIssueGuide(User $admin): Guide
{
$this->command->info('创建指引: 真空阀门故障处理...');
$guide = Guide::create([
'name' => '真空阀门故障处理',
'description' => '真空阀门异常时的排查和处理流程',
'category' => 'fault_handling',
'tags' => ['真空', '阀门', '故障', '联锁', '气动'],
'status' => 'published',
'created_by' => $admin->id,
'published_at' => now(),
]);
$baseUrl = self::BASE_URL . '/vacuum-valve-issue';
$steps = [
['title' => '检查真空度', 'file' => 'step-1.html'],
['title' => '检查联锁状态', 'file' => 'step-2.html'],
['title' => '尝试手动复位', 'file' => 'step-3.html'],
['title' => '检查气动系统', 'file' => 'step-4.html'],
['title' => '联系维护人员', 'file' => 'step-5.html'],
];
$parentId = -1;
foreach ($steps as $i => $step) {
$page = GuidePage::create([
'guide_id' => $guide->id,
'page_number' => $i + 1,
'title' => $step['title'],
'html_url' => "{$baseUrl}/{$step['file']}",
'parent_id' => $parentId,
'sort_order' => $parentId === -1 ? $i : 0,
]);
$parentId = $page->id;
}
return $guide;
}
private function createWaterLeakAlarmGuide(User $admin): Guide
{
$this->command->info('创建指引: 漏水报警处理...');
$guide = Guide::create([
'name' => '漏水报警处理',
'description' => '漏水报警时的应急处理和复位流程',
'category' => 'fault_handling',
'tags' => ['漏水', '报警', '应急', '复位'],
'status' => 'published',
'created_by' => $admin->id,
'published_at' => now(),
]);
$baseUrl = self::BASE_URL . '/water-leak-alarm';
$steps = [
['title' => '确认报警位置', 'file' => 'step-1.html'],
['title' => '搜索光学棚屋', 'file' => 'step-2.html'],
['title' => '定位并处理漏水点', 'file' => 'step-3.html'],
['title' => '复位报警', 'file' => 'step-4.html'],
['title' => '完成', 'file' => 'step-5.html'],
];
$parentId = -1;
foreach ($steps as $i => $step) {
$page = GuidePage::create([
'guide_id' => $guide->id,
'page_number' => $i + 1,
'title' => $step['title'],
'html_url' => "{$baseUrl}/{$step['file']}",
'parent_id' => $parentId,
'sort_order' => $parentId === -1 ? $i : 0,
]);
$parentId = $page->id;
}
return $guide;
}
}

View File

@@ -40,13 +40,13 @@ class PermissionSeeder extends Seeder
'terminal.delete' => '删除终端',
'terminal.sync' => '同步终端配置',
// SOP模板权限
'sop-template.view' => '查看SOP模板',
'sop-template.create' => '创建SOP',
'sop-template.update' => '编辑SOP',
'sop-template.delete' => '删除SOP',
'sop-template.publish' => '发布SOP',
'sop-template.archive' => '归档SOP',
// 操作指引权限
'guide.view' => '查看指引',
'guide.create' => '创建指引',
'guide.update' => '编辑指引',
'guide.delete' => '删除指引',
'guide.publish' => '发布指引',
'guide.archive' => '归档指引',
// 分组管理权限
'group.view' => '查看分组',
@@ -129,13 +129,13 @@ class PermissionSeeder extends Seeder
'terminal.delete',
'terminal.sync',
// SOP模板
'sop-template.view',
'sop-template.create',
'sop-template.update',
'sop-template.delete',
'sop-template.publish',
'sop-template.archive',
// 操作指引
'guide.view',
'guide.create',
'guide.update',
'guide.delete',
'guide.publish',
'guide.archive',
// 分组管理
'group.view',
@@ -173,8 +173,8 @@ class PermissionSeeder extends Seeder
// 终端管理(仅查看)
'terminal.view',
// SOP模板(仅查看)
'sop-template.view',
// 操作指引(仅查看)
'guide.view',
// 分组管理(仅查看)
'group.view',

View File

@@ -1,237 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\SopTemplate;
use App\Models\SopStep;
use App\Models\SopInteractiveTask;
use App\Models\User;
use Illuminate\Database\Seeder;
class SopTemplateSeeder extends Seeder
{
public function run(): void
{
$this->command->info('开始创建SOP模板数据...');
// 获取或创建一个用户作为创建者
$user = User::first();
if (!$user) {
$this->command->warn('未找到用户跳过SOP模板创建');
return;
}
// 1. 光束线开机流程
$this->command->info('创建光束线开机流程...');
$template1 = SopTemplate::create([
'name' => '光束线标准开机流程',
'description' => '本流程规定了光束线开机前的检查项目、开机步骤和注意事项,确保光束线安全、正常启动。',
'category' => '光束线操作',
'tags' => ['标准作业', '开机流程', '安全'],
'version' => '1.0.0',
'status' => 'published',
'applicable_departments' => ['BL02U1', 'BL07U', 'BL08U', 'BL13HB', 'BL13U', 'BL14B', 'BL14W', 'BL15U', 'BL16B', 'BL16U1'],
'applicable_positions' => ['操作员', '值班员'],
'published_at' => now()->subMonths(2),
'created_by' => $user->id,
]);
$steps1 = [
[
'step_number' => 1,
'title' => '开机前安全检查',
'content' => '<p>检查光束线周围环境,确保无障碍物和安全隐患。</p><ul><li>检查光束线外观是否完好</li><li>检查安全联锁装置是否正常</li><li>检查急停按钮是否正常</li><li>确认辐射防护门关闭</li></ul>',
'sort_order' => 1,
'is_required' => true,
],
[
'step_number' => 2,
'title' => '真空系统检查',
'content' => '<p>检查真空系统状态。</p><ul><li>确认真空泵运行正常</li><li>检查真空度读数</li><li>检查真空阀门状态</li></ul>',
'sort_order' => 2,
'is_required' => true,
],
[
'step_number' => 3,
'title' => '冷却水系统检查',
'content' => '<p>检查冷却水系统。</p><ul><li>确认冷却水流量正常</li><li>检查水温是否在正常范围</li><li>检查冷却水压力</li></ul>',
'sort_order' => 3,
'is_required' => true,
],
[
'step_number' => 4,
'title' => '启动光束线',
'content' => '<p>按照正确顺序启动光束线。</p><ol><li>打开控制系统</li><li>等待系统自检完成</li><li>启动束流</li><li>观察束流参数</li></ol>',
'sort_order' => 4,
'is_required' => true,
],
[
'step_number' => 5,
'title' => '运行状态确认',
'content' => '<p>确认光束线正常运行。</p><ul><li>检查束流强度</li><li>检查束流位置</li><li>检查各项参数是否在正常范围</li></ul>',
'sort_order' => 5,
'is_required' => true,
],
];
foreach ($steps1 as $stepData) {
$step = SopStep::create(array_merge($stepData, ['sop_template_id' => $template1->id]));
// 为第1步添加确认任务
if ($stepData['step_number'] == 1) {
SopInteractiveTask::create([
'sop_step_id' => $step->id,
'task_type' => 'confirm',
'task_config' => [
'title' => '安全检查确认',
'message' => '我已完成所有安全检查项目,确认无安全隐患',
],
'validation_rules' => [],
'timeout_seconds' => 300,
'is_required' => true,
]);
}
// 为第4步添加拍照任务
if ($stepData['step_number'] == 4) {
SopInteractiveTask::create([
'sop_step_id' => $step->id,
'task_type' => 'photo',
'task_config' => [
'title' => '拍摄控制系统状态',
'message' => '请拍摄控制系统界面照片',
'min_photos' => 1,
'max_photos' => 3,
],
'validation_rules' => [],
'timeout_seconds' => 180,
'is_required' => true,
]);
}
}
// 2. 用户实验准备流程
$this->command->info('创建用户实验准备流程...');
$template2 = SopTemplate::create([
'name' => '用户实验准备标准流程',
'description' => '本流程规定了用户实验前的准备工作、样品安装和参数设置步骤。',
'category' => '实验操作',
'tags' => ['用户实验', '标准作业', '样品准备'],
'version' => '1.0.0',
'status' => 'published',
'applicable_departments' => ['BL02U1', 'BL07U', 'BL08U', 'BL13HB', 'BL13U', 'BL14B', 'BL14W', 'BL15U', 'BL16B', 'BL16U1'],
'applicable_positions' => ['操作员', '实验员'],
'published_at' => now()->subMonth(),
'created_by' => $user->id,
]);
$steps2 = [
[
'step_number' => 1,
'title' => '扫描用户机时单',
'content' => '<p>使用扫码枪扫描用户机时单二维码,获取实验信息。</p>',
'sort_order' => 1,
'is_required' => true,
],
[
'step_number' => 2,
'title' => '样品安全检查',
'content' => '<p>检查样品安全性。</p><ul><li>确认样品无放射性</li><li>确认样品无毒性</li><li>确认样品符合实验要求</li></ul>',
'sort_order' => 2,
'is_required' => true,
],
[
'step_number' => 3,
'title' => '样品安装',
'content' => '<p>将样品安装到样品台。</p><ul><li>调整样品位置</li><li>固定样品</li><li>对准光束中心</li></ul>',
'sort_order' => 3,
'is_required' => true,
],
[
'step_number' => 4,
'title' => '实验参数设置',
'content' => '<p>在控制系统中设置实验参数。</p>',
'sort_order' => 4,
'is_required' => true,
],
];
foreach ($steps2 as $stepData) {
$step = SopStep::create(array_merge($stepData, ['sop_template_id' => $template2->id]));
// 为第1步添加扫码任务
if ($stepData['step_number'] == 1) {
SopInteractiveTask::create([
'sop_step_id' => $step->id,
'task_type' => 'scan',
'task_config' => [
'title' => '扫描机时单二维码',
'scan_type' => 'qrcode',
],
'validation_rules' => [
'pattern' => '^[A-Z0-9]{10,20}$',
],
'timeout_seconds' => 60,
'is_required' => true,
]);
}
// 为第2步添加选择任务
if ($stepData['step_number'] == 2) {
SopInteractiveTask::create([
'sop_step_id' => $step->id,
'task_type' => 'select',
'task_config' => [
'title' => '样品安全检查结果',
'options' => ['通过', '不通过'],
],
'validation_rules' => [],
'timeout_seconds' => 120,
'is_required' => true,
]);
}
// 为第3步添加拍照任务
if ($stepData['step_number'] == 3) {
SopInteractiveTask::create([
'sop_step_id' => $step->id,
'task_type' => 'photo',
'task_config' => [
'title' => '拍摄样品安装照片',
'message' => '请拍摄样品安装完成后的照片',
'min_photos' => 1,
'max_photos' => 2,
],
'validation_rules' => [],
'timeout_seconds' => 180,
'is_required' => true,
]);
}
}
// 3. 创建一个草稿模板
$this->command->info('创建草稿模板...');
SopTemplate::create([
'name' => '光束线日常维护流程(草稿)',
'description' => '光束线日常维护保养操作流程,包括清洁、检查、记录等内容。',
'category' => '设备维护',
'tags' => ['维护', '保养'],
'version' => '0.1.0',
'status' => 'draft',
'applicable_departments' => ['BL02U1', 'BL07U', 'BL08U'],
'applicable_positions' => ['维修工', '技术员'],
'published_at' => null,
'created_by' => $user->id,
]);
$this->command->info('SOP模板数据创建完成');
$this->command->newLine();
$this->command->info('=== 生成的SOP模板摘要 ===');
$this->command->info('总模板数量: ' . SopTemplate::count());
$this->command->info(' - 已发布: ' . SopTemplate::where('status', 'published')->count());
$this->command->info(' - 草稿: ' . SopTemplate::where('status', 'draft')->count());
$this->command->info(' - 已归档: ' . SopTemplate::where('status', 'archived')->count());
$this->command->info('总步骤数量: ' . SopStep::count());
$this->command->info('总交互任务数量: ' . SopInteractiveTask::count());
}
}

View File

@@ -37,7 +37,7 @@ class TerminalSeeder extends Seeder
'code' => "SCREEN-{$beamline}",
'ip_address' => $ipAddress,
'station_id' => $beamline,
'diagram_url' => "https://example.com/diagrams/{$beamline}.png",
'diagram_url' => 'https://ssrf.9z.work/scada/demo.html',
'display_config' => [
'resolution' => '3840x2160',
'refresh_rate' => 60,
@@ -46,11 +46,11 @@ class TerminalSeeder extends Seeder
'touch_enabled' => true,
],
'is_online' => in_array($beamline, ['BL02U1', 'BL07U', 'BL08U', 'BL13U', 'BL15U']),
'last_online_at' => in_array($beamline, ['BL02U1', 'BL07U', 'BL08U', 'BL13U', 'BL15U'])
? now()
'last_online_at' => in_array($beamline, ['BL02U1', 'BL07U', 'BL08U', 'BL13U', 'BL15U'])
? now()
: now()->subHours(rand(1, 24)),
]);
// 为每个终端创建提示词
TerminalPrompt::create([
'terminal_id' => $terminal->id,

214
package-lock.json generated
View File

@@ -15,9 +15,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
@@ -32,9 +32,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
@@ -49,9 +49,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
@@ -66,9 +66,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
@@ -83,9 +83,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
@@ -100,9 +100,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
@@ -117,9 +117,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
@@ -134,9 +134,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
@@ -168,9 +168,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
@@ -185,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
@@ -202,9 +202,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
@@ -219,9 +219,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
@@ -236,9 +236,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
@@ -253,9 +253,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
@@ -270,9 +270,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
@@ -287,9 +287,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
@@ -321,9 +321,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
@@ -338,9 +338,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
@@ -355,9 +355,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
@@ -372,9 +372,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
@@ -389,9 +389,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
@@ -406,9 +406,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
@@ -423,9 +423,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
@@ -440,9 +440,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
@@ -1419,9 +1419,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1432,32 +1432,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/escalade": {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 1 - 打开光子光闸 PS1</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 16px; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
</style>
</head>
<body>
<div class="step-title">步骤 1: 打开光子光闸 PS1</div>
<div class="step-image-container">
<img src="images/ps1-panel.jpg" alt="PS1 控制面板" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<rect class="annotation-box" x="20%" y="15%" width="30%" height="25%" stroke="#f59e0b" />
<text class="annotation-label" x="21%" y="12%" fill="#f59e0b">PS1 开关位置</text>
</svg>
</div>
<div class="step-content">
<p>首先需要打开光子光闸 PS1。请问安全光闸 SS1 是否能打开?如果 SS1 无法打开需要先检查人员安全系统PSS状态。</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 2 - 搜索光学棚屋</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 16px; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ul { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
</style>
</head>
<body>
<div class="step-title">步骤 2: 搜索光学棚屋</div>
<div class="step-image-container">
<img src="images/optics-hutch-layout.jpg" alt="光学棚屋布局图" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<rect class="annotation-box" x="8%" y="20%" width="25%" height="20%" stroke="#3b82f6" />
<text class="annotation-label" x="9%" y="17%" fill="#3b82f6">前门12</text>
<rect class="annotation-box" x="60%" y="20%" width="25%" height="20%" stroke="#10b981" />
<text class="annotation-label" x="61%" y="17%" fill="#10b981">后门</text>
</svg>
</div>
<div class="step-content">
<p>光学棚屋设置了 2 种搜索顺序,请选择您要使用的搜索路径:</p>
<ul>
<li><strong>前门12</strong>:从前门进入,适合常规实验</li>
<li><strong>后门</strong>:从后门进入,适合特殊设备维护</li>
</ul>
</div>
</body>
</html>

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 3 - 前门12路径 - 检查设备状态</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ol { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
</style>
</head>
<body>
<div class="step-title">步骤 3: 前门12路径 - 检查设备状态</div>
<div class="step-image-container">
<img src="images/frontend-equipment.jpg" alt="前端设备" class="step-image"
onerror="this.style.display='none'">
</div>
<div class="step-content">
<p>从前门12进入后请检查以下设备状态</p>
<ol>
<li>白光挡板是否正常</li>
<li>单色器冷却水流量</li>
<li>光束位置监测器读数</li>
</ol>
</div>
</body>
</html>

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 3 - 后门路径 - 安全确认</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ol { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
.warning-box {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px;
margin: 16px 0;
border-radius: 4px;
}
.warning-box strong {
color: #d97706;
display: block;
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="step-title">步骤 3: 后门路径 - 安全确认</div>
<div class="step-image-container">
<img src="images/backdoor-safety.jpg" alt="后门安全区域" class="step-image"
onerror="this.style.display='none'">
</div>
<div class="step-content">
<p>从后门进入需要额外的安全确认:</p>
<ol>
<li>确认后门区域无人员</li>
<li>检查后门联锁状态</li>
<li>通知控制室操作人员</li>
</ol>
</div>
<div class="warning-box">
<strong>&#9888;&#65039; 注意</strong>
后门路径需要额外安全授权,请确保已通知控制室后再进入。
</div>
</body>
</html>

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 4 - 前门12路径 - 打开实验站光闸</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
</style>
</head>
<body>
<div class="step-title">步骤 4: 前门12路径 - 打开实验站光闸</div>
<div class="step-image-container">
<img src="images/ss2-panel.jpg" alt="SS2 控制面板" class="step-image"
onerror="this.style.display='none'">
</div>
<div class="step-content">
<p>确认所有设备状态正常后,打开实验站光闸 SS2。观察光束强度是否达到预期值。</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 4 - 后门路径 - 设备检查</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ol { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
</style>
</head>
<body>
<div class="step-title">步骤 4: 后门路径 - 设备检查</div>
<div class="step-image-container">
<img src="images/backdoor-equipment.jpg" alt="后门区域设备" class="step-image"
onerror="this.style.display='none'">
</div>
<div class="step-content">
<p>检查后门区域的关键设备:</p>
<ol>
<li>聚焦镜冷却系统</li>
<li>狭缝位置</li>
<li>光束诊断设备</li>
</ol>
</div>
</body>
</html>

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 5 - 完成</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.success-box {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-left: 4px solid #22c55e;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.success-box .check {
font-size: 20px;
color: #16a34a;
font-weight: bold;
margin-bottom: 12px;
}
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
}
.step-content ul { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
.param-table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
font-size: 14px;
}
.param-table td {
padding: 8px 12px;
border-bottom: 1px solid #e2e8f0;
}
.param-table td:first-child {
font-weight: bold;
color: #0f172a;
width: 120px;
}
.param-table td:last-child {
color: #475569;
}
</style>
</head>
<body>
<div class="step-title">步骤 5: 完成</div>
<div class="success-box">
<div class="check">&#10003; 光束已成功送达实验站!</div>
<div class="step-content">
<p>当前光束参数:</p>
<table class="param-table">
<tr><td>能量</td><td>12.66 keV</td></tr>
<tr><td>通量</td><td>1.2&times;10&sup1;&sup2; ph/s</td></tr>
<tr><td>光斑尺寸</td><td>0.15&times;0.08 mm&sup2;</td></tr>
</table>
<p style="margin-top: 16px;">您现在可以开始实验了。</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 1 - 检查真空度</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 16px; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ul { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
.step-content code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
color: #0f172a;
}
</style>
</head>
<body>
<div class="step-title">步骤 1: 检查真空度</div>
<div class="step-image-container">
<img src="images/vacuum-gauge.jpg" alt="真空计读数面板" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<rect class="annotation-box" x="15%" y="22%" width="25%" height="20%" stroke="#3b82f6" />
<text class="annotation-label" x="16%" y="19%" fill="#3b82f6">上游真空计</text>
<rect class="annotation-box" x="58%" y="22%" width="25%" height="20%" stroke="#10b981" />
<text class="annotation-label" x="59%" y="19%" fill="#10b981">下游真空计</text>
</svg>
</div>
<div class="step-content">
<p>真空阀门无法打开通常是因为真空度不满足联锁条件。</p>
<p>请检查阀门两侧的真空度读数:</p>
<ul>
<li>上游真空度应 &lt; <code>1&times;10&#8315;&#8311; mbar</code></li>
<li>下游真空度应 &lt; <code>1&times;10&#8315;&#8310; mbar</code></li>
</ul>
</div>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 2 - 检查联锁状态</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 16px; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ol { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
</style>
</head>
<body>
<div class="step-title">步骤 2: 检查联锁状态</div>
<div class="step-image-container">
<img src="images/valve-control-panel.jpg" alt="阀门控制面板" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<rect class="annotation-box" x="30%" y="18%" width="35%" height="30%" stroke="#f59e0b" />
<text class="annotation-label" x="31%" y="15%" fill="#f59e0b">联锁指示区</text>
</svg>
</div>
<div class="step-content">
<p>如果真空度满足要求但阀门仍无法打开,检查联锁系统:</p>
<ol>
<li>查看阀门控制面板的联锁指示灯</li>
<li>确认相关设备(离子泵、分子泵)运行正常</li>
<li>检查 EPS 系统是否有相关报警</li>
</ol>
</div>
</body>
</html>

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 3 - 尝试手动复位</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 16px; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ol { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
.warning-box {
background: #fef2f2;
border-left: 4px solid #ef4444;
padding: 12px;
margin: 16px 0;
border-radius: 4px;
}
.warning-box strong {
color: #dc2626;
display: block;
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="step-title">步骤 3: 尝试手动复位</div>
<div class="step-image-container">
<img src="images/valve-reset.jpg" alt="阀门复位操作" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<rect class="annotation-box" x="40%" y="40%" width="18%" height="12%" stroke="#ef4444" />
<text class="annotation-label" x="41%" y="37%" fill="#ef4444">复位按钮</text>
</svg>
</div>
<div class="step-content">
<p>如果联锁状态异常,尝试手动复位:</p>
<ol>
<li>按下阀门控制面板上的"复位"按钮</li>
<li>等待 5 秒</li>
<li>再次尝试打开阀门</li>
</ol>
</div>
<div class="warning-box">
<strong>&#9888;&#65039; 重要提醒</strong>
复位前必须确认真空度满足要求!在真空度不达标时强行复位可能导致设备损坏。
</div>
</body>
</html>

View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 4 - 检查气动系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 16px; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ol { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
.step-content code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
color: #0f172a;
}
</style>
</head>
<body>
<div class="step-title">步骤 4: 检查气动系统</div>
<div class="step-image-container">
<img src="images/pneumatic-system.jpg" alt="气动系统" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<rect class="annotation-box" x="22%" y="28%" width="30%" height="24%" stroke="#06b6d4" />
<text class="annotation-label" x="23%" y="25%" fill="#06b6d4">气动控制单元</text>
</svg>
</div>
<div class="step-content">
<p>如果复位后仍无法打开,检查气动系统:</p>
<ol>
<li>确认压缩空气供应正常(压力 &gt; <code>5 bar</code></li>
<li>检查气动管路是否有泄漏</li>
<li>查看电磁阀是否工作(应有"咔哒"声)</li>
</ol>
</div>
</body>
</html>

View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 5 - 联系维护人员</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.contact-card {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.contact-card h4 {
font-size: 15px;
font-weight: bold;
color: #0c4a6e;
margin-bottom: 12px;
}
.contact-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #e0f2fe;
font-size: 14px;
color: #0369a1;
}
.contact-item:last-child { border-bottom: none; }
.contact-item strong {
min-width: 140px;
color: #0c4a6e;
}
.info-box {
background: #fefce8;
border: 1px solid #fde68a;
border-left: 4px solid #f59e0b;
border-radius: 4px;
padding: 16px;
}
.info-box h4 {
font-size: 14px;
font-weight: bold;
color: #92400e;
margin-bottom: 8px;
}
.info-box ul {
margin-left: 24px;
color: #a16207;
font-size: 13px;
}
.info-box li { margin-bottom: 6px; }
</style>
</head>
<body>
<div class="step-title">步骤 5: 联系维护人员</div>
<div class="step-content">
<p>如果以上步骤都无法解决问题,可能是阀门机械故障或控制系统故障。请联系维护人员。</p>
</div>
<div class="contact-card">
<h4>维护人员联系方式</h4>
<div class="contact-item">
<strong>真空系统负责人</strong>
<span>内线 1234</span>
</div>
<div class="contact-item">
<strong>控制系统负责人</strong>
<span>内线 5678</span>
</div>
</div>
<div class="info-box">
<h4>请记录以下信息以便维护人员排查:</h4>
<ul>
<li>故障时间</li>
<li>真空度读数</li>
<li>已执行的操作步骤</li>
</ul>
</div>
</body>
</html>

View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 1 - 确认报警位置</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 16px; }
.annotation-arrow { stroke-width: 3; fill: none; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.warning-box {
background: #fef2f2;
border-left: 4px solid #ef4444;
padding: 12px;
margin-bottom: 16px;
border-radius: 4px;
}
.warning-box strong {
color: #dc2626;
display: block;
margin-bottom: 8px;
}
.warning-box p {
color: #991b1b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="warning-box">
<strong>&#9888;&#65039; 紧急处理流程</strong>
<p>水泄漏报警触发后应立即按照以下步骤处理,防止设备损坏。如遇大面积漏水,请先关闭主水阀并通知值班负责人。</p>
</div>
<div class="step-title">步骤 1: 确认报警位置</div>
<div class="step-image-container">
<img src="images/eps-cabinet.jpg" alt="EPS 机柜" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill="#ef4444" />
</marker>
</defs>
<rect class="annotation-box" x="30%" y="30%" width="35%" height="20%" stroke="#ef4444" />
<text class="annotation-label" x="31%" y="27%" fill="#ef4444">水泄漏检测模块</text>
<line class="annotation-arrow" x1="48%" y1="50%" x2="48%" y2="60%" stroke="#ef4444" marker-end="url(#arrowhead)" />
<text class="annotation-label" x="50%" y="65%" fill="#ef4444">查看编号</text>
</svg>
</div>
<div class="step-content">
<p>水泄漏报警已触发。首先需要确认漏水点的具体位置。</p>
<p>请查看设备保护系统EPS机柜上的水泄漏检测模块记录显示的数字编号。</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 2 - 搜索光学棚屋</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 14px; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ul { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
</style>
</head>
<body>
<div class="step-title">步骤 2: 搜索光学棚屋</div>
<div class="step-image-container">
<img src="images/optics-hutch-layout.jpg" alt="光学棚屋布局图" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<rect class="annotation-box" x="15%" y="20%" width="20%" height="16%" stroke="#c4b5fd" />
<text class="annotation-label" x="16%" y="17%" fill="#7c3aed">1-10</text>
<rect class="annotation-box" x="42%" y="20%" width="20%" height="16%" stroke="#93c5fd" />
<text class="annotation-label" x="43%" y="17%" fill="#2563eb">11-20</text>
<rect class="annotation-box" x="68%" y="20%" width="20%" height="16%" stroke="#6ee7b7" />
<text class="annotation-label" x="69%" y="17%" fill="#059669">21-30</text>
</svg>
</div>
<div class="step-content">
<p>根据检测模块显示的编号,进入光学棚屋找到对应的水泄漏检测线缆。</p>
<p>线缆编号对应位置:</p>
<ul>
<li><strong>1-10</strong>:单色器区域</li>
<li><strong>11-20</strong>:聚焦镜区域</li>
<li><strong>21-30</strong>:实验站区域</li>
</ul>
</div>
</body>
</html>

View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 3 - 定位并处理漏水点</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 16px; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ol { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
.warning-box {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px;
margin: 16px 0;
border-radius: 4px;
}
.warning-box strong {
color: #d97706;
display: block;
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="step-title">步骤 3: 定位并处理漏水点</div>
<div class="step-image-container">
<img src="images/leak-detection-cable.jpg" alt="漏水检测线缆" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<rect class="annotation-box" x="25%" y="40%" width="50%" height="30%" stroke="#f59e0b" />
<text class="annotation-label" x="26%" y="37%" fill="#f59e0b">检查此区域</text>
</svg>
</div>
<div class="step-content">
<p>找到对应编号的线缆后,沿线缆检查漏水点:</p>
<ol>
<li>查看线缆周围是否有水渍</li>
<li>检查附近的冷却水管接头</li>
<li>用干布擦干水渍</li>
<li>如果是管道漏水,需要关闭该区域冷却水阀门并通知维护人员</li>
</ol>
</div>
<div class="warning-box">
<strong>&#9888;&#65039; 注意</strong>
如果是管道漏水,应立即关闭该区域冷却水阀门,并联系维护人员处理。
</div>
</body>
</html>

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 4 - 复位报警</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.step-image-container {
position: relative;
margin-bottom: 16px;
}
.step-image {
max-width: 100%;
height: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: block;
}
.annotation-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-box { stroke-width: 3; fill: none; }
.annotation-label { font-weight: bold; font-size: 16px; }
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
margin-bottom: 24px;
}
.step-content p { margin-bottom: 12px; }
.step-content ol { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
.warning-box {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px;
margin: 16px 0;
border-radius: 4px;
}
.warning-box strong {
color: #d97706;
display: block;
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="step-title">步骤 4: 复位报警</div>
<div class="step-image-container">
<img src="images/eps-reset.jpg" alt="EPS 复位面板" class="step-image"
onerror="this.style.display='none'">
<svg class="annotation-layer">
<rect class="annotation-box" x="40%" y="35%" width="18%" height="12%" stroke="#10b981" />
<text class="annotation-label" x="41%" y="32%" fill="#10b981">复位按钮</text>
</svg>
</div>
<div class="step-content">
<p>处理完漏水点后,返回 EPS 机柜:</p>
<ol>
<li>确认水泄漏检测模块指示灯已熄灭</li>
<li>按下"复位"按钮</li>
<li>观察系统是否恢复正常</li>
</ol>
</div>
<div class="warning-box">
<strong>&#9888;&#65039; 注意</strong>
如果报警持续,说明漏水未完全处理,需要重新检查。
</div>
</body>
</html>

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步骤 5 - 完成</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
padding: 24px;
background: white;
color: #0f172a;
overflow-y: auto;
}
.step-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 16px;
color: #0f172a;
}
.success-box {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-left: 4px solid #22c55e;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.success-box .check {
font-size: 20px;
color: #16a34a;
font-weight: bold;
margin-bottom: 12px;
}
.step-content {
font-size: 14px;
line-height: 1.6;
color: #475569;
}
.step-content ul { margin-left: 24px; margin-top: 12px; }
.step-content li { margin-bottom: 8px; }
</style>
</head>
<body>
<div class="step-title">步骤 5: 完成</div>
<div class="success-box">
<div class="check">&#10003; 水泄漏报警已成功处理!</div>
<div class="step-content">
<p>后续建议:</p>
<ul>
<li>记录漏水位置和处理方法</li>
<li>如果是管道问题,提交维护工单</li>
<li>增加该区域的巡检频率</li>
</ul>
</div>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

52
public/scada/demo.html Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BL15U 光束线组态图</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #ffffff;
overflow: hidden;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
}
#diagram-container {
width: 100%;
height: 100%;
position: relative;
background: #f8f9fa;
}
#background-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
background: #f8f9fa;
}
</style>
</head>
<body>
<div id="diagram-container">
<img id="background-image" src="beamline-background.png" alt="光束线组态图">
</div>
</body>
</html>

12
routes/api.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
use App\Http\Controllers\Api\TerminalApiController;
use Illuminate\Support\Facades\Route;
Route::middleware('identify.terminal')->prefix('terminal')->group(function () {
Route::get('/config', [TerminalApiController::class, 'config']);
Route::get('/knowledge', [TerminalApiController::class, 'knowledge']);
Route::get('/guides', [TerminalApiController::class, 'guides']);
Route::post('/guides/pages', [TerminalApiController::class, 'guidePages']);
Route::post('/heartbeat', [TerminalApiController::class, 'heartbeat']);
});

View File

@@ -1,246 +0,0 @@
<?php
use App\Models\SopTemplate;
use App\Models\User;
use App\Services\SopTemplateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
$this->service = new SopTemplateService();
});
test('可以导出模板为 JSON', function () {
$template = SopTemplate::factory()->create([
'name' => '测试模板',
'version' => '1.0.0',
]);
$template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'content' => '内容',
'sort_order' => 1,
]);
$json = $this->service->exportToJson($template);
$data = json_decode($json, true);
expect($data)->toHaveKey('template')
->and($data)->toHaveKey('steps')
->and($data['template']['name'])->toBe('测试模板')
->and($data['steps'])->toHaveCount(1);
});
test('导出的 JSON 包含步骤信息', function () {
$template = SopTemplate::factory()->create();
$template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'content' => '<p>这是内容</p>',
'sort_order' => 1,
'is_required' => true,
]);
$json = $this->service->exportToJson($template);
$data = json_decode($json, true);
expect($data['steps'][0])->toHaveKey('step_number')
->and($data['steps'][0])->toHaveKey('title')
->and($data['steps'][0])->toHaveKey('content')
->and($data['steps'][0]['title'])->toBe('第一步');
});
test('可以从 JSON 导入模板', function () {
$jsonData = [
'template' => [
'name' => '导入的模板',
'description' => '这是描述',
'category' => '测试分类',
'version' => '1.0.0',
],
'steps' => [
[
'step_number' => 1,
'title' => '第一步',
'content' => '内容',
'sort_order' => 1,
'is_required' => true,
],
],
];
$json = json_encode($jsonData);
$template = $this->service->importFromJson($json);
expect($template)->toBeInstanceOf(SopTemplate::class)
->and($template->name)->toBe('导入的模板')
->and($template->status)->toBe('draft')
->and($template->steps)->toHaveCount(1);
});
test('导入时会创建步骤', function () {
$jsonData = [
'template' => [
'name' => '测试模板',
'version' => '1.0.0',
],
'steps' => [
[
'step_number' => 1,
'title' => '第一步',
'sort_order' => 1,
],
[
'step_number' => 2,
'title' => '第二步',
'sort_order' => 2,
],
],
];
$json = json_encode($jsonData);
$template = $this->service->importFromJson($json);
expect($template->steps)->toHaveCount(2)
->and($template->steps->first()->title)->toBe('第一步')
->and($template->steps->last()->title)->toBe('第二步');
});
test('导入无效 JSON 会抛出异常', function () {
$invalidJson = 'invalid json';
$this->service->importFromJson($invalidJson);
})->throws(\Illuminate\Validation\ValidationException::class);
test('导入缺少必需字段会抛出异常', function () {
$jsonData = [
'template' => [
'name' => '测试模板',
// 缺少 version
],
'steps' => [],
];
$json = json_encode($jsonData);
$this->service->importFromJson($json);
})->throws(\Illuminate\Validation\ValidationException::class);
test('可以发布模板', function () {
$template = SopTemplate::factory()->create([
'status' => 'draft',
]);
$template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'sort_order' => 1,
]);
$this->service->publish($template, '首次发布');
expect($template->fresh()->status)->toBe('published')
->and($template->fresh()->published_at)->not->toBeNull()
->and($template->versions)->toHaveCount(1);
});
test('发布时会创建版本快照', function () {
$template = SopTemplate::factory()->create([
'status' => 'draft',
'version' => '1.0.0',
]);
$template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'sort_order' => 1,
]);
$this->service->publish($template, '测试发布');
$version = $template->versions->first();
expect($version)->not->toBeNull()
->and($version->version)->toBe('1.0.0')
->and($version->change_log)->toBe('测试发布')
->and($version->content_snapshot)->toHaveKey('template')
->and($version->content_snapshot)->toHaveKey('steps');
});
test('可以归档模板', function () {
$template = SopTemplate::factory()->create([
'status' => 'published',
]);
$this->service->archive($template);
expect($template->fresh()->status)->toBe('archived');
});
test('可以复制模板', function () {
$template = SopTemplate::factory()->create([
'name' => '原始模板',
]);
$template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'sort_order' => 1,
]);
$newTemplate = $this->service->duplicate($template, '复制的模板');
expect($newTemplate->name)->toBe('复制的模板')
->and($newTemplate->status)->toBe('draft')
->and($newTemplate->steps)->toHaveCount(1)
->and($newTemplate->id)->not->toBe($template->id);
});
test('复制模板时会复制所有步骤', function () {
$template = SopTemplate::factory()->create();
$template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'sort_order' => 1,
]);
$template->steps()->create([
'step_number' => 2,
'title' => '第二步',
'sort_order' => 2,
]);
$newTemplate = $this->service->duplicate($template, '复制的模板');
expect($newTemplate->steps)->toHaveCount(2)
->and($newTemplate->steps->first()->title)->toBe('第一步')
->and($newTemplate->steps->last()->title)->toBe('第二步');
});
test('复制模板时会复制交互任务', function () {
$template = SopTemplate::factory()->create();
$step = $template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'sort_order' => 1,
]);
$step->interactiveTasks()->create([
'task_type' => 'confirm',
'task_config' => ['message' => '确认?'],
'is_required' => true,
]);
$newTemplate = $this->service->duplicate($template, '复制的模板');
$newStep = $newTemplate->steps->first();
expect($newStep->interactiveTasks)->toHaveCount(1)
->and($newStep->interactiveTasks->first()->task_type)->toBe('confirm');
});

View File

@@ -1,200 +0,0 @@
<?php
use App\Models\SopTemplate;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
test('可以创建 SOP 模板', function () {
$template = SopTemplate::factory()->create([
'name' => '测试模板',
'status' => 'draft',
'created_by' => $this->user->id,
]);
expect($template->name)->toBe('测试模板')
->and($template->status)->toBe('draft')
->and($template->created_by)->toBe($this->user->id);
});
test('可以为模板添加步骤', function () {
$template = SopTemplate::factory()->create();
$step = $template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'content' => '这是第一步的内容',
'sort_order' => 1,
'is_required' => true,
]);
expect($template->steps)->toHaveCount(1)
->and($step->title)->toBe('第一步')
->and($step->template->id)->toBe($template->id);
});
test('步骤按 sort_order 排序', function () {
$template = SopTemplate::factory()->create();
$template->steps()->create([
'step_number' => 3,
'title' => '第三步',
'sort_order' => 3,
]);
$template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'sort_order' => 1,
]);
$template->steps()->create([
'step_number' => 2,
'title' => '第二步',
'sort_order' => 2,
]);
$steps = $template->fresh()->steps;
expect($steps)->toHaveCount(3)
->and($steps->first()->title)->toBe('第一步')
->and($steps->last()->title)->toBe('第三步');
});
test('可以为步骤添加交互任务', function () {
$template = SopTemplate::factory()->create();
$step = $template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'sort_order' => 1,
]);
$task = $step->interactiveTasks()->create([
'task_type' => 'confirm',
'task_config' => ['message' => '确认完成?'],
'validation_rules' => [],
'is_required' => true,
]);
expect($step->interactiveTasks)->toHaveCount(1)
->and($task->task_type)->toBe('confirm')
->and($task->step->id)->toBe($step->id);
});
test('模板状态可以从草稿变为已发布', function () {
$template = SopTemplate::factory()->create([
'status' => 'draft',
]);
$template->update([
'status' => 'published',
'published_at' => now(),
]);
expect($template->fresh()->status)->toBe('published')
->and($template->fresh()->published_at)->not->toBeNull();
});
test('模板状态可以从已发布变为已归档', function () {
$template = SopTemplate::factory()->create([
'status' => 'published',
'published_at' => now(),
]);
$template->update(['status' => 'archived']);
expect($template->fresh()->status)->toBe('archived');
});
test('可以创建模板版本快照', function () {
$template = SopTemplate::factory()->create();
$template->steps()->create([
'step_number' => 1,
'title' => '第一步',
'sort_order' => 1,
]);
$version = $template->versions()->create([
'version' => '1.0.0',
'change_log' => '首次发布',
'content_snapshot' => [
'template' => $template->toArray(),
'steps' => $template->steps->toArray(),
],
'created_by' => $this->user->id,
'created_at' => now(),
]);
expect($template->versions)->toHaveCount(1)
->and($version->version)->toBe('1.0.0')
->and($version->content_snapshot)->toHaveKey('template')
->and($version->content_snapshot)->toHaveKey('steps');
});
test('模板记录活动日志', function () {
$template = SopTemplate::factory()->create([
'name' => '原始名称',
]);
$template->update(['name' => '新名称']);
$activities = $template->activities;
expect($activities)->toHaveCount(2) // created 和 updated
->and($activities->last()->description)->toContain('updated');
});
test('可以通过分类筛选模板', function () {
SopTemplate::factory()->create(['category' => '安全操作']);
SopTemplate::factory()->create(['category' => '设备维护']);
SopTemplate::factory()->create(['category' => '安全操作']);
$safetyTemplates = SopTemplate::where('category', '安全操作')->get();
expect($safetyTemplates)->toHaveCount(2);
});
test('可以通过状态筛选模板', function () {
SopTemplate::factory()->create(['status' => 'draft']);
SopTemplate::factory()->create(['status' => 'published']);
SopTemplate::factory()->create(['status' => 'draft']);
$draftTemplates = SopTemplate::where('status', 'draft')->get();
expect($draftTemplates)->toHaveCount(2);
});
test('模板的标签字段正确转换为数组', function () {
$template = SopTemplate::factory()->create([
'tags' => ['标签1', '标签2', '标签3'],
]);
expect($template->fresh()->tags)->toBeArray()
->and($template->fresh()->tags)->toHaveCount(3)
->and($template->fresh()->tags)->toContain('标签1');
});
test('模板的适用部门字段正确转换为数组', function () {
$template = SopTemplate::factory()->create([
'applicable_departments' => ['生产部', '质检部'],
]);
expect($template->fresh()->applicable_departments)->toBeArray()
->and($template->fresh()->applicable_departments)->toHaveCount(2);
});
test('可以软删除模板', function () {
$template = SopTemplate::factory()->create();
$templateId = $template->id;
$template->delete();
expect(SopTemplate::find($templateId))->toBeNull()
->and(SopTemplate::withTrashed()->find($templateId))->not->toBeNull();
});

View File

@@ -21,13 +21,13 @@ class TerminalResourceTest extends TestCase
protected function setUp(): void
{
parent::setUp();
// 创建测试用户并赋予管理员权限
$this->user = User::factory()->create();
// 设置为Filament管理员
config(['filament.auth.guard' => 'web']);
$this->actingAs($this->user);
}
@@ -62,7 +62,7 @@ class TerminalResourceTest extends TestCase
'code' => 'TEST-0001',
'ip_address' => '192.168.1.100',
'station_id' => 1,
'diagram_url' => 'https://example.com/diagram.png',
'diagram_url' => 'https://example.com/diagram.html',
'display_config' => [
'resolution' => '1920x1080',
'refresh_rate' => '60',
@@ -250,7 +250,7 @@ class TerminalResourceTest extends TestCase
// 测试分组功能是否可用
$component = Livewire::test(ListTerminals::class);
// 验证表格可以正常渲染
$component->assertSuccessful();
}

View File

@@ -48,7 +48,7 @@ class TerminalSyncTest extends TestCase
// 断言任务已加入队列
Queue::assertPushed(SyncTerminalConfigJob::class, function ($job) use ($terminal, $log) {
return $job->terminal->id === $terminal->id
return $job->terminal->id === $terminal->id
&& $job->log->id === $log->id;
});
}
@@ -64,7 +64,7 @@ class TerminalSyncTest extends TestCase
'code' => 'TEST-002',
'ip_address' => '192.168.1.100',
'station_id' => 1,
'diagram_url' => 'https://example.com/diagram.png',
'diagram_url' => 'https://example.com/diagram.html',
'display_config' => ['resolution' => '1920x1080'],
]);
@@ -117,7 +117,7 @@ class TerminalSyncTest extends TestCase
// 断言创建了3个同步日志
$this->assertCount(3, $logs);
// 断言每个终端都有同步日志
foreach ($terminals as $terminal) {
$this->assertDatabaseHas('terminal_sync_logs', [