Files
moc/src/views/my/initiateChange/modules/hazop.vue
T

900 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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, 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 analyzing = ref(false);
// 新增/编辑弹窗
const editModal = ref<boolean>(false);
// 当前弹框标题
const currentModalTitle = ref<string>('');
// 当前弹框类型
const currentModalType = ref<string>('');
// 当前弹框对象
const currentModalObj = ref<string>('');
const name = ref<string>('');
const selectOptions = computed(() => {
const l = props.hazopRiskMatrixConfig?.probability?.map((item:{key:string,label:string}, index:number) => ({
label: 'L'+'_'+Number(index + 1),
value: 'L'+'_'+Number(index + 1)
}));
const s = props.hazopRiskMatrixConfig?.consequence?.map((item:{key:string,label:string}, index:number) => ({
label: 'S'+'_'+Number(index + 1),
value: 'S'+'_'+Number(index + 1)
}));
return [...l, ...s];
});
const l_options = computed(() => {
const newArr = props.hazopRiskMatrixConfig?.probability?.map((item:{key:string,label:string}, index:number) => ({
...item,
key: Number(index + 1)
}));
return newArr;
});
const s_options = computed(() => {
const newArr = props.hazopRiskMatrixConfig?.consequence?.map((item:{key:string,label:string}, index:number) => ({
...item,
key: Number(index + 1)
}));
return newArr;
});
// 右键菜单状态
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, 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(() => {
const targetId = currentDev.value?.id;
if (!targetId) return null;
for (const top of nodes.value) {
const matchedObj = top.objects?.find((obj:any) =>obj.deviations?.some((dev:any) => dev.id === targetId));
if (matchedObj) return top;
}
return null;
});
// 当前对象
const currentObj = computed(() => {
const targetId = currentDev.value?.id;
if (!targetId) return null;
for (const top of nodes.value) {
const matchedObj = top.objects?.find((obj:any) =>obj.deviations?.some((dev:any) => dev.id === targetId));
if (matchedObj) return matchedObj;
}
return null;
});
// 当前偏离
const currentDev = ref<any>(null);
// 展开收起
const collapsed = ref<Record<string, boolean>>({})
// 节点列表
const nodes = ref<any>([]);
// 表格样式
const TH = "border bg-slate-100 px-2 py-1.5 text-[11px] font-medium text-slate-500";
const TD = "border px-1.5 py-1 align-top";
// 偏离总数
const devTotal = computed(() => {
let total = 0;
nodes.value.forEach((node:any) => {
node.objects.forEach((obj:any) => {
total += obj.deviations.length;
});
});
return total;
});
// 已分析偏离数
const devDone = computed(() => {
let nonEmptyCount = 0;
nodes.value.forEach((node:any) => {
node.objects.forEach((obj:any) => {
obj.deviations.forEach((dev:any) => {
if (dev.rows && dev.rows.length > 0) {
nonEmptyCount++;
}
});
});
});
return nonEmptyCount;
});
// 已完成记录总数
const recTotal = computed(() => {
const totalRows = nodes.value.flatMap((node:any) => node.objects)
.flatMap((obj:any) => obj.deviations)
.filter((d:any) => d.rows && d.rows.length > 0)
.reduce((sum:number, d:any) => sum + d.rows.length, 0);
return totalRows;
});
// 人员列表源数据
const workers = ref<{user_id:number,user_name:string,org_name?:string}[]>([]);
// 选中的人员
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:{user_id:number,user_name:string,org_name?:string}) => !searchWorker.value || x.user_name.includes(searchWorker.value)));
// 选择分析人员
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 = 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;
};
/**
* 根据行下标 查找并返回带有父级名称的行对象
*/
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',
item: `${currentDev.value?.deviation}${currentNode.value?.node_name?`${currentNode.value?.node_name}${currentObj.value?.obj_name?`-${currentObj.value?.obj_name}`:''}`:''}`,
inherent_level: row?.risk_value || '',
safeguards: row?.safeguards?.map((item:any) => item.name).join('') || '',
suggestions: row?.suggestions?.map((item:any) => item.name).join('') || '',
residual_level: countRiskLevel(row)?.name || '',
to_pssr: 0,
safes: row?.safeguards || [],
sugs: row?.suggestions || [],
};
}
return null;
}
// AI整理如表、入表
const enterToTable = (type: string, rowIndex: number = -1) => {
if(type==='single'){
const enrichedRow = findRowWithParents(rowIndex);
emit('toTable',type,enrichedRow?[enrichedRow]:[]);
}
if(type==='all'){
const enrichedRows:any[] = [];
nodes.value.forEach((node:any) => {
const node_ame = node.node_name;
node.objects.forEach((obj:any) => {
const obj_ame = obj.obj_name;
obj.deviations.forEach((dev:any) => {
const deviation = dev.deviation;
if (dev.rows && dev.rows.length > 0) {
dev.rows.forEach((row:any) => {
// 创建新对象,保留原 row 全部属性,并添加父级信息
enrichedRows.push({
...row,
scene: `${row.cause?row.cause+';':''}${deviation?deviation+';':''}${row.consequence?'可能导致:'+row.consequence:''}`,
source: 'HAZOP',
item: `${deviation}${node_ame?`${node_ame}${obj_ame?`-${obj_ame}`:''}`:''}`,
inherent_level: row?.risk_value || '',
safeguards: row?.safeguards?.map((item:any) => item.name).join('') || '',
suggestions: row?.suggestions?.map((item:any) => item.name).join('') || '',
residual_level: countRiskLevel(row)?.name || '',
to_pssr: 0,
safes: row?.safeguards || [],
sugs: row?.suggestions || [],
});
});
}
});
});
});
emit('toTable',type,enrichedRows);
}
}
// 导出 Word
const exportWord = () => {
window.$message?.info('还差接口')
}
// AI 分析当前偏离
const runAi = async () => {
if(!currentDev.value){
return window.$message?.warning("请先在左侧选择偏离")
}
if(currentDev.value.rows.length){
return window.$message?.info("当前偏离已有分析记录,可手动新增行补充");
}
};
// 根据l和s计算RR风险等级
const riskLevel = (l: number, s: number) => {
const num = l * s;
for (let i = 0; i < props.hazopRiskMatrixConfig?.risk_grades.length; i++) {
const item = props.hazopRiskMatrixConfig?.risk_grades[i];
if (num >= item.min && num <= item.max) {
return item; // 返回整条数据
}
}
return null;
}
// 计算RR1风险等级
const countRiskLevel = (row: any) => {
const l = row.l_value;
const s = row.s_value;
const newArr = [...row?.safeguards || [],...row?.suggestions || []];
const result:any = { L: 0, S: 0 };
newArr.forEach(item => {
if (!item.value) return; // 跳过空值
const parts = item.value.split('_');
if (parts.length === 2) {
const type = parts[0].toUpperCase(); // 统一大写
const num = parseInt(parts[1], 10);
if (!isNaN(num) && (type === 'L' || type === 'S')) {
result[type] += num;
}
}
});
const newL = (l - result.L) > 0 ? l - result.L : 1;
const newS = (s - result.S) > 0 ? s - result.S : 1;
return riskLevel(newL, newS);
}
// 新增安全措施
const addSafeguard = (index: number) => {
if(currentDev.value.rows[index].safeguards){
currentDev.value.rows[index].safeguards.push({name:'',value:''});
}else{
currentDev.value.rows[index].safeguards = [{name:'',value:''}];
}
save()
}
// 删除安全措施
const delSafeguard = (index: number, safeguardIndex: number) => {
currentDev.value.rows[index].safeguards.splice(safeguardIndex, 1);
save()
}
// 新增建议措施
const addSuggestion = (index: number) => {
if(currentDev.value.rows[index].suggestions){
currentDev.value.rows[index].suggestions.push({name:'',value:''});
}else{
currentDev.value.rows[index].suggestions = [{name:'',value:''}];
}
save()
}
// 删除建议措施
const delSuggestion = (index: number, suggestionIndex: number) => {
currentDev.value.rows[index].suggestions.splice(suggestionIndex, 1);
save()
}
// 新增行
const addRow = async () => {
if(!currentDev.value){
return window.$message?.warning("请先在左侧选择偏离")
}
currentDev.value.rows.push({
cause: "",
consequence: "",
l_value: l_options.value[0]?.key || 1,
s_value: s_options.value[0]?.key || 1,
risk_value: riskLevel(l_options.value[0]?.key || 1, s_options.value[0]?.key || 1)?.name || '',
safeguards: [],
suggestions: [],
});
};
// 删除行
const delRow = async (index: number) => {
currentDev.value.rows.splice(index, 1);
save()
}
// 展开收起
const leftToggle = (id: string) => {
collapsed.value = { ...collapsed.value, [id]: !collapsed.value[id] }
}
// 打开弹窗
const openModal = (type: string,obj: string, title: string,editName: string = '') => {
if(type==='add'){
name.value = '';
}else{
name.value = editName;
}
currentModalType.value = type;
currentModalObj.value = obj;
currentModalTitle.value = title;
editModal.value = true;
}
// 新增/编辑保存
const modalSave = async () => {
if(name.value===''){
return window.$message?.warning("名称不能为空")
}
// 新增节点
if(currentModalType.value==='add' && currentModalObj.value==='node'){
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'){
nodes.value[contextTarget.value?.nodeIndex].node_name = name.value;
window.$message?.success("编辑节点成功")
editModal.value = false;
}
// 新增分析对象
if(currentModalType.value==='add' && currentModalObj.value==='obj'){
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'){
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'){
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'){
nodes.value[contextTarget.value?.nodeIndex].objects[contextTarget.value?.objIndex].deviations[contextTarget.value?.devIndex].deviation = name.value;
window.$message?.success("编辑偏离成功")
editModal.value = false;
}
}
// 打开删除弹框
const openDelModal = (type: string) => {
let txt = ''
if(type==='node'){
txt = `确定删除节点「${contextTarget.value?.node?.node_name}」吗?`
}
if(type==='obj'){
txt = `确定删除分析对象「${contextTarget.value?.obj?.obj_name}」吗?`
}
if(type==='dev'){
txt = `确定删除偏离「${contextTarget.value?.dev?.deviation}」吗?`
}
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: () => {
}
})
}
// 菜单选项配置(根据不同层级返回不同菜单)
const getMenuOptions = (type: string) => {
const nodeOptions = [
{ label: '新增分析对象', key: 'addObj' },
{ 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,
dev: devOptions,
}
return map[type] || []
}
// 计算当前菜单选项
const contextMenuOptions = computed(() => {
return getMenuOptions(contextTarget.value.type)
})
// 点击页面其他区域关闭菜单(可选)
const closeMenu = () => {
contextMenuShow.value = false
}
// 右键菜单事件处理
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, nodeIndex, objIndex, devIndex }
// 计算菜单位置
contextMenuX.value = Math.max(0, event.clientX)
contextMenuY.value = Math.max(0, event.clientY)
contextMenuShow.value = true
}
// 菜单选中回调
// ============================================================
const handleMenuSelect = (key: string) => {
const { type, node, obj, dev } = contextTarget.value
switch (key) {
// ---------- 节点操作 ----------
case 'addObj':
// 新增分析对象
openModal('add','obj','新增分析对象');
break
case 'editNode':
// 编辑节点
openModal('edit','node','编辑节点',node?.node_name);
break
case 'delNode':
// 删除
openDelModal('node');
break
// ---------- 对象操作 ----------
case 'addDev':
// 新增偏离
openModal('add','dev','新增偏离');
break
case 'editObj':
// 编辑分析对象
openModal('edit','obj','编辑分析对象',obj?.obj_name);
break
case 'delObj':
// 删除
openDelModal('obj');
break
// ---------- 偏离操作 ----------
case 'editDev':
// 编辑偏离
openModal('edit','dev','编辑偏离',dev?.deviation);
break
case 'delDev':
// 删除
openDelModal('dev');
break
default:
console.warn('未知菜单项:', key)
}
// 关闭菜单
closeMenu()
}
// 监听全局点击关闭菜单
// 注意:这里需要排除菜单自身,使用 Naive UI 的 mask 或手动处理
// 简单起见,我们利用 n-dropdown 的 mask: false,并在外部点击时关闭
// 但更好的方式是监听 document 的 click 事件
if (typeof window !== 'undefined') {
document.addEventListener('click', (e) => {
// 如果点击的不是菜单内部,关闭菜单
const menuEl = document.querySelector('.n-dropdown')
if (menuEl && !menuEl.contains(e.target as Node)) {
closeMenu()
}
})
}
// 获取HAZOP节点列表
const getHazopNodes = async () => {
loading.value = true;
const {data,error} = await hazopNodesApi(props.currentId);
if(!error){
nodes.value = data.nodes;
selectedWorkers.value = data.users;
currentDev.value = nodes.value[0]?.objects[0]?.deviations?.[0] || null;
}
loading.value = false;
}
// 获取人员列表
const getWorkers = async () => {
const {data,error} = await peopleListApi();
if(!error){
const transformed = data.map((item:any) => ({
user_id: item.id,
user_name: item.realname,
org_name: item.user_departments.map((d:any) => d.label).join(';')
}));
workers.value = transformed;
}
}
onMounted(() => {
getWorkers();
getHazopNodes();
})
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu)
})
</script>
<template>
<NSpin :show="loading" size="small">
<div class="space-y-3">
<div
class="flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
:style="{
'--theme-color': themeStore.themeColor
}"
>
<NButton size="small" ghost type="primary" class="text-xs" @click="emit('close')">
<Icon icon="basil:arrow-left-outline" class="size-18px" />
返回工具列表
</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 }}
</NButton>
<template v-if="openSelectWorker">
<div class="fixed inset-0 z-10" @click="openSelectWorker = false"></div>
<div class="absolute right-0 top-8 z-20 w-72 rounded-lg border bg-white p-2 shadow-xl">
<div class="mb-1.5 flex items-center justify-between">
<span class="text-xs font-semibold text-slate-700">选择分析组成员岗位人员库</span>
<span class="text-[11px] text-slate-400">已选 {{ selectedWorkers.length }} </span>
</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.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">
首位选择者默认为组长成员为选择式非手写),参与记录自动归属到人用于参与风险分析次数统计采集
</div>
</div>
</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')">
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 整理入表
</NButton>
<NButton size="small" ghost type="primary" class="text-xs" @click="exportWord">
<Icon icon="material-symbols:download" class="size-14px" /> 导出 Word
</NButton>
<NButton v-if="isAiOpen" size="small" type="primary" class="text-xs" :disabled="analyzing" @click="runAi">
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
<Icon v-else icon="lucide:sparkles" class="size-12px" />
AI 分析当前偏离
</NButton>
</div>
</div>
<div class="flex items-center gap-2 text-xs text-slate-500">
<span>当前位置</span>
<NTag size="small">节点{{ currentNode?.node_name ?? '未选择' }}</NTag>
<span></span>
<NTag size="small" type="success">分析对象{{ currentObj?.obj_name ?? '未选择' }}</NTag>
<span></span>
<NTag size="small" type="warning">偏离{{ currentDev?.deviation ?? '未选择' }}</NTag>
</div>
<div
class="flex gap-3"
:style="{
'--theme-color': themeStore.themeColor,
'--success-color': themeStore.otherColor.success
}"
>
<!-- 节点 / 偏离树 -->
<div class="w-240px">
<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>
</div>
</div>
<!-- HAZOP 记录表 -->
<div class="min-w-0 flex-1 bg-white">
<div class="flex items-center gap-2 pb-2">
<NButton size="small" ghost type="primary" class="text-xs" @click="addRow">
<Icon icon="ic:round-plus" class="size-14px" /> 新增行
</NButton>
<span class="text-[11px] text-slate-400">单元格点击即可修改风险等级按风险矩阵L / S 取值实时重算;「入表将本条选入风险分析记录表</span>
</div>
<div class="scroll">
<table class="w-full border-collapse text-xs">
<thead>
<tr>
<th :class="TH" class="w-16">操作</th>
<th :class="TH" class="w-60">原因</th>
<th :class="TH" class="w-60">后果</th>
<th :class="TH" class="w-6">L</th>
<th :class="TH" class="w-6">S</th>
<th :class="TH" class="w-20">RR</th>
<th :class="TH" class="w-60">安全措施</th>
<th :class="TH" class="w-60">建议措施</th>
<th :class="TH" class="w-20">RR1</th>
</tr>
</thead>
<tbody>
<tr v-if="!currentDev || currentDev?.rows?.length === 0">
<td colspan="8" class="px-3 py-8 text-center text-xs text-slate-400">
当前偏离暂无分析记录点击右上角AI 分析当前偏离自动生成新增行手动填写
</td>
</tr>
<tr v-for="(row, ri) in currentDev?.rows" :key="ri">
<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', Number(ri))">入表
</NButton>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delRow(Number(ri))"
>
<template #trigger>
<NButton text type="error" title="删除行">
<Icon icon="ep:delete" class="size-14px" />
</NButton>
</template>
确定删除吗
</NPopconfirm>
</div>
</td>
<td :class="TD">
<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="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="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="save()" />
</td>
<td :class="TD" class="text-center">
<NTag
v-if="row.l_value>0 && row.s_value>0"
class="text-[11px]"
size="small"
:color="{
color: riskLevel(row.l_value, row.s_value)?.bg_color,
textColor: riskLevel(row.l_value, row.s_value)?.font_color,
borderColor: riskLevel(row.l_value, row.s_value)?.bg_color,
}"
>
{{riskLevel(row.l_value, row.s_value)?.name}}({{row.l_value * row.s_value}})
</NTag>
</td>
<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="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="save()"></NSelect>
</div>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delSafeguard(Number(ri), Number(si))"
>
<template #trigger>
<NButton text type="error" class="absolute top-[-5px] right-[-5px] bg-white">
<Icon icon="ant-design:close-circle-outlined" class="size-16px" />
</NButton>
</template>
确定删除吗
</NPopconfirm>
</div>
</div>
<NButton size="tiny" dashed type="primary" @click="addSafeguard(Number(ri))">
<Icon icon="ic:round-plus" class="size-12px" />增加
</NButton>
</td>
<td :class="TD">
<div v-if="row.suggestions && row.suggestions.length" class="flex flex-col gap-2 mb-1">
<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="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(si2))"
>
<template #trigger>
<NButton text type="error" class="absolute top-[-5px] right-[-5px] bg-white">
<Icon icon="ant-design:close-circle-outlined" class="size-16px" />
</NButton>
</template>
确定删除吗
</NPopconfirm>
</div>
</div>
<NButton size="tiny" dashed type="primary" @click="addSuggestion(Number(ri))">
<Icon icon="ic:round-plus" class="size-12px" />增加
</NButton>
</td>
<td :class="TD" class="text-center">
<NTag
v-if="row.l_value>0 && row.s_value>0"
class="text-[11px]"
size="small"
:color="{
color: countRiskLevel(row)?.bg_color,
textColor: countRiskLevel(row)?.font_color,
borderColor: countRiskLevel(row)?.bg_color,
}"
>
{{countRiskLevel(row)?.name}}
</NTag>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</NSpin>
<!-- ========== 右键菜单 ========== -->
<NDropdown
v-model:show="contextMenuShow"
:x="contextMenuX"
:y="contextMenuY"
:options="contextMenuOptions"
@select="handleMenuSelect"
placement="bottom-start"
trigger="manual"
:mask="false"
to="body"
flip
/>
<!-- 新增/编辑弹窗 -->
<NModal
v-model:show="editModal"
preset="card"
:title="currentModalTitle"
:auto-focus="true"
:style="{ width: '450px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
>
<div>
<NInput v-model:value="name" placeholder="请输入名称" />
</div>
<template #footer>
<div class="flex justify-end gap-[10px]">
<NButton size="small" @click="editModal = false">取消</NButton>
<NButton size="small" type="primary" @click="modalSave">确定</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 160px);
overflow-y: auto;
}
</style>