refactor: 修复知识库和操作指引
This commit is contained in:
@@ -65,7 +65,7 @@ class ActivityLogExport implements FromQuery, WithHeadings, WithMapping, WithSty
|
||||
'Document' => '文档',
|
||||
'Group' => '分组',
|
||||
'Terminal' => '终端',
|
||||
'SopTemplate' => 'SOP模板',
|
||||
'Guide' => '操作指引',
|
||||
default => $className,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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('全部类型'),
|
||||
])
|
||||
|
||||
@@ -61,7 +61,7 @@ class ViewActivityLog extends ViewRecord
|
||||
'Document' => '文档',
|
||||
'Group' => '分组',
|
||||
'Terminal' => '终端',
|
||||
'SopTemplate' => 'SOP模板',
|
||||
'Guide' => '操作指引',
|
||||
default => $className,
|
||||
};
|
||||
}),
|
||||
|
||||
204
app/Filament/Resources/GuideResource.php
Normal file
204
app/Filament/Resources/GuideResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Filament/Resources/GuideResource/Pages/CreateGuide.php
Normal file
22
app/Filament/Resources/GuideResource/Pages/CreateGuide.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
app/Filament/Resources/GuideResource/Pages/EditGuide.php
Normal file
32
app/Filament/Resources/GuideResource/Pages/EditGuide.php
Normal 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;
|
||||
}
|
||||
}
|
||||
20
app/Filament/Resources/GuideResource/Pages/ListGuides.php
Normal file
20
app/Filament/Resources/GuideResource/Pages/ListGuides.php
Normal 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('创建指引'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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}") ?? [];
|
||||
|
||||
@@ -80,7 +80,7 @@ class ViewRole extends ViewRecord
|
||||
'system-setting' => '⚙️ 系统设置',
|
||||
'activity-log' => '📋 操作日志',
|
||||
'terminal' => '🖥️ 终端管理',
|
||||
'sop-template' => '📝 SOP模板',
|
||||
'guide' => '📖 操作指引',
|
||||
'group' => '👥 分组管理',
|
||||
'user' => '👤 用户管理',
|
||||
'role' => '🛡️ 角色管理',
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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}") ?? [];
|
||||
|
||||
@@ -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' => '🛡️ 角色管理',
|
||||
|
||||
227
app/Http/Controllers/Api/TerminalApiController.php
Normal file
227
app/Http/Controllers/Api/TerminalApiController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
38
app/Http/Middleware/IdentifyTerminal.php
Normal file
38
app/Http/Middleware/IdentifyTerminal.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
75
app/Models/Guide.php
Normal 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
58
app/Models/GuidePage.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取终端的提示词配置
|
||||
*
|
||||
|
||||
81
app/Policies/GuidePolicy.php
Normal file
81
app/Policies/GuidePolicy.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
94
app/Services/KnowledgeContextService.php
Normal file
94
app/Services/KnowledgeContextService.php
Normal 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) . '...';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user