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

490 lines
20 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, 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>