hazop插件完成

This commit is contained in:
2026-09-08 15:34:25 +08:00
parent 30bf3aa01a
commit d7b1f4b2ef
3 changed files with 747 additions and 578 deletions
+325 -222
View File
@@ -1,10 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useMessage } from "naive-ui";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { AiError, aiHazopDeviation } from '@/service/api/ai';
import { matrixConfigApi } from "@/service/api/system";
import { peopleListApi } from '@/service/api/system';
import {
hazopNodesApi,
hazopNodeAddApi,
@@ -22,13 +20,13 @@ import {
} from "@/service/api/plugin";
const themeStore = useThemeStore();
const message = useMessage();
const props = defineProps(['isAiOpen','currentId'])
const emit = defineEmits(['close','export']);
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);
// 删除确定状态
@@ -44,17 +42,28 @@ const currentModalObj = ref<string>('');
// 弹窗保存状态
const modalSaving = ref<boolean>(false);
const name = ref<string>('');
// 风险矩阵配置
const riskMatrixConfig = ref<any>(null);
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 = riskMatrixConfig.value?.consequence?.map((item:{key:string,label:string}, index:number) => ({
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 = riskMatrixConfig.value?.probability?.map((item:{key:string,label:string}, index:number) => ({
const newArr = props.hazopRiskMatrixConfig?.consequence?.map((item:{key:string,label:string}, index:number) => ({
...item,
key: Number(index + 1)
}));
@@ -133,97 +142,19 @@ const recTotal = computed(() => {
return totalRows;
});
const HAZOP_AI:any = {
"搅拌失效": [
{ cause: "搅拌器机械故障或变频器跳闸,釜内物料传热不均", cons: "局部过热引发暴沸冲料;温度测量失真误导操作", l: 2, s: 4, safe: "搅拌电流 DCS 在线监视与低电流报警;跳闸联锁停进料", sug: "变更后复核搅拌电流裕量,纳入交接班巡检" },
],
"冷却水中断": [
{ cause: "冷却水总管压力波动或阀门误关", cons: "撤热失效,反应温度快速上升溜温", l: 2, s: 5, safe: "冷却水流量低报警;紧急切断进料联锁", sug: "核实紧急冷却旁路可用性,写入超温应急处置卡" },
],
"联锁拒动 / 误动": [
{ cause: "联锁设定值修改后未重新整定验证,或逻辑修改错误", cons: "超温时联锁拒动失去最后保护层;误动造成非计划停车", l: 1, s: 5, safe: "投用前 100% 联锁传动测试,动作正确率确认", sug: "联锁测试记录归档至变更资料;修改执行双人复核" },
],
};
const inherentOf = (l: number, s: number): "高" | "中" | "低" => {
const v = l * s;
return v >= 10 ? "高" : v >= 5 ? "中" : "低";
}
const lowerOf = (lv: "高" | "中" | "低"): "高" | "中" | "低" => {
return lv === "高" ? "中" : "低";
}
const sel = ref({ n: 0, d: 0 });
const analyzing = ref(false);
const toRec = (obj: string, dev: string, row: any): any => ({
item: `${dev}${obj}`,
scene: `${row.cause};可能导致:${row.cons}`,
inherent: inherentOf(row.l, row.s),
existing: row.safe,
suggest: row.sug,
residual: lowerOf(inherentOf(row.l, row.s)),
source: "HAZOP",
});
const runAi = async () => {
if (currentDev.value.rows.length) return message.info("当前偏离已有分析记录,可手动新增行补充");
analyzing.value = true;
try {
const node = nodes.value[sel.value.n];
const res = await aiHazopDeviation({
node_name: node.name,
deviation: currentDev.value.name,
object_name: node.obj,
change_context: {
change: "缩合反应温度及时间优化",
content: "R-101 缩合反应釜反应温度由 80℃ 提升至 90℃、保温时间由 8h 缩短至 4h,DCS 温度报警 / 联锁值随之上调",
},
});
const rows = (res?.rows ?? []).map((r) => ({
cause: r.cause ?? "", cons: r.consequence ?? "", l: r.l ?? 2, s: r.s ?? 3,
safe: r.safeguards ?? "", sug: r.suggestions ?? "",
}));
if (!rows.length) throw new AiError(0, "AI 未返回分析结果");
currentDev.value.rows = rows;
message.success(`AI 已完成「${currentDev.value.name}」偏离分析,生成 ${rows.length} 条记录(支持手动修改)`);
} catch (e: any) {
const msg = e instanceof AiError ? e.message : "AI 服务异常";
window.$message?.warning(`AI 分析失败:${msg},可手动填写`);
// 静态示例数据作 fallback,不阻塞页面
const gen = HAZOP_AI[currentDev.value.name] ?? [
{ cause: "(AI 生成)偏离场景原因分析", cons: "AI 生成)可能后果描述", l: 2, s: 3, safe: "现有保护层说明", sug: "建议补充的管控措施" },
];
currentDev.value.rows = gen.map((x: any) => ({ ...x }));
} finally {
analyzing.value = false;
}
};
const ANALYSIS_POOL = [
{ name: "张工艺", org: "一车间", post: "工艺工程师" },
{ name: "王仪表", org: "技术部", post: "仪表工程师" },
{ name: "周设备", org: "设备科", post: "设备工程师" },
{ name: "刘文静", org: "技术部", post: "工艺员" },
{ name: "王海燕", org: "安全环保部", post: "安全工程师" },
{ name: "李主任", org: "一车间", post: "车间主任" },
{ name: "吴海涛", org: "设备科", post: "维修班长" },
{ name: "陈国栋", org: "一车间", post: "班长" },
];
const sels = ref<string[]>(["张工艺", "王仪表"]);
const workers = ref<{id:number,name:string,org:string}[]>([]);
// 选中的人员
const selectedWorkers = ref<number[]>([]);
// 选择分析人员弹窗
const openSelectWorker = ref(false);
// 搜索分析人员
const searchWorker = ref("");
// 分析人员列表
const list = computed(() => ANALYSIS_POOL.filter((x) => !searchWorker.value || x.name.includes(searchWorker.value)));
const workerList = computed(() => workers.value.filter((x:{id:number,name:string,org:string}) => !searchWorker.value || x.name.includes(searchWorker.value)));
// 选择分析人员
const selectWorker = (n: string) => {
sels.value = sels.value.includes(n) ? sels.value.filter((x) => x !== n) : [...sels.value, n];
const selectWorker = (n: number) => {
selectedWorkers.value = selectedWorkers.value.includes(n) ? selectedWorkers.value.filter((x:number) => x !== n) : [...selectedWorkers.value, n];
};
// 保存
@@ -231,27 +162,146 @@ const save = () => {
window.$message?.success("已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)");
};
// AI整理如表
const exportAll = () => {
emit("export", nodes.value.flatMap((n) => n.devs.flatMap((d) => d.rows.map((row) => toRec(n.obj, d.name, row)))), "ai");
/**
* 根据行 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
};
}
}
}
}
}
return null; // 未找到
}
// AI整理如表、入表
const enterToTable = (type: string, row: any) => {
if(type==='single'){
const enrichedRow = findRowWithParents(nodes.value, row.id);
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',
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
});
});
}
});
});
});
emit('toTable',type,enrichedRows);
}
}
// 导出 Word
const exportWord = () => {
window.$message?.success('HAZOP 分析报告已导出(Word')
window.$message?.info('还差接口')
}
// 根据l和s获取风险等级
// 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 < riskMatrixConfig.value?.risk_grades.length; i++) {
const item = riskMatrixConfig.value?.risk_grades[i];
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:''}];
}
}
// 删除安全措施
const delSafeguard = (index: number, safeguardIndex: number, row: any) => {
currentDev.value.rows[index].safeguards.splice(safeguardIndex, 1);
editRow(row)
}
// 新增建议措施
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:''}];
}
}
// 删除建议措施
const delSuggestion = (index: number, suggestionIndex: number, row: any) => {
currentDev.value.rows[index].suggestions.splice(suggestionIndex, 1);
editRow(row)
}
// 新增行
const addRow = async () => {
@@ -265,12 +315,11 @@ const addRow = async () => {
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: "",
safeguards: [],
suggestions: [],
})
if(!error){
currentDev.value.rows.push(data);
window.$message?.success("新增行成功")
}
loading.value = false;
};
@@ -459,21 +508,9 @@ const handleContextMenu = (event: MouseEvent, type: string, node: any, obj = nul
event.preventDefault()
// 记录目标数据
contextTarget.value = { type, node, obj, dev }
// 计算菜单位置(防止超出视口)
const menuWidth = 200 // 预估菜单宽度
const menuHeight = 300 // 预估菜单高度
let x = event.clientX
let y = event.clientY
// 如果菜单会超出右边界,向左偏移
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 10
}
// 如果菜单会超出下边界,向上偏移
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 10
}
contextMenuX.value = x
contextMenuY.value = y
// 计算菜单位置
contextMenuX.value = Math.max(0, event.clientX)
contextMenuY.value = Math.max(0, event.clientY)
contextMenuShow.value = true
}
// 菜单选中回调
@@ -491,7 +528,6 @@ const handleMenuSelect = (key: string) => {
openModal('edit','node','编辑节点',node?.node_name);
break
case 'delNode':
console.log('[删除节点] 节点:', node?.node_name)
// 删除
openDelModal('node');
break
@@ -538,31 +574,35 @@ if (typeof window !== 'undefined') {
})
}
// 获取风险矩阵配置
const getRiskMatrixConfig = async () => {
treeLoading.value = true;
const {data,error} = await matrixConfigApi("HAZOP");
if(!error){
console.log(data)
riskMatrixConfig.value = data;
getHazopNodes();
}else{
treeLoading.value = false;
}
}
// 获取HAZOP节点列表
const getHazopNodes = async () => {
treeLoading.value = true;
const {data,error} = await hazopNodesApi(props.currentId);
if(!error){
console.log(data)
nodes.value = data;
}
treeLoading.value = false;
}
// 获取人员列表
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(';')
}));
workers.value = transformed;
}
}
onMounted(() => {
getRiskMatrixConfig();
getWorkers();
getHazopNodes();
})
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu)
})
</script>
@@ -582,29 +622,28 @@ onMounted(() => {
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
<span class="text-sm font-bold text-[var(--theme-color)]">HAZOP 分析插件</span>
<NTag size="small" type="info">插件</NTag>
<NTag size="small">MOC 变更缩合反应温度及时间优化演示</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" /> 分析组成员 · 已选 {{ sels.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>
<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">已选 {{ sels.length }} </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 list" :key="x.name" @click="selectWorker(x.name)"
<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="sels.includes(x.name) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'">
: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="sels.includes(x.name) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ sels.includes(x.name) ? "✓" : "" }}</span>
: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 }} · {{ x.post }}</span>
<NTag v-if="sels[0] === x.name" size="small" type="primary" class="ml-auto">组长</NTag>
<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>
<div class="mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400">
@@ -614,7 +653,7 @@ onMounted(() => {
</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="exportAll">
<NButton v-if="isAiOpen" size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="enterToTable('all',null)">
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 整理入表
</NButton>
<NButton size="small" ghost type="primary" class="text-xs" @click="exportWord">
@@ -701,82 +740,144 @@ onMounted(() => {
</NButton>
<span class="text-[11px] text-slate-400">单元格点击即可修改风险等级按风险矩阵L / S 取值实时重算入表将本条选入风险分析记录表</span>
</div>
<table class="w-full min-w-[860px] 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>
</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="message.success('已选入 1 条风险记录,可继续挑选或返回查看记录表')">入表
</NButton>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delRow(Number(ri), row.id)"
>
<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="editRow(row)" />
</td>
<td :class="TD">
<NInput type="textarea" size="small" v-model:value="row.consequence" :autosize="{minRows:1}" class="text-xs" @blur="editRow(row)" />
</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)" />
</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)" />
</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">
<NInput type="textarea" size="small" v-model:value="row.safeguards" :autosize="{minRows:1}" class="text-xs" @blur="editRow(row)" />
</td>
<td :class="TD">
<NInput type="textarea" size="small" v-model:value="row.suggestions" :autosize="{minRows:1}" class="text-xs" @blur="editRow(row)" />
</td>
</tr>
</tbody>
</table>
<div class="scroll">
<table class="w-full min-w-[860px] 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',row)">入表
</NButton>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delRow(Number(ri), row.id)"
>
<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="editRow(row)" />
</td>
<td :class="TD">
<NInput type="textarea" size="small" v-model:value="row.consequence" :autosize="{minRows:1}" class="text-xs" @blur="editRow(row)" />
</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)" />
</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)" />
</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="editRow(row)" />
<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>
</div>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delSafeguard(Number(ri), Number(si),row)"
>
<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="(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 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>
</div>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delSuggestion(Number(ri), Number(si),row)"
>
<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>
@@ -791,13 +892,15 @@ onMounted(() => {
placement="bottom-start"
trigger="manual"
:mask="false"
to="body"
flip
/>
<!-- 新增/编辑弹窗 -->
<NModal
v-model:show="editModal"
preset="card"
:title="currentModalTitle"
:auto-focus="false"
:auto-focus="true"
:style="{ width: '450px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
>
@@ -834,7 +937,7 @@ onMounted(() => {
<style scoped lang="scss">
.scroll {
height: calc(100vh - 200px);
height: calc(100vh - 160px);
overflow-y: auto;
}
</style>