FEMA矩阵配置完成,FEMA插件静态页面完成

This commit is contained in:
2026-09-14 14:59:27 +08:00
parent a7cd8f1901
commit 9bd865cb2c
7 changed files with 1974 additions and 119 deletions
@@ -99,9 +99,9 @@ const PURPOSE_OPTIONS = [
const UPDATE_DOC_OPTIONS = ['PID图纸', '操作规程', '总图', '设备台账', '工艺卡片', '联锁台账', '应急预案'];
// 风险分析选项
const RISK_TOOLS_FORM = [
{ key: "RISK", label: "风险预分析" },
{ key: "HAZOP", label: "HAZOP" },
{ key: "JSA", label: "JSA" },
{ key: "SCL", label: "设备SCL" },
{ key: "FMEA", label: "FMEA" },
{ key: "RISK_CHECK", label: "风险检查表" },
];
const questions = ref<any>([
File diff suppressed because it is too large Load Diff
@@ -737,8 +737,8 @@ const getMenuOptions = (type: string) => {
{ label: '删除分析对象', key: 'delObj' },
]
const devOptions = [
{ label: '编辑离', key: 'editDev' },
{ label: '删除离', key: 'delDev' },
{ label: '编辑离', key: 'editDev' },
{ label: '删除离', key: 'delDev' },
]
const map:any = {
node: nodeOptions,
@@ -1105,7 +1105,7 @@ onBeforeUnmount(() => {
</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">
<td colspan="9" class="px-3 py-8 text-center text-xs text-slate-400">
当前偏离暂无分析记录点击右上角AI 分析当前偏离自动生成新增行手动填写
</td>
</tr>
@@ -1389,9 +1389,9 @@ onBeforeUnmount(() => {
<div
class="mr-[5px] cursor-grab"
:draggable="true"
@dragstart="onDeviceDragStart($event, objIndex)"
@dragstart="onDeviceDragStart($event, Number(objIndex))"
@dragover.prevent
@drop="onDeviceDrop($event, objIndex)"
@drop="onDeviceDrop($event, Number(objIndex))"
>
<Icon icon="gravity-ui:grip" class="size-12px" />
</div>
@@ -1428,9 +1428,9 @@ onBeforeUnmount(() => {
<div
class="mr-[5px] cursor-grab"
:draggable="true"
@dragstart="onDeviateDragStart($event, objIndex, devIndex)"
@dragstart="onDeviateDragStart($event, Number(objIndex), Number(devIndex))"
@dragover.prevent
@drop="onDeviateDrop($event, objIndex, devIndex)"
@drop="onDeviateDrop($event, Number(objIndex), Number(devIndex))"
>
<Icon icon="gravity-ui:grip" class="size-12px" />
</div>
@@ -6,7 +6,7 @@ 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 Fmea from "./fmea.vue";
import RiskCheck from "./riskCheck.vue";
import Footer from './footer.vue'
import { matrixConfigApi } from "@/service/api/system";
@@ -19,12 +19,13 @@ 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'
'created_at', 'updated_at', 'change_id', 'source_row_id'
]));
riskRecords.value = result || [];
})
const hazopRiskMatrixConfig = ref<any>(null);
const fmeaRiskMatrixConfig = ref<any>(null);
const jsaRiskMatrixConfig = ref<any>(null);
const pluginDone = computed(() =>
riskRecords.value.reduce((acc:any, item:any) => {
@@ -33,7 +34,7 @@ const pluginDone = computed(() =>
return acc;
}, {} as Record<string, number>)
);
const tools = ref<string[]>(["RISK","HAZOP","JSA", "RISK_CHECK"]);
const tools = ref<string[]>(["RISK","HAZOP","FMEA", "RISK_CHECK"]);
const riskRecords = ref<any>([]);
const pluginDrawer = ref<boolean>(false);
const currentPlugin = ref<string>('');
@@ -42,7 +43,7 @@ const currentPlugin = ref<string>('');
const TOOL_CARDS = [
{ key: "RISK", name: "风险预分析" },
{ key: "HAZOP", name: "HAZOP 分析" },
{ key: "JSA", name: "JSA 分析" },
{ key: "FMEA", name: "FMEA 分析" },
{ key: "RISK_CHECK", name: "风险检查表" },
];
@@ -157,6 +158,10 @@ return result;
}
// 整理入表
const exportAll = (type: string, items: any[]) => {
// console.log(items)
// console.log(riskRecords.value)
// console.log([...riskRecords.value, ...items])
// 如果有id且id!=0,就用id去重,否则根据对象内容去重
riskRecords.value = uniqueById([...riskRecords.value, ...items]);
if (type === "all") {
pluginDrawer.value = false;
@@ -173,10 +178,7 @@ const usePlugin = (key: string) => {
pluginDrawer.value = true;
}
// 关闭插件弹框
const closePlugin = (type: string = "", count: any = undefined) => {
if(type){
pluginDone.value[type] = count;
}
const closePlugin = () => {
pluginDrawer.value = false;
}
@@ -193,26 +195,20 @@ 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");
const getRiskMatrixConfig = async () => {
const {data,error} = await matrixConfigApi();
if(!error){
jsaRiskMatrixConfig.value = data;
hazopRiskMatrixConfig.value = data?.hazop || null;
jsaRiskMatrixConfig.value = data?.jsa || null;
fmeaRiskMatrixConfig.value = data?.fmea || null;
}
}
defineExpose({riskRecords})
onMounted(() => {
getHazopRiskMatrixConfig();
getJsaRiskMatrixConfig();
getRiskMatrixConfig();
})
</script>
@@ -229,7 +225,7 @@ onMounted(() => {
{{ formInfo?.change_level === 2 ? '重要变更' : '一般变更' }}
</NTag>
<span class="text-xs text-slate-400">
{{ formInfo?.change_level === 2 ? '规则:HAZOP 必做,同时开展 JSA' : '规则:JSA涉及工艺安全边界的应升级 HAZOP' }}
{{ formInfo?.change_level === 2 ? '规则:HAZOP 必做' : '规则:涉及工艺安全边界的应升级 HAZOP' }}
</span>
</div>
<div class="grid gap-2 grid-cols-4 mt-3">
@@ -355,6 +351,16 @@ onMounted(() => {
{{jsaRiskLevel(rec.l_value, rec.s_value)?.name}}
</NTag>
</div>
<div v-if="rec.source==='RISK_CHECK'">
<NTag
class="text-[11px]"
size="small"
>
<span>
{{rec.inherent_level?rec.inherent_level:'-'}}
</span>
</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" />
@@ -421,9 +427,19 @@ onMounted(() => {
</span>
</NTag>
</div>
<div v-if="rec.source==='RISK_CHECK'">
<NTag
class="text-[11px]"
size="small"
>
<span>
{{rec.residual_level?rec.residual_level:'-'}}
</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>
<NTag size="small" type="info" class="text-11px">{{ rec.source==='RISK'?'风险预分析':rec.source==='RISK_CHECK'?'风险检查': 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>
@@ -470,14 +486,11 @@ onMounted(() => {
<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 v-if="currentPlugin === 'FMEA'">
<Fmea :isAiOpen="props.isAiOpen" :currentId="props.currentId" :fmeaRiskMatrixConfig="fmeaRiskMatrixConfig" @close="closePlugin" @toTable="exportAll" />
</template>
<template v-if="currentPlugin === 'RISK_CHECK'">
<RiskCheck :isAiOpen="props.isAiOpen" :currentId="props.currentId" @close="closePlugin" />
<RiskCheck :isAiOpen="props.isAiOpen" :currentId="props.currentId" @close="closePlugin" @toTable="exportAll" />
</template>
</NDrawerContent>
</NDrawer>
@@ -4,7 +4,7 @@ import { Icon } from '@iconify/vue'
import { riskCheckDetailApi, riskCheckSaveApi } from '@/service/api/plugin'
const props = defineProps(['isAiOpen','currentId'])
const emit = defineEmits(['close']);
const emit = defineEmits(['close','toTable']);
// answer 1 是
// answer 0 否
@@ -16,35 +16,35 @@ const RISK_CHECKS_INIT = [
group_label: "安全健康",
items: [
{
question_id: 1,
question_id: 'RISK_CHECK'+1,
answer: null,
question: "是否存在任何可能导致安全问题的化学反应?检查化学反应禁忌表。",
risk_desc: "",
control_measure: "",
},
{
question_id: 2,
question_id: 'RISK_CHECK'+2,
answer: null,
question: "是否存在任何易燃易爆化学物质或粉尘?",
risk_desc: "",
control_measure: "",
},
{
question_id: 3,
question_id: 'RISK_CHECK'+3,
answer: null,
question: "是否存在任何有危险的原材料流速、成分或温度条件变更?",
risk_desc: "",
control_measure: "",
},
{
question_id: 4,
question_id: 'RISK_CHECK'+4,
answer: null,
question: "是否会因加热、冷却或精确的温度控制失控而导致安全问题?",
risk_desc: "",
control_measure: "",
},
{
question_id: 5,
question_id: 'RISK_CHECK'+5,
answer: null,
question: "是否具有为安全操作所必需的仪表、控制、紧急制动、开关或报警?",
risk_desc: "",
@@ -57,14 +57,14 @@ const RISK_CHECKS_INIT = [
group_label: "工艺与操作",
items: [
{
question_id: 6,
question_id: 'RISK_CHECK'+6,
answer: null,
question: "变更后是否需要对操作人员进行专门培训?",
risk_desc: "",
control_measure: "",
},
{
question_id: 7,
question_id: 'RISK_CHECK'+7,
answer: null,
question: "是否涉及应急预案或消防设施的调整?",
risk_desc: "",
@@ -73,11 +73,10 @@ const RISK_CHECKS_INIT = [
],
}
];
// 是否是ai生成
const isAiGenerated = ref<boolean>(false);
const loading = ref<boolean>(false);
const riskGroups = ref<any[]>(RISK_CHECKS_INIT);
const riskFilter = ref<number>(-1);
const analyzing = ref<boolean>(false);
// 过滤风险项
const filterChange = (v: number) => {
@@ -92,11 +91,6 @@ const filterChange = (v: number) => {
riskGroups.value = result;
}
};
// 返回工具列表
const onBack = () => {
const count = riskGroups.value.flatMap((group: any) =>group.items.filter((item: any) => item.answer !== null)).length;
emit('close','RISK_CHECK',count===0?undefined:count);
}
// 保存
const save = async () => {
loading.value = true;
@@ -108,6 +102,51 @@ const save = async () => {
}
loading.value = false;
};
// AI整理如表、入表
const enterToTable = (type: string, groupIndex: number = -1, questionIndex: number = -1) => {
if(type==='single'){
const row = riskGroups.value[groupIndex]?.items[questionIndex];
const newRow = {
scene: row.risk_desc || '',
source: 'RISK_CHECK',
item: row.question || '',
inherent_level: '',
safeguards: row?.control_measure || '',
suggestions: '',
residual_level: '',
to_pssr: 0,
}
emit('toTable',type,newRow?[newRow]:[]);
}
if(type==='all'){
const enrichedRows:any[] = [];
riskGroups.value.forEach((group:any) => {
group.items.forEach((row:any) => {
enrichedRows.push({
scene: row.risk_desc || '',
source: 'RISK_CHECK',
item: row.question || '',
inherent_level: '',
safeguards: row?.control_measure || '',
suggestions: '',
residual_level: '',
to_pssr: 0,
});
})
})
emit('toTable',type,enrichedRows);
}
}
// AI 自动分析工序
const runAi = async () => {
analyzing.value = true;
setTimeout(() => {
analyzing.value = false;
}, 1000);
};
// 获取风险检查表详情
const getRiskCheckDetail = async () => {
loading.value = true;
@@ -130,7 +169,7 @@ onMounted(() => {
<NSpin :show="loading" size="small">
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<NButton size="small" ghost type="primary" class="text-xs" @click="onBack">
<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">保存</NButton>
@@ -142,27 +181,37 @@ onMounted(() => {
<NRadio :value=1>仅看</NRadio>
</NRadioGroup>
</div>
<span v-if="isAiGenerated" class="ml-auto inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
AI 生成 · 需人工确认
</span>
<div class="ml-auto flex items-center gap-1.5">
<NButton size="small" ghost type="primary" class="text-xs" @click="enterToTable('all')">
<Icon icon="carbon:document-import" class="size-14px mr-1" /> 全部整理入表
</NButton>
<NButton 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>
<p class="text-[11px] text-slate-400">逐项判定 / / NA判定为的检查项输出风险描述与管控措施完成后点返回工具列表回显完成项数</p>
<div class="space-y-4 scroll">
<div v-for="group in riskGroups" :key="group.group_id" class="rounded-lg border">
<div v-for="(group,groupIndex) in riskGroups" :key="group.group_id" class="rounded-lg border">
<div class="bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700">{{ group.group_label }}</div>
<div v-for="item in group.items" :key="item.question_id"
<div v-for="(item,questionIndex) in group.items" :key="item.question_id"
class="grid gap-3 border-t px-3 py-3 grid-cols-[1.1fr_1fr_1fr] min-h-77px">
<div>
<div class="text-sm text-slate-700">{{ item.question }}</div>
<div class="mt-2 flex items-center gap-4">
<NRadioGroup v-model:value="item.answer" name="radiogroup">
<div class="flex gap-2">
<NRadio :value="1"></NRadio>
<NRadio :value=0></NRadio>
<NRadio :value=2>NA</NRadio>
<div class="flex items-start gap-2">
<NButton text type="primary" class="text-xs mt-2px" @click="enterToTable('single',Number(groupIndex),Number(questionIndex))">入表</NButton>
<div>
<div class="text-sm text-slate-700">{{ item.question }}</div>
<div class="mt-2">
<NRadioGroup v-model:value="item.answer" name="radiogroup">
<div class="flex gap-2">
<NRadio :value="1"></NRadio>
<NRadio :value=0></NRadio>
<NRadio :value=2>NA</NRadio>
</div>
</NRadioGroup>
</div>
</NRadioGroup>
</div>
</div>
</div>
<template v-if="item.answer === 1">
<NInput v-model:value="item.risk_desc" type="textarea" size="small" :autosize="{minRows:2}" placeholder="风险描述" />