490 lines
20 KiB
Vue
490 lines
20 KiB
Vue
<!-- 风险分析记录表 -->
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import { Icon } from '@iconify/vue'
|
||
import { useThemeStore } from '@/store/modules/theme';
|
||
import RiskPre from "./riskPre.vue";
|
||
import Hazop from "./hazop.vue";
|
||
import Jsa from "./jsa.vue";
|
||
import Scl from "./scl.vue";
|
||
import RiskCheck from "./riskCheck.vue";
|
||
import Footer from './footer.vue'
|
||
import { matrixConfigApi } from "@/service/api/system";
|
||
|
||
const themeStore = useThemeStore();
|
||
|
||
const props = defineProps(['type','isAiOpen','title','currentId','formInfo', 'currentForm', 'confirmed'])
|
||
const emit = defineEmits(['save','submit','confirmTab']);
|
||
|
||
watch(() => props.currentForm, (newVal:any) => {
|
||
const omit = (obj:any, fields: string[]) => Object.fromEntries(Object.entries(obj).filter(([k]) => !fields.includes(k)));
|
||
const result = newVal?.records.map((item:any) => omit(item, [
|
||
'id', 'created_at', 'updated_at', 'change_id', 'source_row_id'
|
||
]));
|
||
riskRecords.value = result || [];
|
||
})
|
||
|
||
const hazopRiskMatrixConfig = ref<any>(null);
|
||
const jsaRiskMatrixConfig = ref<any>(null);
|
||
const pluginDone = computed(() =>
|
||
riskRecords.value.reduce((acc:any, item:any) => {
|
||
const s = item.source;
|
||
if (s) acc[s] = (acc[s] || 0) + 1;
|
||
return acc;
|
||
}, {} as Record<string, number>)
|
||
);
|
||
const tools = ref<string[]>(["RISK","HAZOP","JSA", "RISK_CHECK"]);
|
||
const riskRecords = ref<any>([]);
|
||
const pluginDrawer = ref<boolean>(false);
|
||
const currentPlugin = ref<string>('');
|
||
|
||
// 风险识别分析记录表
|
||
const TOOL_CARDS = [
|
||
{ key: "RISK", name: "风险预分析" },
|
||
{ key: "HAZOP", name: "HAZOP 分析" },
|
||
{ key: "JSA", name: "JSA 分析" },
|
||
{ key: "RISK_CHECK", name: "风险检查表" },
|
||
];
|
||
|
||
// 根据l和s获取风险等级
|
||
const hazopRiskLevel = (l: number, s: number) => {
|
||
const num = l * s;
|
||
for (let i = 0; i < hazopRiskMatrixConfig.value?.risk_grades.length; i++) {
|
||
const item = hazopRiskMatrixConfig.value?.risk_grades[i];
|
||
if (num >= item.min && num <= item.max) {
|
||
return item; // 返回整条数据
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
const jsaRiskLevel = (l: number, s: number) => {
|
||
const num = l * s;
|
||
for (let i = 0; i < jsaRiskMatrixConfig.value?.risk_grades.length; i++) {
|
||
const item = jsaRiskMatrixConfig.value?.risk_grades[i];
|
||
if (num >= item.min && num <= item.max) {
|
||
return item; // 返回整条数据
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
// 计算RR1风险等级
|
||
const countHazopRiskLevel = (row: any) => {
|
||
const l = row.l_value;
|
||
const s = row.s_value;
|
||
const newArr = [...row?.safes || [],...row?.sugs || []];
|
||
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 hazopRiskLevel(newL, newS);
|
||
}
|
||
const countJsaRiskLevel = (row: any) => {
|
||
const l = row.l_value;
|
||
const s = row.s_value;
|
||
const newArr = [...row?.safes || [],...row?.sugs || []];
|
||
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 jsaRiskLevel(newL, newS);
|
||
}
|
||
|
||
|
||
// 删除记录
|
||
const delRec = (i: number) => {
|
||
riskRecords.value.splice(i, 1);
|
||
};
|
||
|
||
// 单个转换为 PSSR 行动项
|
||
const toPssr = (i: number) => {
|
||
const rec = riskRecords.value[i];
|
||
if (!rec || rec.to_pssr) return;
|
||
riskRecords.value[i] = { ...rec, to_pssr: 1 };
|
||
window.$message?.success("建议措施已转为 PSSR 行动项(见「PSSR 检查内容」· 一、风险分析行动项落实)");
|
||
};
|
||
|
||
// 全部转为 PSSR 行动项
|
||
const toPssrAll = () => {
|
||
const result = riskRecords.value.filter((item:any) => item.to_pssr === 0);
|
||
if (!result.length) {
|
||
return window.$message?.info("所有建议措施均已转为 PSSR 行动项");
|
||
}
|
||
riskRecords.value = riskRecords.value.map((x:any) => ({ ...x, to_pssr: 1 }));
|
||
window.$message?.success(`已将建议措施全部转为 PSSR 行动项(见「PSSR 检查内容」)`);
|
||
};
|
||
|
||
// AI 自动整理
|
||
const aiOrganize = async () => {
|
||
|
||
};
|
||
|
||
/**
|
||
* 根据 id 字段对数组去重(保留首次出现)
|
||
* @param {Array} arr - 包含 id 字段的对象数组
|
||
* @returns {Array} 去重后的新数组
|
||
*/
|
||
function uniqueById(arr: any[]) {
|
||
const seen = new Set();
|
||
const result = [];
|
||
for (let i = arr.length - 1; i >= 0; i--) {
|
||
const item = arr[i];
|
||
if (!seen.has(item.id)) {
|
||
seen.add(item.id);
|
||
result.unshift(item); // 插入到头部,保持原顺序
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
// 整理入表
|
||
const exportAll = (type: string, items: any[]) => {
|
||
riskRecords.value = uniqueById([...riskRecords.value, ...items]);
|
||
if (type === "all") {
|
||
pluginDrawer.value = false;
|
||
currentPlugin.value = '';
|
||
window.$message?.success(`AI 已自动整理形成风险分析记录表(新增 ${items.length} 项),支持手动修改`);
|
||
} else {
|
||
window.$message?.success(`已选入 ${items.length} 条风险记录,可继续挑选或返回查看记录表`);
|
||
}
|
||
}
|
||
|
||
// 使用插件弹框
|
||
const usePlugin = (key: string) => {
|
||
currentPlugin.value = key;
|
||
pluginDrawer.value = true;
|
||
}
|
||
// 关闭插件弹框
|
||
const closePlugin = (type: string = "", count: any = undefined) => {
|
||
if(type){
|
||
pluginDone.value[type] = count;
|
||
}
|
||
pluginDrawer.value = false;
|
||
}
|
||
|
||
// 保存草稿
|
||
const saveDraft = () => {
|
||
emit('save');
|
||
}
|
||
// 提交
|
||
const submitConfirm = () => {
|
||
emit('submit', props.type);
|
||
}
|
||
// // 确认标签内容
|
||
const confirmTab = (tab:string, val:boolean) => {
|
||
emit('confirmTab', tab, val);
|
||
}
|
||
|
||
// 获取HAZOP风险矩阵配置
|
||
const getHazopRiskMatrixConfig = async () => {
|
||
const {data,error} = await matrixConfigApi("HAZOP");
|
||
if(!error){
|
||
hazopRiskMatrixConfig.value = data;
|
||
}
|
||
}
|
||
// 获取JSA风险矩阵配置
|
||
const getJsaRiskMatrixConfig = async () => {
|
||
const {data,error} = await matrixConfigApi("JSA");
|
||
if(!error){
|
||
jsaRiskMatrixConfig.value = data;
|
||
}
|
||
}
|
||
|
||
defineExpose({riskRecords})
|
||
|
||
onMounted(() => {
|
||
getHazopRiskMatrixConfig();
|
||
getJsaRiskMatrixConfig();
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="p-4">
|
||
<div class="space-y-4 scroll">
|
||
<div class="border p-3" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||
<Icon icon="lucide:shield-alert" class="size-16px" />
|
||
风险识别工具(按变更等级匹配)
|
||
</span>
|
||
<NTag size="small" :type="formInfo?.change_level === 2 ? 'error' : 'default'">
|
||
{{ formInfo?.change_level === 2 ? '重要变更' : '一般变更' }}
|
||
</NTag>
|
||
<span class="text-xs text-slate-400">
|
||
{{ formInfo?.change_level === 2 ? '规则:HAZOP 必做,同时开展 JSA' : '规则:JSA,涉及工艺安全边界的应升级 HAZOP' }}
|
||
</span>
|
||
</div>
|
||
<div class="grid gap-2 grid-cols-4 mt-3">
|
||
<div v-for="t in TOOL_CARDS" :key="t.key"
|
||
class="flex items-center gap-2 rounded-lg border px-3 py-2 transition"
|
||
:class="pluginDone[t.key] !== undefined ? 'border-[var(--theme-color)] bg-[var(--theme-color)]' : tools.includes(t.key) ? 'border-[var(--theme-color)] bg-white' : 'border-slate-200 bg-white'"
|
||
:style="{'--theme-color': themeStore.themeColor}"
|
||
>
|
||
<div class="min-w-0 flex-1">
|
||
<div
|
||
class="flex items-center gap-1.5 text-xs font-semibold"
|
||
:class="pluginDone[t.key] !== undefined ? 'text-white' : tools.includes(t.key) ? 'text-[var(--theme-color)]' : 'text-slate-500'"
|
||
:style="{'--theme-color': themeStore.themeColor}"
|
||
>
|
||
<Icon icon="fluent:wand-24-regular" class="size-16px" />
|
||
{{ t.name }}
|
||
</div>
|
||
<div class="mt-0.5 text-[10px]" :class="pluginDone[t.key] !== undefined ? 'text-white' : 'text-slate-400'">
|
||
{{ pluginDone[t.key] !== undefined ? `已使用并分析了 ${pluginDone[t.key]} 条内容` : '未使用此工具分析' }}
|
||
</div>
|
||
</div>
|
||
<NButton size="small" class="text-xs !text-white" :tertiary="pluginDone[t.key] !== undefined" type="primary" @click="usePlugin(t.key)">
|
||
使用
|
||
</NButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||
<div class="flex flex-wrap items-center gap-2 bg-slate-100 px-3 py-2">
|
||
<span class="flex items-center gap-1.5 text-sm font-medium text-slate-700">
|
||
<Icon icon="proicons:alert-triangle" class="size-16px text-amber-500" />
|
||
风险分析记录表
|
||
</span>
|
||
<span class="text-[11px] text-slate-400">共 {{ riskRecords.length }} 项</span>
|
||
<span class="ml-auto hidden text-[11px] text-slate-400 lg:block">插件结果「入表」或 AI 自动整理形成;单元格点击修改,风险等级点击切换</span>
|
||
<NButton ghost size="small" color="#7c3aed" class="text-xs" @click="toPssrAll">
|
||
<Icon icon="tabler:clipboard-check" class="size-14px" />
|
||
全部转 PSSR 行动项
|
||
</NButton>
|
||
<NButton v-if="isAiOpen" ghost size="small" type="primary" class="text-xs" @click="aiOrganize">
|
||
<Icon icon="lucide:sparkles" class="size-12px" />
|
||
AI 自动整理
|
||
</NButton>
|
||
</div>
|
||
<div v-if="riskRecords.length === 0" class="px-3 py-6 text-center text-xs text-slate-400">
|
||
尚无风险记录:调用上方插件完成分析后逐条「入表」,或点击「AI 自动整理」形成记录表
|
||
</div>
|
||
<div v-else class="overflow-x-auto p-3">
|
||
<table class="w-full border-collapse text-xs">
|
||
<thead>
|
||
<tr>
|
||
<th class="w-40 border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">风险项</th>
|
||
<th class="w-80 border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">场景描述</th>
|
||
<th class="w-16 border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">固有风险</th>
|
||
<th class="w-60 border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">现有措施</th>
|
||
<th class="w-60 border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">建议措施</th>
|
||
<th class="w-16 border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">剩余风险</th>
|
||
<th class="w-16 border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">来源</th>
|
||
<th class="w-15 border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(rec, i) in riskRecords" :key="i">
|
||
<td class="border px-1.5 py-1 font-medium text-slate-700">
|
||
<NInput type="textarea" size="small" v-model:value="rec.item" :autosize="{minRows:1}" class="text-xs" />
|
||
</td>
|
||
<td class="border px-1.5 py-1">
|
||
<NInput type="textarea" size="small" v-model:value="rec.scene" :autosize="{minRows:1}" class="text-xs" />
|
||
</td>
|
||
<td class="border px-1.5 py-1 text-center align-top">
|
||
<div v-if="rec.source==='RISK'">
|
||
<NTag
|
||
v-if="rec.inherent_level && rec.inherent_level === '低'"
|
||
class="text-[11px]"
|
||
size="small"
|
||
type="success"
|
||
>
|
||
{{rec.inherent_level}}
|
||
</NTag>
|
||
<NTag
|
||
v-if="rec.inherent_level && rec.inherent_level === '中'"
|
||
class="text-[11px]"
|
||
size="small"
|
||
type="warning"
|
||
>
|
||
{{rec.inherent_level}}
|
||
</NTag>
|
||
<NTag
|
||
v-if="rec.inherent_level && rec.inherent_level === '高'"
|
||
class="text-[11px]"
|
||
size="small"
|
||
type="error"
|
||
>
|
||
{{rec.inherent_level}}
|
||
</NTag>
|
||
</div>
|
||
<div v-if="rec.source==='HAZOP'">
|
||
<NTag
|
||
v-if="rec.l_value>0 && rec.s_value>0"
|
||
class="text-[11px]"
|
||
size="small"
|
||
:color="{
|
||
color: hazopRiskLevel(rec.l_value, rec.s_value)?.bg_color,
|
||
textColor: hazopRiskLevel(rec.l_value, rec.s_value)?.font_color,
|
||
borderColor: hazopRiskLevel(rec.l_value, rec.s_value)?.bg_color,
|
||
}"
|
||
>
|
||
{{hazopRiskLevel(rec.l_value, rec.s_value)?.name}}
|
||
</NTag>
|
||
</div>
|
||
<div v-if="rec.source==='JSA'">
|
||
<NTag
|
||
v-if="rec.l_value>0 && rec.s_value>0"
|
||
class="text-[11px]"
|
||
size="small"
|
||
:color="{
|
||
color: jsaRiskLevel(rec.l_value, rec.s_value)?.bg_color,
|
||
textColor: jsaRiskLevel(rec.l_value, rec.s_value)?.font_color,
|
||
borderColor: jsaRiskLevel(rec.l_value, rec.s_value)?.bg_color,
|
||
}"
|
||
>
|
||
{{jsaRiskLevel(rec.l_value, rec.s_value)?.name}}
|
||
</NTag>
|
||
</div>
|
||
</td>
|
||
<td class="border px-1.5 py-1">
|
||
<NInput type="textarea" size="small" v-model:value="rec.safeguards" :autosize="{minRows:1}" class="text-xs" />
|
||
</td>
|
||
<td class="border px-1.5 py-1">
|
||
<NInput type="textarea" size="small" v-model:value="rec.suggestions" :autosize="{minRows:1}" class="text-xs" />
|
||
</td>
|
||
<td class="border px-1.5 py-1 text-center align-top">
|
||
<div v-if="rec.source==='RISK'">
|
||
<NTag
|
||
v-if="rec.residual_level && rec.residual_level === '低'"
|
||
class="text-[11px]"
|
||
size="small"
|
||
type="success"
|
||
>
|
||
{{rec.residual_level}}
|
||
</NTag>
|
||
<NTag
|
||
v-if="rec.residual_level && rec.residual_level === '中'"
|
||
class="text-[11px]"
|
||
size="small"
|
||
type="warning"
|
||
>
|
||
{{rec.residual_level}}
|
||
</NTag>
|
||
<NTag
|
||
v-if="rec.residual_level && rec.residual_level === '高'"
|
||
class="text-[11px]"
|
||
size="small"
|
||
type="error"
|
||
>
|
||
{{rec.residual_level}}
|
||
</NTag>
|
||
</div>
|
||
<div v-if="rec.source==='HAZOP'">
|
||
<NTag
|
||
v-if="rec.l_value>0 && rec.s_value>0"
|
||
class="text-[11px]"
|
||
size="small"
|
||
:color="{
|
||
color: countHazopRiskLevel(rec)?.bg_color,
|
||
textColor: countHazopRiskLevel(rec)?.font_color,
|
||
borderColor: countHazopRiskLevel(rec)?.bg_color,
|
||
}"
|
||
>
|
||
<span>
|
||
{{countHazopRiskLevel(rec)?.name}}
|
||
</span>
|
||
</NTag>
|
||
</div>
|
||
<div v-if="rec.source==='JSA'">
|
||
<NTag
|
||
v-if="rec.l_value>0 && rec.s_value>0"
|
||
class="text-[11px]"
|
||
size="small"
|
||
:color="{
|
||
color: countJsaRiskLevel(rec)?.bg_color,
|
||
textColor: countJsaRiskLevel(rec)?.font_color,
|
||
borderColor: countJsaRiskLevel(rec)?.bg_color,
|
||
}"
|
||
>
|
||
<span>
|
||
{{countJsaRiskLevel(rec)?.name}}
|
||
</span>
|
||
</NTag>
|
||
</div>
|
||
</td>
|
||
<td class="border px-1.5 py-1 text-center align-top">
|
||
<NTag size="small" type="info" class="text-11px">{{ rec.source==='RISK'?'风险预分析':rec.source }}</NTag>
|
||
</td>
|
||
<td class="border px-1.5 py-1 text-center align-top">
|
||
<NTag v-if="rec.to_pssr" size="small" :color="{ color: '#f5f3ff', textColor: '#7c3aed', borderColor: '#f5f3ff' }">已转 PSSR</NTag>
|
||
<NButton text size="tiny" color="#7c3aed" v-else class="hover:underline" @click="toPssr(Number(i))">转 PSSR</NButton>
|
||
<div>
|
||
<NPopconfirm
|
||
positive-text="确定"
|
||
:positiveButtonProps="{ size: 'tiny' }"
|
||
:negativeButtonProps="{ size: 'tiny' }"
|
||
@positive-click="delRec(Number(i))"
|
||
>
|
||
<template #trigger>
|
||
<NButton text type="error" size="tiny" class="mt-1 hover:underline">删除</NButton>
|
||
</template>
|
||
确定删除吗?
|
||
</NPopconfirm>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Footer
|
||
ref="footerRef"
|
||
:type="props.type"
|
||
:currentId="props.currentId"
|
||
:title="props.title"
|
||
:isAiOpen="props.isAiOpen"
|
||
:currentForm="formInfo"
|
||
:confirmed="props.confirmed"
|
||
@save="saveDraft"
|
||
@submit="submitConfirm"
|
||
@confirmTab="confirmTab"
|
||
/>
|
||
<!-- 插件抽屉 -->
|
||
<NDrawer v-model:show="pluginDrawer" width="85%" placement="right">
|
||
<NDrawerContent>
|
||
<template v-if="currentPlugin === 'RISK'">
|
||
<RiskPre :isAiOpen="props.isAiOpen" :currentId="props.currentId" @close="closePlugin" @toTable="exportAll" />
|
||
</template>
|
||
<template v-if="currentPlugin === 'HAZOP'">
|
||
<Hazop :isAiOpen="props.isAiOpen" :currentId="props.currentId" :hazopRiskMatrixConfig="hazopRiskMatrixConfig" @close="closePlugin" @toTable="exportAll" />
|
||
</template>
|
||
<template v-if="currentPlugin === 'JSA'">
|
||
<Jsa :isAiOpen="props.isAiOpen" :currentId="props.currentId" :jsaRiskMatrixConfig="jsaRiskMatrixConfig" @close="closePlugin" @toTable="exportAll" />
|
||
</template>
|
||
<template v-if="currentPlugin === 'SCL'">
|
||
<Scl @close="closePlugin" @toTable="exportAll" />
|
||
</template>
|
||
<template v-if="currentPlugin === 'RISK_CHECK'">
|
||
<RiskCheck :isAiOpen="props.isAiOpen" :currentId="props.currentId" @close="closePlugin" />
|
||
</template>
|
||
</NDrawerContent>
|
||
</NDrawer>
|
||
</template>
|
||
<style scoped lang="scss">
|
||
.scroll {
|
||
height: calc(100vh - 393px);
|
||
overflow-y: auto;
|
||
}
|
||
</style> |