900 lines
36 KiB
Vue
900 lines
36 KiB
Vue
<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>
|