hazop插件和jsa插件修改完成

This commit is contained in:
2026-09-09 17:10:16 +08:00
parent d7b1f4b2ef
commit 000178f04d
6 changed files with 834 additions and 660 deletions
+208 -260
View File
@@ -2,35 +2,18 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { useDialog } from 'naive-ui';
import { peopleListApi } from '@/service/api/system';
import {
hazopNodesApi,
hazopNodeAddApi,
hazopObjAddApi,
hazopDevAddApi,
hazopNodeEditApi,
hazopObjEditApi,
hazopDevEditApi,
hazopNodeDelApi,
hazopObjDelApi,
hazopDevDelApi,
hazopRowAddApi,
hazopRowEditApi,
hazopRowDelApi,
} from "@/service/api/plugin";
import { hazopNodesApi, hazopSaveApi } from "@/service/api/plugin";
const themeStore = useThemeStore();
const dialog = useDialog();
const props = defineProps(['isAiOpen','currentId','hazopRiskMatrixConfig'])
const emit = defineEmits(['close','toTable']);
const loading = ref<boolean>(false);
const treeLoading = ref<boolean>(false);
const analyzing = ref(false);
// 删除确认弹窗
const delModal = ref<boolean>(false);
// 删除确定状态
const delLoading = ref<boolean>(false);
// 新增/编辑弹窗
const editModal = ref<boolean>(false);
// 当前弹框标题
@@ -39,8 +22,6 @@ const currentModalTitle = ref<string>('');
const currentModalType = ref<string>('');
// 当前弹框对象
const currentModalObj = ref<string>('');
// 弹窗保存状态
const modalSaving = ref<boolean>(false);
const name = ref<string>('');
const selectOptions = computed(() => {
@@ -74,11 +55,14 @@ const contextMenuShow = ref<boolean>(false)
const contextMenuX = ref<number>(0)
const contextMenuY = ref<number>(0)
// 存储当前右键点击的目标数据
const contextTarget = ref<{type: string, node: any, obj: any, dev: any}>({
const contextTarget = ref<{type: string, node: any, obj: any, dev: any, nodeIndex: number, objIndex: number, devIndex: number}>({
type: '', // 'node' | 'obj' | 'dev'
node: null,
obj: null,
dev: null,
nodeIndex: -1,
objIndex: -1,
devIndex: -1,
})
// 当前节点
const currentNode = computed(() => {
@@ -142,61 +126,66 @@ const recTotal = computed(() => {
return totalRows;
});
const workers = ref<{id:number,name:string,org:string}[]>([]);
// 人员列表源数据
const workers = ref<{user_id:number,user_name:string,org_name?:string}[]>([]);
// 选中的人员
const selectedWorkers = ref<number[]>([]);
const selectedWorkers = ref<{user_id:number,user_name:string,org_name?:string}[]>([]);
// 选择分析人员弹窗
const openSelectWorker = ref(false);
// 搜索分析人员
const searchWorker = ref("");
// 分析人员列表
const workerList = computed(() => workers.value.filter((x:{id:number,name:string,org:string}) => !searchWorker.value || x.name.includes(searchWorker.value)));
const workerList = computed(() => workers.value.filter((x:{user_id:number,user_name:string,org_name?:string}) => !searchWorker.value || x.user_name.includes(searchWorker.value)));
// 选择分析人员
const selectWorker = (n: number) => {
selectedWorkers.value = selectedWorkers.value.includes(n) ? selectedWorkers.value.filter((x:number) => x !== n) : [...selectedWorkers.value, n];
const selectWorker = (n: {user_id:number,user_name:string,org_name?:string}) => {
selectedWorkers.value = selectedWorkers.value.includes(n) ? selectedWorkers.value.filter((x:any) => x.user_id !== n.user_id) : [...selectedWorkers.value, n];
};
// 匹配是否有人员
const hasWorker = (id:number) => {
return selectedWorkers.value.some((item:{user_id:number,user_name:string,org_name?:string}) => item.user_id === id);
};
// 保存
const save = () => {
window.$message?.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
const save = async (isClick: boolean = false) => {
loading.value = isClick;
const workers = (selectedWorkers.value ?? []).map((item: { org_name?:string; user_id: number; user_name: string }) => {
const { org_name, ...rest } = item;
return rest;
});
const {error} = await hazopSaveApi(props.currentId, {
nodes: nodes.value,
users: workers,
});
if(!error){
if(isClick){
window.$message?.success("保存成功");
}
}
loading.value = false;
};
/**
* 根据行 ID 查找并返回带有父级名称的行对象
* @param {Array} data - 原始 JSON 数据数组
* @param {Number|String} rowId - 要查找的行 ID
* @returns {Object|null} - 增强后的行对象,或 null(未找到)
* 根据行下标 查找并返回带有父级名称的行对象
*/
const findRowWithParents = (data:any, rowId:any) => {
for (const node of data) {
const node_ame = node.node_name;
for (const obj of node.objects) {
const obj_ame = obj.obj_name;
for (const dev of obj.deviations) {
const deviation = dev.deviation;
for (const row of dev.rows) {
if (row.id === rowId) {
return {
...row,
scene: `${row.cause?row.cause+';':''}${deviation?deviation+';':''}${row.consequence?'可能导致:'+row.consequence:''}`,
source: 'HAZOP',
risk_item: `${deviation}${node_ame?`${node_ame}${obj_ame?`-${obj_ame}`:''}`:''}`,
safe: row?.safeguards?.map((item:any) => item.name).join('') || '',
suggestion: row?.suggestions?.map((item:any) => item.name).join('') || '',
to_pssr: 0
};
}
}
}
}
const findRowWithParents = (rowIndex: number = -1) => {
const row = currentDev.value.rows[rowIndex];
if(row){
return {
...row,
scene: `${row.cause?row.cause+';':''}${currentDev.value?.deviation+';'}${row.consequence?'可能导致:'+row.consequence:''}`,
source: 'HAZOP',
risk_item: `${currentDev.value?.deviation}${currentNode.value?.node_name?`${currentNode.value?.node_name}${currentObj.value?.obj_name?`-${currentObj.value?.obj_name}`:''}`:''}`,
safe: row?.safeguards?.map((item:any) => item.name).join('') || '',
suggestion: row?.suggestions?.map((item:any) => item.name).join('') || '',
to_pssr: 0
};
}
return null; // 未找到
return null;
}
// AI整理如表、入表
const enterToTable = (type: string, row: any) => {
const enterToTable = (type: string, rowIndex: number = -1) => {
if(type==='single'){
const enrichedRow = findRowWithParents(nodes.value, row.id);
const enrichedRow = findRowWithParents(rowIndex);
emit('toTable',type,enrichedRow?[enrichedRow]:[]);
}
if(type==='all'){
@@ -283,11 +272,12 @@ const addSafeguard = (index: number) => {
}else{
currentDev.value.rows[index].safeguards = [{name:'',value:''}];
}
save()
}
// 删除安全措施
const delSafeguard = (index: number, safeguardIndex: number, row: any) => {
const delSafeguard = (index: number, safeguardIndex: number) => {
currentDev.value.rows[index].safeguards.splice(safeguardIndex, 1);
editRow(row)
save()
}
// 新增建议措施
const addSuggestion = (index: number) => {
@@ -296,11 +286,12 @@ const addSuggestion = (index: number) => {
}else{
currentDev.value.rows[index].suggestions = [{name:'',value:''}];
}
save()
}
// 删除建议措施
const delSuggestion = (index: number, suggestionIndex: number, row: any) => {
const delSuggestion = (index: number, suggestionIndex: number) => {
currentDev.value.rows[index].suggestions.splice(suggestionIndex, 1);
editRow(row)
save()
}
// 新增行
@@ -308,8 +299,7 @@ const addRow = async () => {
if(!currentDev.value){
return window.$message?.warning("请先在左侧选择偏离")
}
loading.value = true;
const {data,error} = await hazopRowAddApi(currentDev.value?.id,{
currentDev.value.rows.push({
cause: "",
consequence: "",
l_value: l_options.value[0]?.key || 1,
@@ -317,33 +307,12 @@ const addRow = async () => {
risk_value: riskLevel(l_options.value[0]?.key || 1, s_options.value[0]?.key || 1)?.name || '',
safeguards: [],
suggestions: [],
})
if(!error){
currentDev.value.rows.push(data);
}
loading.value = false;
});
};
// 编辑行
const editRow = async (row: any) => {
const {error} = await hazopRowEditApi(row.id,{
cause: row.cause,
consequence: row.consequence,
l_value: row.l_value,
s_value: row.s_value,
risk_value: riskLevel(row.l_value, row.s_value)?.name || '',
safeguards: row.safeguards,
suggestions: row.suggestions,
})
}
// 删除行
const delRow = async (index: number, row_id: number) => {
loading.value = true;
const {error} = await hazopRowDelApi(row_id);
if(!error){
currentDev.value.rows.splice(index, 1);
window.$message?.success("删除成功")
}
loading.value = false;
const delRow = async (index: number) => {
currentDev.value.rows.splice(index, 1);
save()
}
// 展开收起
@@ -369,101 +338,102 @@ const modalSave = async () => {
if(name.value===''){
return window.$message?.warning("名称不能为空")
}
modalSaving.value = true;
// 新增节点
if(currentModalType.value==='add' && currentModalObj.value==='node'){
const {error} = await hazopNodeAddApi(props.currentId,{node_name: name.value});
if(!error){
getHazopNodes();
window.$message?.success("新增节点成功")
editModal.value = false;
}
nodes.value.push({id:0,node_name:name.value,change_id:props.currentId,objects:[]});
window.$message?.success("新增节点成功")
editModal.value = false;
}
// 编辑节点
if(currentModalType.value==='edit' && currentModalObj.value==='node'){
const {error} = await hazopNodeEditApi(contextTarget.value?.node?.id,{node_name: name.value});
if(!error){
getHazopNodes();
window.$message?.success("编辑节点成功")
editModal.value = false;
}
nodes.value[contextTarget.value?.nodeIndex].node_name = name.value;
window.$message?.success("编辑节点成功")
editModal.value = false;
}
// 新增分析对象
if(currentModalType.value==='add' && currentModalObj.value==='obj'){
const {error} = await hazopObjAddApi(contextTarget.value?.node?.id,{obj_name: name.value});
if(!error){
getHazopNodes();
window.$message?.success("新增分析对象成功")
editModal.value = false;
let obj = {
id: 0,
obj_name: name.value,
node_id: nodes.value[contextTarget.value?.nodeIndex]?.id,
deviations: []
}
if(nodes.value[contextTarget.value?.nodeIndex].objects){
nodes.value[contextTarget.value?.nodeIndex].objects.push(obj);
}else{
nodes.value[contextTarget.value?.nodeIndex].objects = [obj];
}
window.$message?.success("新增分析对象成功")
editModal.value = false;
}
// 编辑分析对象
if(currentModalType.value==='edit' && currentModalObj.value==='obj'){
const {error} = await hazopObjEditApi(contextTarget.value?.obj?.id,{obj_name: name.value});
if(!error){
getHazopNodes();
window.$message?.success("编辑分析对象成功")
editModal.value = false;
}
nodes.value[contextTarget.value?.nodeIndex].objects[contextTarget.value?.objIndex].obj_name = name.value;
window.$message?.success("编辑分析对象成功")
editModal.value = false;
}
// 新增偏离
if(currentModalType.value==='add' && currentModalObj.value==='dev'){
const {error} = await hazopDevAddApi(contextTarget.value?.obj?.id,{deviation: name.value});
if(!error){
getHazopNodes();
window.$message?.success("新增偏离成功")
editModal.value = false;
let dev = {
id: 0,
object_id: nodes.value[contextTarget.value?.nodeIndex].objects[contextTarget.value?.objIndex].id,
deviation: name.value,
rows: []
}
if(nodes.value[contextTarget.value?.nodeIndex].objects[contextTarget.value?.objIndex].deviations){
nodes.value[contextTarget.value?.nodeIndex].objects[contextTarget.value?.objIndex].deviations.push(dev);
}else {
nodes.value[contextTarget.value?.nodeIndex].objects[contextTarget.value?.objIndex].deviations = [dev];
}
window.$message?.success("新增偏离成功")
editModal.value = false;
}
// 编辑偏离
if(currentModalType.value==='edit' && currentModalObj.value==='dev'){
const {error} = await hazopDevEditApi(contextTarget.value?.dev?.id,{deviation: name.value});
if(!error){
getHazopNodes();
window.$message?.success("编辑偏离成功")
editModal.value = false;
}
nodes.value[contextTarget.value?.nodeIndex].objects[contextTarget.value?.objIndex].deviations[contextTarget.value?.devIndex].deviation = name.value;
window.$message?.success("编辑偏离成功")
editModal.value = false;
}
modalSaving.value = false;
}
// 打开删除弹框
const openDelModal = (type: string) => {
currentModalType.value = type;
delModal.value = true;
}
// 删除确认
const delConfirm = async () => {
delLoading.value = true;
// 删除节点
if(currentModalType.value==='node'){
const {error} = await hazopNodeDelApi(contextTarget.value?.node?.id);
if(!error){
getHazopNodes();
window.$message?.success("删除节点成功")
delModal.value = false;
}
let txt = ''
if(type==='node'){
txt = `确定删除节点「${contextTarget.value?.node?.node_name}」吗?`
}
// 删除分析对象
if(currentModalType.value==='obj'){
const {error} = await hazopObjDelApi(contextTarget.value?.obj?.id);
if(!error){
getHazopNodes();
window.$message?.success("删除分析对象成功")
delModal.value = false;
}
if(type==='obj'){
txt = `确定删除分析对象「${contextTarget.value?.obj?.obj_name}」吗?`
}
// 删除偏离
if(currentModalType.value==='dev'){
const {error} = await hazopDevDelApi(contextTarget.value?.dev?.id);
if(!error){
getHazopNodes();
window.$message?.success("删除偏离成功")
delModal.value = false;
}
if(type==='dev'){
txt = `确定删除偏离「${contextTarget.value?.dev?.deviation}」吗?`
}
delLoading.value = false;
dialog.info({
title: '删除',
content: txt,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
// 删除节点
if(type==='node'){
nodes.value.splice(contextTarget.value?.nodeIndex, 1);
window.$message?.success("删除节点成功")
}
// 删除分析对象
if(type==='obj'){
nodes.value[contextTarget.value?.nodeIndex].objects.splice(contextTarget.value?.objIndex, 1);
window.$message?.success("删除分析对象成功")
}
// 删除偏离
if(type==='dev'){
nodes.value[contextTarget.value?.nodeIndex].objects[contextTarget.value?.objIndex].deviations.splice(contextTarget.value?.devIndex, 1);
window.$message?.success("删除偏离成功")
}
},
onNegativeClick: () => {
}
})
}
// 菜单选项配置(根据不同层级返回不同菜单)
@@ -473,18 +443,15 @@ const getMenuOptions = (type: string) => {
{ label: '编辑节点', key: 'editNode' },
{ label: '删除节点', key: 'delNode' },
]
const objOptions = [
{ label: '新增偏离', key: 'addDev' },
{ label: '编辑分析对象', key: 'editObj' },
{ label: '删除分析对象', key: 'delObj' },
]
const devOptions = [
{ label: '编辑分离', key: 'editDev' },
{ label: '删除分离', key: 'delDev' },
]
const map:any = {
node: nodeOptions,
obj: objOptions,
@@ -503,11 +470,11 @@ const closeMenu = () => {
}
// 右键菜单事件处理
const handleContextMenu = (event: MouseEvent, type: string, node: any, obj = null, dev = null) => {
const handleContextMenu = (event: MouseEvent, type: string, node: any, obj: any, dev: any, nodeIndex: number, objIndex: number, devIndex: number) => {
// 阻止浏览器默认右键菜单
event.preventDefault()
// 记录目标数据
contextTarget.value = { type, node, obj, dev }
contextTarget.value = { type, node, obj, dev, nodeIndex, objIndex, devIndex }
// 计算菜单位置
contextMenuX.value = Math.max(0, event.clientX)
contextMenuY.value = Math.max(0, event.clientY)
@@ -576,12 +543,14 @@ if (typeof window !== 'undefined') {
// 获取HAZOP节点列表
const getHazopNodes = async () => {
treeLoading.value = true;
loading.value = true;
const {data,error} = await hazopNodesApi(props.currentId);
if(!error){
nodes.value = data;
nodes.value = data.nodes;
selectedWorkers.value = data.users;
currentDev.value = nodes.value[0]?.objects[0]?.deviations?.[0] || null;
}
treeLoading.value = false;
loading.value = false;
}
// 获取人员列表
@@ -589,9 +558,9 @@ const getWorkers = async () => {
const {data,error} = await peopleListApi();
if(!error){
const transformed = data.map((item:any) => ({
id: item.id,
name: item.realname,
org: item.user_departments.map((d:any) => d.label).join(';')
user_id: item.id,
user_name: item.realname,
org_name: item.user_departments.map((d:any) => d.label).join(';')
}));
workers.value = transformed;
}
@@ -619,13 +588,13 @@ onBeforeUnmount(() => {
<Icon icon="basil:arrow-left-outline" class="size-18px" />
返回工具列表
</NButton>
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
<NButton size="small" ghost type="primary" class="text-xs" @click="save(true)">保存</NButton>
<span class="text-sm font-bold text-[var(--theme-color)]">HAZOP 分析插件</span>
<NTag size="small" type="info">插件</NTag>
<span class="text-[11px] text-slate-500">{{ `分析进度 ${devDone}/${devTotal} 个偏离 · ${recTotal} 条记录` }}</span>
<div class="relative">
<NButton size="small" ghost type="primary" class="text-[11px]" @click="openSelectWorker = !openSelectWorker">
<Icon icon="lucide:users" class="size-12px" /> 分析组成员 · 已选 {{ selectedWorkers.length }}
<Icon icon="lucide:users" class="size-12px" /> 分析组成员 · 已选 {{ selectedWorkers.length }}
</NButton>
<template v-if="openSelectWorker">
<div class="fixed inset-0 z-10" @click="openSelectWorker = false"></div>
@@ -636,15 +605,15 @@ onBeforeUnmount(() => {
</div>
<NInput v-model:value="searchWorker" size="small" placeholder="搜索人名…" class="mb-1.5 text-xs" />
<div class="max-h-48 space-y-0.5 overflow-y-auto">
<div v-for="x in workerList" :key="x.id" @click="selectWorker(x.id)"
class="cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
:class="selectedWorkers.includes(x.id) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]"
:class="selectedWorkers.includes(x.id) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ selectedWorkers.includes(x.id) ? "✓" : "" }}</span>
<span class="font-medium text-slate-800">{{ x.name }}</span>
<span class="text-[11px] text-slate-400">{{ x.org }}</span>
<NTag v-if="selectedWorkers[0] === x.id" size="small" type="primary" class="ml-auto">组长</NTag>
</div>
<div v-for="x in workerList" :key="x.user_id" @click="selectWorker(x)"
class="cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
:class="hasWorker(x.user_id) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]"
:class="hasWorker(x.user_id) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ hasWorker(x.user_id) ? "✓" : "" }}</span>
<span class="font-medium text-slate-800">{{ x.user_name }}</span>
<span class="text-[11px] text-slate-400">{{ x.org_name }}</span>
<NTag v-if="selectedWorkers[0]?.user_id === x.user_id" size="small" type="primary" class="ml-auto">组长</NTag>
</div>
</div>
<div class="mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400">
首位选择者默认为组长成员为选择式非手写参与记录自动归属到人用于参与风险分析次数统计采集
@@ -653,7 +622,7 @@ onBeforeUnmount(() => {
</template>
</div>
<div class="ml-auto flex items-center gap-1.5">
<NButton v-if="isAiOpen" size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="enterToTable('all',null)">
<NButton v-if="isAiOpen" size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="enterToTable('all')">
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 整理入表
</NButton>
<NButton size="small" ghost type="primary" class="text-xs" @click="exportWord">
@@ -685,52 +654,50 @@ onBeforeUnmount(() => {
>
<!-- 节点 / 偏离树 -->
<div class="w-240px">
<NSpin :show="treeLoading" size="small">
<NButton size="small" type="primary" ghost block class="mb-2 text-xs" @click="openModal('add','node','新增节点')">
<Icon icon="ic:round-plus" class="size-14px" />新增节点
</NButton>
<div class="shrink-0 rounded-md border p-2 scroll">
<div v-for="(node, nodeIndex) in nodes" :key="nodeIndex">
<div
class="cursor-pointer flex items-center select-none"
@click="leftToggle(node.id)"
@contextmenu.prevent="handleContextMenu($event, 'node', node)"
>
<Icon v-if="collapsed[node.id]" icon="akar-icons:chevron-right" class="size-12px" />
<Icon v-else icon="akar-icons:chevron-down" class="size-12px" />
<p class="flex-1 px-2 py-1.5 text-xs font-semibold text-slate-700">节点{{ Number(nodeIndex) + 1 }}{{ node.node_name }}</p>
</div>
<template v-if="!collapsed[node.id]">
<div v-for="(obj, objIndex) in node.objects" :key="objIndex" class="mb-1 ml-3">
<div
class="cursor-pointer flex items-center select-none"
@click="leftToggle(node.id+'_'+obj.id)"
@contextmenu.prevent="handleContextMenu($event, 'obj', node, obj)"
>
<Icon v-if="collapsed[node.id+'_'+obj.id]" icon="akar-icons:chevron-right" class="size-12px" />
<Icon v-else icon="akar-icons:chevron-down" class="size-12px" />
<p class="flex-1 px-2 py-1.5 text-xs font-semibold text-slate-700">{{ obj.obj_name }}</p>
</div>
<template v-if="!collapsed[node.id+'_'+obj.id]">
<div v-for="(dev, devIndex) in obj.deviations" :key="devIndex"
@click="currentDev = dev"
@contextmenu.prevent="handleContextMenu($event, 'dev', node, obj, dev)"
class="cursor-pointer flex w-full items-center ml-2 gap-1.5 rounded-md px-3 py-1.5 text-left text-xs transition"
:class="currentDev?.id === dev.id ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'"
>
<span class="flex-1">{{ dev.deviation }}</span>
<Icon v-if="dev.analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin text-blue-500" />
<span v-else-if="dev.rows.length" class="rounded-full bg-emerald-100 px-1.5 text-[10px] text-emerald-600">
{{ dev.rows.length }}
</span>
<span v-else class="rounded-full bg-slate-100 px-1.5 text-[10px] text-slate-400">待分析</span>
</div>
</template>
</div>
</template>
<NButton size="small" type="primary" ghost block class="mb-2 text-xs" @click="openModal('add','node','新增节点')">
<Icon icon="ic:round-plus" class="size-14px" />新增节点
</NButton>
<div class="shrink-0 rounded-md border p-2 scroll">
<div v-for="(node, nodeIndex) in nodes" :key="nodeIndex">
<div
class="cursor-pointer flex items-center select-none"
@click="leftToggle(node.id)"
@contextmenu.prevent="handleContextMenu($event, 'node', node, null, null, Number(nodeIndex), -1, -1)"
>
<Icon v-if="collapsed[node.id]" icon="akar-icons:chevron-right" class="size-12px" />
<Icon v-else icon="akar-icons:chevron-down" class="size-12px" />
<p class="flex-1 px-2 py-1.5 text-xs font-semibold text-slate-700">节点{{ Number(nodeIndex) + 1 }}{{ node.node_name }}</p>
</div>
<template v-if="!collapsed[node.id]">
<div v-for="(obj, objIndex) in node.objects" :key="objIndex" class="mb-1 ml-3">
<div
class="cursor-pointer flex items-center select-none"
@click="leftToggle(node.id+'_'+obj.id)"
@contextmenu.prevent="handleContextMenu($event, 'obj', node, obj, null, Number(nodeIndex), Number(objIndex), -1)"
>
<Icon v-if="collapsed[node.id+'_'+obj.id]" icon="akar-icons:chevron-right" class="size-12px" />
<Icon v-else icon="akar-icons:chevron-down" class="size-12px" />
<p class="flex-1 px-2 py-1.5 text-xs font-semibold text-slate-700">{{ obj.obj_name }}</p>
</div>
<template v-if="!collapsed[node.id+'_'+obj.id]">
<div v-for="(dev, devIndex) in obj.deviations" :key="devIndex"
@click="currentDev = dev"
@contextmenu.prevent="handleContextMenu($event, 'dev', node, obj, dev, Number(nodeIndex), Number(objIndex), Number(devIndex))"
class="cursor-pointer flex w-full items-center ml-2 gap-1.5 rounded-md px-3 py-1.5 text-left text-xs transition"
:class="currentDev?.id === dev.id ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'"
>
<span class="flex-1">{{ dev.deviation }}</span>
<Icon v-if="dev.analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin text-blue-500" />
<span v-else-if="dev.rows.length" class="rounded-full bg-emerald-100 px-1.5 text-[10px] text-emerald-600">
{{ dev.rows.length }}
</span>
<span v-else class="rounded-full bg-slate-100 px-1.5 text-[10px] text-slate-400">待分析</span>
</div>
</template>
</div>
</template>
</div>
</NSpin>
</div>
</div>
<!-- HAZOP 记录表 -->
<div class="min-w-0 flex-1 bg-white">
@@ -741,7 +708,7 @@ onBeforeUnmount(() => {
<span class="text-[11px] text-slate-400">单元格点击即可修改风险等级按风险矩阵L / S 取值实时重算入表将本条选入风险分析记录表</span>
</div>
<div class="scroll">
<table class="w-full min-w-[860px] border-collapse text-xs">
<table class="w-full border-collapse text-xs">
<thead>
<tr>
<th :class="TH" class="w-16">操作</th>
@@ -765,13 +732,13 @@ onBeforeUnmount(() => {
<td :class="TD">
<div class="flex items-center justify-between px-1 mt-1">
<NButton text type="primary" class="mt-1px text-[11px] hover:underline" title="选入风险分析记录表"
@click="enterToTable('single',row)">入表
@click="enterToTable('single', Number(ri))">入表
</NButton>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delRow(Number(ri), row.id)"
@positive-click="delRow(Number(ri))"
>
<template #trigger>
<NButton text type="error" title="删除行">
@@ -783,16 +750,16 @@ onBeforeUnmount(() => {
</div>
</td>
<td :class="TD">
<NInput type="textarea" size="small" v-model:value="row.cause" :autosize="{minRows:1}" class="text-xs" @blur="editRow(row)" />
<NInput type="textarea" size="small" v-model:value="row.cause" :autosize="{minRows:1}" class="text-xs" @blur="save()" />
</td>
<td :class="TD">
<NInput type="textarea" size="small" v-model:value="row.consequence" :autosize="{minRows:1}" class="text-xs" @blur="editRow(row)" />
<NInput type="textarea" size="small" v-model:value="row.consequence" :autosize="{minRows:1}" class="text-xs" @blur="save()" />
</td>
<td :class="TD" class="text-center">
<NInputNumber v-model:value="row.l_value" size="tiny" :min="l_options[0].key" :max="l_options[l_options.length-1].key" :show-button="false" placeholder="" class="text-center w-10" @blur="editRow(row)" />
<NInputNumber v-model:value="row.l_value" size="tiny" :min="l_options[0].key" :max="l_options[l_options.length-1].key" :show-button="false" placeholder="" class="text-center w-10" @blur="save()" />
</td>
<td :class="TD" class="text-center">
<NInputNumber v-model:value="row.s_value" size="tiny" :min="s_options[0].key" :max="s_options[s_options.length-1].key" :show-button="false" placeholder="" class="text-center w-10" @blur="editRow(row)" />
<NInputNumber v-model:value="row.s_value" size="tiny" :min="s_options[0].key" :max="s_options[s_options.length-1].key" :show-button="false" placeholder="" class="text-center w-10" @blur="save()" />
</td>
<td :class="TD" class="text-center">
<NTag
@@ -811,15 +778,15 @@ onBeforeUnmount(() => {
<td :class="TD">
<div v-if="row.safeguards && row.safeguards.length" class="flex flex-col gap-2 mb-1">
<div v-for="(sItem, si) in row.safeguards" :key="si" class="border rounded-md relative">
<NInput type="textarea" size="small" :bordered="false" v-model:value="sItem.name" :autosize="{minRows:1}" class="text-xs" @blur="editRow(row)" />
<NInput type="textarea" size="small" :bordered="false" v-model:value="sItem.name" :autosize="{minRows:1}" class="text-xs" @blur="save()" />
<div class="flex justify-end px-2 pb-1">
<NSelect v-model:value="sItem.value" size="tiny" class="!w-70px" :options="selectOptions" placeholder="" @blur="editRow(row)"></NSelect>
<NSelect v-model:value="sItem.value" size="tiny" class="!w-70px" :options="selectOptions" placeholder="" @blur="save()"></NSelect>
</div>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delSafeguard(Number(ri), Number(si),row)"
@positive-click="delSafeguard(Number(ri), Number(si))"
>
<template #trigger>
<NButton text type="error" class="absolute top-[-5px] right-[-5px] bg-white">
@@ -836,16 +803,16 @@ onBeforeUnmount(() => {
</td>
<td :class="TD">
<div v-if="row.suggestions && row.suggestions.length" class="flex flex-col gap-2 mb-1">
<div v-for="(sItem, si) in row.suggestions" :key="si" class="border rounded-md relative">
<NInput type="textarea" size="small" :bordered="false" v-model:value="sItem.name" :autosize="{minRows:1}" class="text-xs" @blur="editRow(row)" />
<div v-for="(sItem2, si2) in row.suggestions" :key="si2" class="border rounded-md relative">
<NInput type="textarea" size="small" :bordered="false" v-model:value="sItem2.name" :autosize="{minRows:1}" class="text-xs" @blur="save()" />
<div class="flex justify-end px-2 pb-1">
<NSelect v-model:value="sItem.value" size="tiny" class="!w-70px" :options="selectOptions" placeholder="" @blur="editRow(row)"></NSelect>
<NSelect v-model:value="sItem2.value" size="tiny" class="!w-70px" :options="selectOptions" placeholder="" @blur="save()"></NSelect>
</div>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delSuggestion(Number(ri), Number(si),row)"
@positive-click="delSuggestion(Number(ri), Number(si2))"
>
<template #trigger>
<NButton text type="error" class="absolute top-[-5px] right-[-5px] bg-white">
@@ -910,26 +877,7 @@ onBeforeUnmount(() => {
<template #footer>
<div class="flex justify-end gap-[10px]">
<NButton size="small" @click="editModal = false">取消</NButton>
<NButton size="small" type="primary" :loading="modalSaving" @click="modalSave">确定</NButton>
</div>
</template>
</NModal>
<!-- 删除弹窗 -->
<NModal
v-model:show="delModal"
preset="card"
title="删除"
:auto-focus="false"
:style="{ width: '450px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
>
<p v-if="currentModalType === 'node'">节点{{contextTarget?.node?.node_name}}确定删除吗</p>
<p v-else-if="currentModalType === 'obj'">分析对象{{contextTarget?.obj?.obj_name}}确定删除吗</p>
<p v-else-if="currentModalType === 'dev'">偏离{{contextTarget?.dev?.deviation}}确定删除吗</p>
<template #footer>
<div class="flex justify-end gap-[10px]">
<NButton size="small" @click="delModal = false">取消</NButton>
<NButton size="small" type="primary" :loading="delLoading" @click="delConfirm">确定</NButton>
<NButton size="small" type="primary" @click="modalSave">确定</NButton>
</div>
</template>
</NModal>