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

353 lines
16 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 { ref } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { AiError, aiRiskCheck } from "@/service/api/ai";
import Ed from "./Ed.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'
const themeStore = useThemeStore();
const props = defineProps(['type','isAiOpen','title','currentId','formInfo','preInfo'])
const emit = defineEmits(['save','submit','confirmTab']);
const footerRef = ref<any>(null);
const aiFail = (e: any) => {
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
else window.$message?.warning("AI 分析失败,可手动填写");
};
const form = ref<{ content: string }>({
content: ''
});
const pluginDone = ref<Record<string, number>>({});
const tools = ref<string[]>(["HAZOP","JSA", "SCL", "RISK_CHECK"]);
const riskRecords = ref<any>([]);
// 风险检查表检查项(数据提升到本组件:AI 预填/重新生成随时可写,插件抽屉打开即渲染)
const riskChecks = ref<any[]>([]);
const riskCheckLoading = ref<boolean>(false);
const pluginDrawer = ref<boolean>(false);
const currentPlugin = ref<string>('');
const lvlNext = (v: string) => (v === "高" ? "中" : v === "中" ? "低" : "高");
// AI 生成/重新生成风险检查表(footer 派发;整表生成约 1~3 分钟,42901 闸门满自动重试一次)
const generateRiskCheck = async () => {
const info = props.preInfo;
if (!info?.description) {
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
}
if (riskCheckLoading.value) return;
riskCheckLoading.value = true;
footerRef.value?.setRegenerateLoading(true);
try {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await aiRiskCheck(info.description, (info.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
const items = (res?.items ?? []).map((x: any, i: number) => ({
id: i + 1,
group: x.group || '安全健康',
q: x.q ?? '',
ans: x.ans ?? 'NA',
risk: x.risk ?? '',
measure: x.measure ?? '',
}));
if (!items.length) {
window.$message?.warning("AI 未生成检查项,可在风险检查表插件中手动维护");
return;
}
riskChecks.value = items;
window.$message?.success(`风险检查表已由 AI 生成(${items.length} 项),打开「风险检查表」工具卡逐项确认`);
return;
} catch (e: any) {
if (e instanceof AiError && e.code === 42901 && attempt === 0) {
const waitSec = (e.data?.retry_after as number) ?? 30;
window.$message?.info(`当前 AI 生成任务较多,${waitSec} 秒后自动重试一次…`);
await new Promise((r) => setTimeout(r, waitSec * 1000));
continue;
}
aiFail(e);
return;
}
}
} finally {
riskCheckLoading.value = false;
footerRef.value?.setRegenerateLoading(false);
}
};
const onRegenerate = generateRiskCheck;
// 风险识别分析记录表
const TOOL_CARDS = [
{ key: "HAZOP" as const, name: "HAZOP 分析", desc: "节点-偏离结构化分析:原因 / 后果 / LS 风险矩阵 / 安全措施" },
{ key: "JSA" as const, name: "JSA 分析", desc: "作业步骤分解:危害因素识别与 LS 风险分级管控" },
{ key: "SCL" as const, name: "设备 SCL", desc: "设备 / 管理检查项目对照标准逐项核查" },
{ key: "RISK_CHECK" as const, name: "风险检查表", desc: "变更通用检查项:是 / 否 / 不涉及逐项判定,输出风险描述与管控措施" },
];
const pssrGroups = ref<{ g: string; items: any[] }[]>([
{ g: "一、风险分析行动项落实", items: [
{ c: "联锁跳车电流设定值校核并留存记录", p: "投用前" },
{ c: "电缆与开关容量校核升级完成", p: "投用前" },
{ c: "操作人员培训完成并考核合格", p: "投用前" },
] },
{ g: "二、现场设备安装检查确认", items: [
{ c: "电机安装就位,联轴器对中合格", p: "投用前" },
{ c: "地脚螺栓紧固,减振垫安装到位", p: "投用前" },
{ c: "电缆接线、防爆格兰头密封良好", p: "投用前" },
] },
{ g: "三、保护措施确认", items: [
{ c: "联锁跳车设定值与新电机额定电流匹配", p: "投用前" },
{ c: "电机外壳接地、防护罩完好", p: "投用前" },
{ c: "紧急停机按钮功能试验正常", p: "投用前" },
] },
{ g: "四、资料归档确认", items: [
{ c: "P&ID(图号 PID-R201-03)已更新并受控", p: "关闭前" },
{ c: "设备台账、备件清单已更新", p: "关闭前" },
{ c: "培训签到表、考核记录已归档", p: "关闭前" },
] },
]);
const toPssrAll = () => {
const rest = riskRecords.value.map((x:any, i:any) => (x.pssr ? -1 : i)).filter((i:any) => i >= 0);
if (!rest.length) { window.$message?.info("所有建议措施均已转为 PSSR 行动项"); return; }
const items = rest.map((i:any) => ({ c: `${riskRecords.value[i].suggest}(来源:风险分析记录表 · ${riskRecords.value[i].item}`, p: "投用前" }));
pssrGroups.value = pssrGroups.value.map((g:any, gi:any) => gi === 0 ? { ...g, items: [...g.items, ...items] } : g);
riskRecords.value = riskRecords.value.map((x:any) => ({ ...x, pssr: true }));
window.$message?.success(`已将 ${rest.length} 条建议措施全部转为 PSSR 行动项(见「PSSR 检查内容」)`);
};
const lsLevel = (l: number | null, s: number | null) => {
const rr = (l ?? 0) * (s ?? 0);
return rr >= 10 ? "高" : rr >= 5 ? "中" : "低";
};
const aiOrganize = async () => {
// let form = changePreRef.value?.form;
// analyzing.value = true;
// try {
// const res = await aiRiskOrganize(form.description, (form.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
// const gen = (res?.records ?? []).map((r) => ({
// item: r.item,
// scene: r.scene,
// inherent: lsLevel(r.l, r.s),
// existing: r.existing,
// suggest: r.suggest,
// residual: lsLevel(r.residual_l, r.residual_s),
// source: "AI 整理",
// }));
// riskRecords.value = [...riskRecords.value, ...gen];
// window.$message?.success(`AI 已根据变更内容自动整理形成风险分析记录表(${gen.length} 项),支持手动修改`);
// } catch (e: any) {
// aiFail(e);
// } finally {
// analyzing.value = false;
// }
};
const toPssr = (i: number) => {
const rec = riskRecords.value[i];
if (!rec || rec.pssr) return;
pssrGroups.value = pssrGroups.value.map((g:any, gi:any) => gi === 0
? { ...g, items: [...g.items, { c: `${rec.suggest}(来源:风险分析记录表 · ${rec.item}`, p: "投用前" }] }
: g);
updRec(i, { pssr: true });
window.$message?.success("建议措施已转为 PSSR 行动项(见「PSSR 检查内容」· 一、风险分析行动项落实)");
};
const updRec = (i: number, patch: Partial<any>) => {
riskRecords.value = riskRecords.value.map((x, j) => (j === i ? { ...x, ...patch } : x));
};
const exportAll = (items: any[], mode: "manual" | "ai") => {
riskRecords.value = [...riskRecords.value, ...items];
if (mode === "ai") {
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 = () => {
pluginDrawer.value = false;
}
// 保存草稿
const saveDraft = () => {
emit('save');
}
// 提交
const submitConfirm = () => {
emit('submit', props.type);
}
// // 确认标签内容
const confirmTab = (tab:string, val:boolean) => {
emit('confirmTab', tab, val);
}
defineExpose({form, generateRiskCheck})
</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 与检查表法(SCL)' : '规则:JSA + 检查表法(SCL),涉及工艺安全边界的应升级 HAZOP' }}
</span>
</div>
<div class="grid gap-2 grid-cols-4 mt-3">
<div v-for="t in TOOL_CARDS" :key="t.key" :title="t.desc"
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/75' : 'text-slate-400'">
{{ pluginDone[t.key] !== undefined ? `已使用并分析了 ${pluginDone[t.key]} 条内容` : '未使用此工具分析' }}
</div>
</div>
<NButton size="small" class="text-xs" 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 min-w-[960px] border-collapse text-xs">
<thead>
<tr>
<th v-for="h in ['风险项', '场景描述', '固有风险', '现有措施', '建议措施', '剩余风险', '来源', '操作']" :key="h"
class="border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">{{ h }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(rec, i) in riskRecords" :key="i">
<td class="border px-1.5 py-1 align-top font-medium text-slate-700">
<Ed :v="rec.item" @update="(x) => updRec(i, { item: x })" />
</td>
<td class="border px-1.5 py-1 align-top"><Ed :v="rec.scene" @update="(x) => updRec(i, { scene: x })" /></td>
<td class="border px-1.5 py-1 text-center align-top">
<NTag v-if="rec.inherent==='高'" type="error" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { inherent: lvlNext(rec.inherent) as '高' | '中' | '低' })">
{{ rec.inherent }}
</NTag>
<NTag v-if="rec.inherent==='中'" type="warning" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { inherent: lvlNext(rec.inherent) as '高' | '中' | '低' })">
{{ rec.inherent }}
</NTag>
<NTag v-if="rec.inherent==='低'" type="success" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { inherent: lvlNext(rec.inherent) as '高' | '中' | '低' })">
{{ rec.inherent }}
</NTag>
</td>
<td class="border px-1.5 py-1 align-top"><Ed :v="rec.existing" @update="(x) => updRec(i, { existing: x })" /></td>
<td class="border px-1.5 py-1 align-top"><Ed :v="rec.suggest" @update="(x) => updRec(i, { suggest: x })" /></td>
<td class="border px-1.5 py-1 text-center align-top">
<NTag v-if="rec.residual==='高'" type="error" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { residual: lvlNext(rec.residual) as '高' | '中' | '低' })">
{{ rec.residual }}
</NTag>
<NTag v-if="rec.residual==='中'" type="warning" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { residual: lvlNext(rec.residual) as '高' | '中' | '低' })">
{{ rec.residual }}
</NTag>
<NTag v-if="rec.residual==='低'" type="success" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { residual: lvlNext(rec.residual) as '高' | '中' | '低' })">
{{ rec.residual }}
</NTag>
</td>
<td class="border px-1.5 py-1 text-center align-top">
<NTag size="small" type="info">{{ rec.source }}</NTag>
</td>
<td class="border px-1.5 py-1 text-center align-top">
<NTag v-if="rec.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(i)"> PSSR</NButton>
<NButton text type="error" size="tiny" class="mt-2" @click="riskRecords = riskRecords.filter((_, j) => j !== i)">删除</NButton>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<Footer
ref="footerRef"
:type="props.type"
:currentId="props.currentId"
:title="props.title"
:isAiOpen="props.isAiOpen"
:currentForm="form"
@save="saveDraft"
@submit="submitConfirm"
@confirmTab="confirmTab"
@regenerate="onRegenerate"
/>
<!-- 插件抽屉 -->
<NDrawer v-model:show="pluginDrawer" :width="1200" placement="right">
<NDrawerContent>
<template v-if="currentPlugin === 'HAZOP'">
<Hazop :isAiOpen="props.isAiOpen" :currentId="props.currentId" @close="closePlugin" @export="exportAll" />
</template>
<template v-else-if="currentPlugin === 'JSA'">
<Jsa @close="closePlugin" @export="exportAll" />
</template>
<template v-else-if="currentPlugin === 'SCL'">
<Scl @close="closePlugin" @export="exportAll" />
</template>
<template v-else-if="currentPlugin === 'RISK_CHECK'">
<RiskCheck v-model="riskChecks" :loading="riskCheckLoading" @close="closePlugin" @back="closePlugin" />
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 393px);
overflow-y: auto;
}
</style>