高风险场景查看分析记录,hazop和fmea完成

This commit is contained in:
2026-09-20 16:18:18 +08:00
parent 05b7357ca6
commit 7ea6ef4f2d
8 changed files with 1125 additions and 67 deletions
+7 -1
View File
@@ -8,4 +8,10 @@ export function highRiskListApi(params:any) {
params,
});
}
// 变更分析记录列表
export function analysisRecordListApi(id:number) {
return request({
url: `/api/changes/${id}/analysis-records`,
method: 'get',
});
}
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { analysisRecordListApi } from "@/service/api/highRisk";
const themeStore = useThemeStore();
const props = defineProps(['currentId', 'info', 'fmeaRiskMatrixConfig'])
const emit = defineEmits(['close'])
const loading = ref<boolean>(false);
// 当前偏离
const currentDev = ref<any>(null);
// 展开收起
const collapsed = ref<Record<string, boolean>>({})
// 节点列表
const nodes = ref<any>([]);
// 点击偏离
const clickDev = (dev:any) => {
currentDev.value = dev
}
// 根据l和s计算RR风险等级
const riskLevel = (l: number, s: number) => {
const num = l * s;
for (let i = 0; i < props.fmeaRiskMatrixConfig?.risk_grades.length; i++) {
const item = props.fmeaRiskMatrixConfig?.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 leftToggle = (id: string) => {
collapsed.value = { ...collapsed.value, [id]: !collapsed.value[id] }
}
// 导出数据
const exportData = () => {
window.$message?.info('还差接口')
}
// 获取记录
const getList = async () => {
loading.value = true;
const {data,error} = await analysisRecordListApi(props.currentId);
if(!error){
nodes.value = data?.fmea?.nodes || [];
clickDev(nodes.value[0]?.deviations[0] || null)
}
loading.value = false;
}
onMounted(() => {
getList();
})
</script>
<template>
<NSpin :show="loading" size="small">
<div class="flex items-center gap-4 mb-3">
<p v-if="props.info?.change_title" class="text-xs text-slate-500">变更:<span class="text-slate-800">{{ props.info?.change_title }}</span></p>
<p v-if="props.info?.created_at" class="text-xs text-slate-500">分析日期{{ props.info?.created_at?.slice(0,10) }}</p>
<p v-if="props.info?.applicant_name" class="text-xs text-slate-500">责任人{{ props.info?.applicant_name }}</p>
<p class="ml-auto text-xs text-slate-500">分析编制与修改在变更上下文内完成此处只读</p>
</div>
<div
class="flex gap-3"
:style="{
'--theme-color': themeStore.themeColor,
'--success-color': themeStore.otherColor.success
}"
>
<!-- 节点 / 偏离树 -->
<div class="w-240px">
<div class="shrink-0 rounded-md border p-2 scroll">
<p v-if="nodes.length === 0" class="text-center text-xs text-slate-400 py-10">暂无数据</p>
<div v-for="(node, nodeIndex) in nodes" :key="nodeIndex">
<div
class="cursor-pointer flex items-center select-none"
@click="leftToggle(node.id)"
>
<Icon v-if="collapsed[node.id]" icon="akar-icons:chevron-right" class="size-12px" />
<Icon v-else icon="akar-icons:chevron-down" class="size-12px" />
<p class="flex-1 px-2 py-1.5 text-xs font-semibold text-slate-700">步骤{{ Number(nodeIndex) + 1 }}{{ node.node_name }}</p>
</div>
<template v-if="!collapsed[node.id]">
<div v-for="(dev, devIndex) in node.deviations" :key="devIndex"
@click="clickDev(dev)"
class="cursor-pointer flex items-center ml-4 gap-1.5 rounded-md px-2 py-1.5 text-left text-xs transition"
:class="currentDev?.id === dev.id ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'"
>
<span class="flex-1">{{ dev.deviation }}</span>
<span class="rounded-full bg-emerald-100 px-1.5 text-[10px] text-emerald-600">
{{ dev.rows.length }}
</span>
</div>
</template>
</div>
</div>
</div>
<!-- FMEA 记录表 -->
<div class="min-w-0 flex-1 bg-white">
<div class="scroll">
<NTable size="small" class="w-full text-xs">
<thead>
<tr>
<th class="w-60">原因</th>
<th class="w-60">后果</th>
<th class="w-6">L</th>
<th class="w-6">S</th>
<th class="w-20">RR</th>
<th class="w-60">安全措施</th>
<th class="w-60">建议措施</th>
<th class="w-20">RR1</th>
</tr>
</thead>
<tbody>
<tr v-if="!currentDev || currentDev?.rows?.length === 0">
<td colspan="9" class="px-3 py-8 text-center text-xs text-slate-400">
当前暂无分析记录
</td>
</tr>
<tr v-for="(row, ri) in currentDev?.rows" :key="ri">
<td class="align-top">
<p class="text-xs">{{row.cause}}</p>
</td>
<td class="align-top">
<p class="text-xs">{{row.consequence}}</p>
</td>
<td>
<p class="text-xs text-center w-10">{{row.l_value}}</p>
</td>
<td>
<p class="text-xs text-center w-10">{{row.s_value}}</p>
</td>
<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="align-top">
<div v-if="row.safeguards && row.safeguards.length" class="flex flex-col gap-3">
<div v-for="(sItem, si) in row.safeguards" :key="si">
<p class="text-xs">{{Number(si)+1}}{{sItem.name}}</p>
</div>
</div>
</td>
<td class="align-top">
<div v-if="row.suggestions && row.suggestions.length" class="flex flex-col gap-2">
<div v-for="(sItem2, si2) in row.suggestions" :key="si2">
<p class="text-xs">{{Number(si2)+1}}{{sItem2.name}}</p>
</div>
</div>
</td>
<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>
</NTable>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-2 pt-3">
<NButton size="small" @click="exportData">
<Icon icon="bi:download" class="mr-1 size-12px" />
导出
</NButton>
<NButton type="primary" size="small" @click="emit('close')">关闭</NButton>
</div>
</NSpin>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 155px);
overflow-y: auto;
}
</style>
+236
View File
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { analysisRecordListApi } from "@/service/api/highRisk";
const themeStore = useThemeStore();
const props = defineProps(['currentId', 'info', 'hazopRiskMatrixConfig'])
const emit = defineEmits(['close'])
const loading = ref<boolean>(false);
// 当前偏离
const currentDev = ref<any>(null);
// 展开收起
const collapsed = ref<Record<string, boolean>>({})
// 节点列表
const nodes = ref<any>([]);
// 点击偏离
const clickDev = (dev:any) => {
currentDev.value = dev
}
// 根据l和s计算RR风险等级
const riskLevel = (l: number, s: number) => {
const num = l * s;
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 leftToggle = (id: string) => {
collapsed.value = { ...collapsed.value, [id]: !collapsed.value[id] }
}
// 导出数据
const exportData = () => {
window.$message?.info('还差接口')
}
// 获取记录
const getList = async () => {
loading.value = true;
const {data,error} = await analysisRecordListApi(props.currentId);
if(!error){
nodes.value = data?.hazop?.nodes || [];
clickDev(nodes.value[0]?.objects[0]?.deviations[0] || null)
}
loading.value = false;
}
onMounted(() => {
getList();
})
</script>
<template>
<NSpin :show="loading" size="small">
<div class="flex items-center gap-4 mb-3">
<p v-if="props.info?.change_title" class="text-xs text-slate-500">变更:<span class="text-slate-800">{{ props.info?.change_title }}</span></p>
<p v-if="props.info?.created_at" class="text-xs text-slate-500">分析日期{{ props.info?.created_at?.slice(0,10) }}</p>
<p v-if="props.info?.applicant_name" class="text-xs text-slate-500">责任人{{ props.info?.applicant_name }}</p>
<p class="ml-auto text-xs text-slate-500">分析编制与修改在变更上下文内完成此处只读</p>
</div>
<div
class="flex gap-3"
:style="{
'--theme-color': themeStore.themeColor,
'--success-color': themeStore.otherColor.success
}"
>
<!-- 节点 / 偏离树 -->
<div class="w-240px">
<div class="shrink-0 rounded-md border p-2 scroll">
<p v-if="nodes.length === 0" class="text-center text-xs text-slate-400 py-10">暂无节点数据</p>
<div v-for="(node, nodeIndex) in nodes" :key="nodeIndex">
<div
class="cursor-pointer flex items-center select-none"
@click="leftToggle(node.id)"
>
<Icon v-if="collapsed[node.id]" icon="akar-icons:chevron-right" class="size-12px" />
<Icon v-else icon="akar-icons:chevron-down" class="size-12px" />
<p class="flex-1 px-2 py-1.5 text-xs font-semibold text-slate-700">节点{{ Number(nodeIndex) + 1 }}{{ node.node_name }}</p>
</div>
<template v-if="!collapsed[node.id]">
<div v-for="(obj, objIndex) in node.objects" :key="objIndex" class="mb-1 ml-3">
<div
class="cursor-pointer flex items-center select-none"
@click="leftToggle(node.id+'_'+obj.id)"
>
<Icon v-if="collapsed[node.id+'_'+obj.id]" icon="akar-icons:chevron-right" class="size-12px" />
<Icon v-else icon="akar-icons:chevron-down" class="size-12px" />
<p class="flex-1 px-2 py-1.5 text-xs font-semibold text-slate-700">{{ obj.obj_name }}</p>
</div>
<template v-if="!collapsed[node.id+'_'+obj.id]">
<div v-for="(dev, devIndex) in obj.deviations" :key="devIndex"
@click="clickDev(dev)"
class="cursor-pointer flex items-center ml-4 gap-1.5 rounded-md px-2 py-1.5 text-left text-xs transition"
:class="currentDev?.id === dev.id ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'"
>
<span class="flex-1">{{ dev.deviation }}</span>
<span class="rounded-full bg-emerald-100 px-1.5 text-[10px] text-emerald-600">
{{ dev.rows.length }}
</span>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
<!-- HAZOP 记录表 -->
<div class="min-w-0 flex-1 bg-white">
<div class="scroll">
<NTable size="small" class="w-full text-xs">
<thead>
<tr>
<th class="w-60">原因</th>
<th class="w-60">后果</th>
<th class="w-6">L</th>
<th class="w-6">S</th>
<th class="w-20">RR</th>
<th class="w-60">安全措施</th>
<th class="w-60">建议措施</th>
<th class="w-20">RR1</th>
</tr>
</thead>
<tbody>
<tr v-if="!currentDev || currentDev?.rows?.length === 0">
<td colspan="9" class="px-3 py-8 text-center text-xs text-slate-400">
当前暂无分析记录
</td>
</tr>
<tr v-for="(row, ri) in currentDev?.rows" :key="ri">
<td class="align-top">
<p class="text-xs">{{row.cause}}</p>
</td>
<td class="align-top">
<p class="text-xs">{{row.consequence}}</p>
</td>
<td>
<p class="text-xs text-center w-10">{{row.l_value}}</p>
</td>
<td>
<p class="text-xs text-center w-10">{{row.s_value}}</p>
</td>
<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="align-top">
<div v-if="row.safeguards && row.safeguards.length" class="flex flex-col gap-3">
<div v-for="(sItem, si) in row.safeguards" :key="si">
<p class="text-xs">{{Number(si)+1}}{{sItem.name}}</p>
</div>
</div>
</td>
<td class="align-top">
<div v-if="row.suggestions && row.suggestions.length" class="flex flex-col gap-2">
<div v-for="(sItem2, si2) in row.suggestions" :key="si2">
<p class="text-xs">{{Number(si2)+1}}{{sItem2.name}}</p>
</div>
</div>
</td>
<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>
</NTable>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-2 pt-3">
<NButton size="small" @click="exportData">
<Icon icon="bi:download" class="mr-1 size-12px" />
导出
</NButton>
<NButton type="primary" size="small" @click="emit('close')">关闭</NButton>
</div>
</NSpin>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 155px);
overflow-y: auto;
}
</style>
+42 -40
View File
@@ -89,53 +89,55 @@ onMounted(() => {
<template>
<!-- 历史分析检索 -->
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-3 border-b mb-3">
<div class="flex items-center gap-2">
<Icon icon="mage:book" class="size-18px" :style="{color:themeStore.themeColor}" />
<p class="text-base font-semibold">历史分析记录检索企业风险知识资产</p>
<NSpin :show="loading" size="small">
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-3 border-b mb-3">
<div class="flex items-center gap-2">
<Icon icon="mage:book" class="size-18px" :style="{color:themeStore.themeColor}" />
<p class="text-base font-semibold">历史分析记录检索企业风险知识资产</p>
</div>
<div class="flex items-center gap-2 min-h-28px">
<NInput v-model:value="keyword" size="small" class="text-xs !w-200px" placeholder="按单号 / 装置 / 关键词检索…">
<template #suffix>
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" />
</template>
</NInput>
</div>
</div>
<div class="flex items-center gap-2 min-h-28px">
<NInput v-model:value="keyword" size="small" class="text-xs !w-200px" placeholder="按单号 / 装置 / 关键词检索…">
<template #suffix>
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" />
</template>
</NInput>
<div class="px-4 mb-3 flex flex-wrap items-center gap-1.5">
<NButton
size="tiny"
round
v-for="m in ['全部', 'HAZOP', 'JSA', '检查表', 'AI 预分析']"
:key="m"
:type="method === m ? 'primary' : 'default'"
@click="method = m"
>
{{ m }}
</NButton>
<span class="ml-auto self-center text-[11px] text-slate-400">{{ list.length }} 条记录</span>
</div>
</div>
<div class="px-4 mb-3 flex flex-wrap items-center gap-1.5">
<NButton
size="tiny"
round
v-for="m in ['全部', 'HAZOP', 'JSA', '检查表', 'AI 预分析']"
:key="m"
:type="method === m ? 'primary' : 'default'"
@click="method = m"
>
{{ m }}
</NButton>
<span class="ml-auto self-center text-[11px] text-slate-400">{{ list.length }} 条记录</span>
</div>
<div class="scroll">
<div class="space-y-3 px-4 pb-3">
<div v-for="h in list" :key="h.id" class="flex flex-wrap items-center gap-3 rounded-lg border px-4 py-2.5">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-[11px] text-slate-400">{{ h.id }}</span>
<NTag type="info" size="small">{{ h.method }}</NTag>
<span class="text-sm text-slate-800">{{ h.change }}</span>
<div class="scroll">
<div class="space-y-3 px-4 pb-3">
<div v-for="h in list" :key="h.id" class="flex flex-wrap items-center gap-3 rounded-lg border px-4 py-2.5">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-[11px] text-slate-400">{{ h.id }}</span>
<NTag type="info" size="small">{{ h.method }}</NTag>
<span class="text-sm text-slate-800">{{ h.change }}</span>
</div>
<div class="mt-0.5 text-xs text-slate-500">{{ h.unit }} · 范围{{ h.scope }} · {{ h.rows }} 行分析记录 · {{ h.date }}</div>
</div>
<div class="mt-0.5 text-xs text-slate-500">{{ h.unit }} · 范围{{ h.scope }} · {{ h.rows }} 行分析记录 · {{ h.date }}</div>
<NButton text type="primary" @click="viewHistory(h.id)">
<Icon icon="lucide:eye" class="size-16px" />
</NButton>
<NButton ghost size="small" class="text-xs" @click="copyHistory(h.id)">复用到新变更</NButton>
</div>
<NButton text type="primary" @click="viewHistory(h.id)">
<Icon icon="lucide:eye" class="size-16px" />
</NButton>
<NButton ghost size="small" class="text-xs" @click="copyHistory(h.id)">复用到新变更</NButton>
<div v-if="list.length === 0" class="py-6 text-center text-xs text-slate-400">未找到匹配的分析记录</div>
</div>
<div v-if="list.length === 0" class="py-6 text-center text-xs text-slate-400">未找到匹配的分析记录</div>
</div>
</div>
</div>
</NSpin>
</template>
<style scoped lang="scss">
+42 -4
View File
@@ -8,6 +8,11 @@ import { systemConfigApi, matrixConfigApi } from '@/service/api/system';
import { orgListApi } from '@/service/api/user';
import { highRiskListApi } from '@/service/api/highRisk';
import RiskPre from "./riskPre.vue";
import Hazop from "./hazop.vue";
import Fmea from "./fmea.vue";
import RiskCheck from "./riskCheck.vue";
const themeStore = useThemeStore();
const { routerPushByKey } = useRouterPush();
@@ -23,6 +28,8 @@ const RISK_RANGES = [
// 风险矩阵配置
const hazopMatrix = ref<any>(null);
const fmeaMatrix = ref<any>(null);
// 插件抽屉
const pluginDrawer = ref<boolean>(false);
// 高风险场景列表
const list = ref<any[]>([]);
@@ -32,7 +39,9 @@ const jumpTo = (r: any) => {
};
// 查看分析记录
const viewAnalysis = (record: any) => {
console.log(record);
currentRecord.value = record;
pluginDrawer.value = true;
};
// 时间范围
const timeRange = ref<string>('month');
@@ -233,7 +242,7 @@ onMounted(async () => {
<div v-for="(s, i) in list" :key="`${s.cid ?? 'x'}-${i}`" class="flex flex-wrap items-center gap-3 rounded-lg border px-4 py-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<NTag type="info" size="small">{{ s.source }}</NTag>
<NTag size="small" type="info" class="text-11px">{{ s.source==='RISK'?'风险预分析':s.source==='RISK_CHECK'?'风险检查': s.source }}</NTag>
<span class="text-sm font-medium text-slate-800">{{ s.item }}</span>
<!-- 固有风险等级 -->
<template v-if="s.source==='RISK'">
@@ -290,7 +299,7 @@ onMounted(async () => {
</template>
<template v-if="s.source==='RISK_CHECK'">
<span>
{{s.inherent_level?s.inherent_level:'-'}}
{{s.inherent_level ?? '-'}}
</span>
</template>
<!-- 剩余风险等级 -->
@@ -348,13 +357,13 @@ onMounted(async () => {
</template>
<template v-if="s.source==='RISK_CHECK'">
<span>
{{s.residual_level?s.residual_level:'-'}}
{{s.residual_level ?? '-'}}
</span>
</template>
</div>
<div class="mt-1 text-xs text-slate-600">{{ s.scene }}</div>
<div class="mt-0.5 text-xs text-slate-400">
变更{{ s.change_title }} · {{ s.org_name }} · 分析日期 {{ s.created_at.slice(0, 10) }} · 责任{{ s.applicant_name }} · 分析记录 {{ s.record_count }}
变更{{ s?.change_title }} · {{ s?.org_name }} · 分析日期 {{ s?.created_at?.slice(0, 10) }} · 责任{{ s?.applicant_name }} · 分析记录 {{ s?.record_count }}
</div>
</div>
<NButton ghost size="small" class="text-xs" @click="jumpTo(s)">查看变更</NButton>
@@ -376,6 +385,35 @@ onMounted(async () => {
</NPagination>
</div>
</div>
<!-- 插件抽屉 -->
<NDrawer v-model:show="pluginDrawer" width="80%" placement="right">
<NDrawerContent closable>
<template #header>
<div class="flex items-center gap-2">
<span>风险分析记录表</span>
<NTag v-if="currentRecord?.source" size="small" type="info">
<span v-if="currentRecord?.source === 'RISK'">风险预分析</span>
<span v-else-if="currentRecord?.source === 'HAZOP'">HAZOP</span>
<span v-else-if="currentRecord?.source === 'FMEA'">FMEA</span>
<span v-else-if="currentRecord?.source === 'RISK_CHECK'">风险检查</span>
<span v-else>未知分析记录</span>
</NTag>
</div>
</template>
<template v-if="currentRecord?.source === 'RISK'">
<RiskPre :currentId="currentRecord?.change_id" :info="currentRecord" @close="pluginDrawer = false" />
</template>
<template v-if="currentRecord?.source === 'HAZOP'">
<Hazop :currentId="currentRecord?.change_id" :info="currentRecord" :hazopRiskMatrixConfig="hazopMatrix" @close="pluginDrawer = false" />
</template>
<template v-if="currentRecord?.source === 'FMEA'">
<Fmea :currentId="currentRecord?.change_id" :info="currentRecord" :fmeaRiskMatrixConfig="fmeaMatrix" @close="pluginDrawer = false" />
</template>
<template v-if="currentRecord?.source === 'RISK_CHECK'">
<RiskCheck :currentId="currentRecord?.change_id" :info="currentRecord" @close="pluginDrawer = false" />
</template>
</NDrawerContent>
</NDrawer>
</NSpin>
</template>
+246
View File
@@ -0,0 +1,246 @@
<script setup lang="ts">
import {onMounted, ref } from "vue";
import { Icon } from '@iconify/vue'
import { riskCheckDetailApi, riskCheckSaveApi } from '@/service/api/plugin'
const props = defineProps(['isAiOpen','currentId'])
const emit = defineEmits(['close','toTable']);
// answer 1 是
// answer 0 否
// answer 2 NA
const RISK_CHECKS_INIT = [
{
group_id: 1,
group_label: "安全健康",
items: [
{
question_id: 1,
answer: null,
question: "是否存在任何可能导致安全问题的化学反应?检查化学反应禁忌表。",
risk_desc: "",
control_measure: "",
},
{
question_id: 2,
answer: null,
question: "是否存在任何易燃易爆化学物质或粉尘?",
risk_desc: "",
control_measure: "",
},
{
question_id: 3,
answer: null,
question: "是否存在任何有危险的原材料流速、成分或温度条件变更?",
risk_desc: "",
control_measure: "",
},
{
question_id: 4,
answer: null,
question: "是否会因加热、冷却或精确的温度控制失控而导致安全问题?",
risk_desc: "",
control_measure: "",
},
{
question_id: 5,
answer: null,
question: "是否具有为安全操作所必需的仪表、控制、紧急制动、开关或报警?",
risk_desc: "",
control_measure: " ",
},
],
},
{
group_id: 2,
group_label: "工艺与操作",
items: [
{
question_id: 6,
answer: null,
question: "变更后是否需要对操作人员进行专门培训?",
risk_desc: "",
control_measure: "",
},
{
question_id: 7,
answer: null,
question: "是否涉及应急预案或消防设施的调整?",
risk_desc: "",
control_measure: ""
},
],
}
];
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) => {
riskFilter.value = v;
if(v===-1){
riskGroups.value = RISK_CHECKS_INIT;
}else{
const result = RISK_CHECKS_INIT.map(group => ({
...group,
items: group.items.filter((item:any) => item.answer === 1)
})).filter(group => group.items.length > 0);
riskGroups.value = result;
}
};
// 保存
const save = async () => {
loading.value = true;
const {error} = await riskCheckSaveApi(props.currentId,{
groups: riskGroups.value
});
if(!error){
window.$message?.success("保存成功");
getRiskCheckDetail();
}
loading.value = false;
};
// AI整理如表、入表
const enterToTable = (type: string, groupIndex: number = -1, questionIndex: number = -1) => {
if(type==='single'){
const row = riskGroups.value[groupIndex]?.items[questionIndex];
if(!row?.id){
return window.$message?.warning("请先保存再入表");
}
const newRow = {
id: row.id || 0,
scene: row.risk_desc || '',
source: 'RISK_CHECK',
item: row.question || '',
inherent_level: '',
safeguards: row?.control_measure || '',
suggestions: '',
residual_level: '',
to_pssr: 0,
source_row_id: row.id || 0,
}
emit('toTable',type,newRow?[newRow]:[]);
}
if(type==='all'){
const result = riskGroups.value.flatMap((group:any) =>
group.items.map((item:any, index:number) => ({ group_id: group.group_id, group_label: group.group_label, index, item })).filter(({ item }:any) => !item.id)
);
if(result.length){
return window.$message?.warning("请先保存再入表");
}
const enrichedRows:any[] = [];
riskGroups.value.forEach((group:any) => {
group.items.forEach((row:any) => {
enrichedRows.push({
id: row.id || 0,
scene: row.risk_desc || '',
source: 'RISK_CHECK',
item: row.question || '',
inherent_level: '',
safeguards: row?.control_measure || '',
suggestions: '',
residual_level: '',
to_pssr: 0,
source_row_id: row.id || 0,
});
})
})
emit('toTable',type,enrichedRows);
}
}
// AI 自动分析工序
const runAi = async () => {
analyzing.value = true;
setTimeout(() => {
analyzing.value = false;
}, 1000);
};
// 获取风险检查表详情
const getRiskCheckDetail = async () => {
loading.value = true;
const {data,error} = await riskCheckDetailApi(props.currentId);
if(!error){
if(data.length){
riskGroups.value = data;
}else{
riskGroups.value = RISK_CHECKS_INIT;
}
}
loading.value = false;
}
onMounted(() => {
getRiskCheckDetail();
})
</script>
<template>
<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="emit('close')">
<Icon icon="basil:arrow-left-outline" class="size-18px" /> 返回工具列表
</NButton>
<NButton size="small" ghost type="primary" class="text-xs" @click="save">保存</NButton>
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">变更风险检查表通用</span>
<NTag size="small" type="info">插件</NTag>
<div class="flex items-center gap-3">
<NRadioGroup :value="riskFilter" name="radiogroup" :on-update:value="filterChange">
<NRadio :value="-1">全部</NRadio>
<NRadio :value=1>仅看</NRadio>
</NRadioGroup>
</div>
<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,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,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 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>
</div>
</div>
<template v-if="item.answer === 1">
<NInput v-model:value="item.risk_desc" type="textarea" size="small" :autosize="{minRows:2}" placeholder="风险描述" />
<NInput v-model:value="item.control_measure" type="textarea" size="small" :autosize="{minRows:2}" placeholder="管控措施" />
</template>
<div v-if="item.answer === 0 || item.answer === 2" class="text-xs text-slate-300 self-center col-span-2">回答为{{ item.answer===0?'':'NA' }}无风险描述</div>
</div>
</div>
</div>
</div>
</NSpin>
</template>
<style scoped lang="scss">
.scroll{
height: calc(100vh - 110px);
overflow-y: auto;
}
</style>
+304
View File
@@ -0,0 +1,304 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { peopleListApi } from '@/service/api/system';
import { riskPreDetailApi, riskPreSaveApi } from '@/service/api/plugin';
const themeStore = useThemeStore();
const props = defineProps(['isAiOpen','currentId'])
const emit = defineEmits(['close','toTable']);
// 表格样式
const TH = "border bg-slate-100 px-2 py-1.5 text-[11px] font-medium text-slate-500";
const TD = "border px-1.5 py-1 align-top";
// 加载状态
const loading = ref(false);
// 风险分析记录
const riskRecords = ref<{id:number,risk_item:string,scene_desc:string,risk_value:string,residual_risk_value:string,safeguards:string,suggestions:string}[]>([]);
// 风险等级
const risk_grades = ref<{label:string,value:string}[]>([
{label:'低',value:'低'},
{label:'中',value:'中'},
{label:'高',value:'高'},
]);
// 人员列表源数据
const workers = ref<{user_id:number,user_name:string,org_name?:string}[]>([]);
// 选中的人员
const selectedWorkers = ref<{user_id:number,user_name:string,org_name?:string}[]>([]);
// 选择分析人员弹窗
const openSelectWorker = ref(false);
// 搜索分析人员
const searchWorker = ref("");
// 分析人员列表
const workerList = computed(() => workers.value.filter((x:{user_id:number,user_name:string,org_name?:string}) => !searchWorker.value || x.user_name.includes(searchWorker.value)));
// 选择分析人员
const selectWorker = (n: {user_id:number,user_name:string,org_name?:string}) => {
selectedWorkers.value = selectedWorkers.value.includes(n) ? selectedWorkers.value.filter((x:any) => x.user_id !== n.user_id) : [...selectedWorkers.value, n];
};
// 匹配是否有人员
const hasWorker = (id:number) => {
return selectedWorkers.value.some((item:{user_id:number,user_name:string,org_name?:string}) => item.user_id === id);
};
// 运行 AI 执行风险分析
const runAi = async () => {
};
// 导出Word
const generateReport = async () => {
window.$message?.warning("还差接口");
}
// 保存
const save = async () => {
loading.value = true;
// 处理选中的人员,移除 org_name 字段
const workers = (selectedWorkers.value ?? []).map((item: { org_name?:string; user_id: number; user_name: string }) => {
const { org_name, ...rest } = item;
return rest;
});
const {error} = await riskPreSaveApi(props.currentId, {
rows: riskRecords.value,
users: workers,
});
if(!error){
window.$message?.success("保存成功");
getDetail();
}
loading.value = false;
};
// AI整理如表、入表
const enterToTable = (type: string, row: any) => {
if(type==='single'){
if(!row?.id){
return window.$message?.warning("请先保存再入表");
}
const enrichedRow:any[] = [];
enrichedRow.push({
id: row?.id || 0,
scene: row?.scene_desc || '',
source: 'RISK',
item: row?.risk_item || '',
inherent_level: row?.risk_value || '',
safeguards: row?.safeguards || '',
suggestions: row?.suggestions || '',
residual_level: row?.residual_risk_value || '',
to_pssr: 0,
source_row_id: row?.id || 0,
});
emit('toTable',type,enrichedRow);
}
if(type==='all'){
const result = riskRecords.value.filter((item:any) => !item.id);
if(result.length){
return window.$message?.warning("请先保存再入表");
}
const enrichedRows:any[] = [];
riskRecords.value.forEach((row:any) => {
enrichedRows.push({
id: row?.id || 0,
scene: row?.scene_desc || '',
source: 'RISK',
item: row?.risk_item || '',
inherent_level: row?.risk_value || '',
safeguards: row?.safeguards || '',
suggestions: row?.suggestions || '',
residual_level: row?.residual_risk_value || '',
to_pssr: 0,
source_row_id: row?.id || 0,
});
})
emit('toTable',type,enrichedRows);
}
}
// 新增风险分析记录
const addRisk = () => {
riskRecords.value.push({
id: 0,
risk_item: '',
scene_desc: '',
risk_value: '',
residual_risk_value: '',
safeguards: '',
suggestions: '',
})
}
// 删除行
const delRow = (rowIndex: number) => {
riskRecords.value.splice(rowIndex, 1);
}
// 获取人员列表
const getWorkers = async () => {
const {data,error} = await peopleListApi();
if(!error){
const transformed = data.map((item:any) => ({
user_id: item.id,
user_name: item.realname,
org_name: item.user_departments.map((d:any) => d.label).join(';')
}));
workers.value = transformed;
}
}
// 获取详情
const getDetail = async () => {
loading.value = true;
const {data,error} = await riskPreDetailApi(props.currentId);
if(!error){
riskRecords.value = data?.rows || [];
selectedWorkers.value = data?.users || [];
}
loading.value = false;
}
onMounted(() => {
getWorkers();
getDetail();
})
</script>
<template>
<NSpin :show="loading" size="small">
<div class="space-y-3">
<div
class="flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
:style="{
'--theme-color': themeStore.themeColor
}"
>
<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>
<span class="text-sm font-bold text-[var(--theme-color)]">风险预分析插件</span>
<NTag size="small" type="info">插件</NTag>
<span class="text-[11px] text-slate-500">{{`${riskRecords.length} 条风险项`}}</span>
<div class="relative">
<NButton size="small" ghost type="primary" class="text-[11px]" @click="openSelectWorker = !openSelectWorker">
<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">已选 {{ 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 workerList" :key="x.user_id" @click="selectWorker(x)"
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="hasWorker(x.user_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="hasWorker(x.user_id) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border-slate-300'">{{ hasWorker(x.user_id) ? "✓" : "" }}</span>
<span class="font-medium text-slate-800">{{ x.user_name }}</span>
<span class="text-[11px] text-slate-400">{{ x.org_name }}</span>
<NTag v-if="selectedWorkers[0]?.user_id === x.user_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">
首位选择者默认为组长成员为选择式非手写参与记录自动归属到人用于参与风险分析次数统计采集
</div>
</div>
</template>
</div>
<div class="ml-auto flex items-center gap-1.5">
<NButton size="small" ghost type="primary" class="text-xs" :disabled="riskRecords.length === 0" @click="enterToTable('all',null)">
<Icon icon="carbon:document-import" class="size-14px mr-1" /> 全部整理入表
</NButton>
<NButton size="small" ghost type="primary" class="text-xs" @click="generateReport">
<Icon icon="material-symbols:download" class="size-14px" /> 导出 Word
</NButton>
<NButton v-if="isAiOpen" size="small" type="primary" class="text-xs" :disabled="loading" @click="runAi">
<Icon v-if="loading" icon="ri:loader-4-fill" class="size-14px animate-spin" />
<Icon v-else icon="lucide:sparkles" class="size-12px" />
AI 执行风险分析
</NButton>
</div>
</div>
<div class="space-y-3" :style="{'--theme-color': themeStore.themeColor}">
<!-- 作业活动分析记录 -->
<div class="bg-white">
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-600 border border-b-0 rounded-tl-lg rounded-tr-lg">
风险分析记录表
<NButton size="small" ghost type="primary" class="text-xs ml-2" @click="addRisk">
<Icon icon="ic:round-plus" class="size-14px" /> 新增风险分析记录
</NButton>
<span class="ml-auto text-[11px] font-normal text-slate-400">入表将本条选入风险分析记录表</span>
</div>
<p v-if="riskRecords.length===0" class="border py-5 text-center text-xs text-slate-400">暂无风险分析记录</p>
<div v-else class="scroll">
<table class="w-full">
<thead>
<tr>
<th :class="TH" class="w-80px">操作</th>
<th :class="TH" class="w-30">风险项</th>
<th :class="TH" class="w-60">场景描述</th>
<th :class="TH" class="w-20">固有风险</th>
<th :class="TH" class="w-50">现有措施</th>
<th :class="TH" class="w-60">建议措施</th>
<th :class="TH" class="w-20">剩余风险</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in riskRecords" :key="rowIndex">
<td :class="TD">
<div class="flex items-center justify-center gap-2 px-1 mt-2">
<NButton text type="primary" size="tiny" @click="enterToTable('single',row)">
入表
</NButton>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="delRow(Number(rowIndex))"
>
<template #trigger>
<NButton text type="error" size="tiny" 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.risk_item" :autosize="{minRows:1}" />
</td>
<td :class="TD">
<NInput type="textarea" size="small" v-model:value="row.scene_desc" :autosize="{minRows:1}" />
</td>
<td :class="TD" class="text-center">
<NSelect v-model:value="row.risk_value" :options="risk_grades" size="small" class="w-full" />
</td>
<td :class="TD">
<NInput type="textarea" size="small" v-model:value="row.safeguards" :autosize="{minRows:1}" />
</td>
<td :class="TD">
<NInput type="textarea" size="small" v-model:value="row.suggestions" :autosize="{minRows:1}" />
</td>
<td :class="TD" class="text-center">
<NSelect v-model:value="row.residual_risk_value" :options="risk_grades" size="small" class="w-full" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</NSpin>
</template>
<style scoped lang="scss">
.scroll{
height: calc(100vh - 135px);
overflow-y: auto;
}
</style>
+24 -22
View File
@@ -274,34 +274,36 @@ onMounted(() => {
<template>
<!-- 方法模板库 -->
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-3 border-b mb-3">
<div class="flex items-center gap-2">
<Icon icon="akar-icons:file" class="size-16px" :style="{color:themeStore.themeColor}" />
<p class="text-base font-semibold">方法模板库AI 风险预分析的知识来源之一</p>
<NSpin :show="loading" size="small">
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-3 border-b mb-3">
<div class="flex items-center gap-2">
<Icon icon="akar-icons:file" class="size-16px" :style="{color:themeStore.themeColor}" />
<p class="text-base font-semibold">方法模板库AI 风险预分析的知识来源之一</p>
</div>
</div>
</div>
<div class="scroll">
<div class="space-y-3 px-4 pb-3">
<div class="grid gap-3 grid-cols-2">
<div v-for="t in list" :key="t.name" class="rounded-lg border p-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-slate-800">{{ t.name }}</span>
<NTag size="small" type="info">{{ t.tag }}</NTag>
</div>
<p class="mt-1.5 text-xs leading-relaxed text-slate-500">{{ t.desc }}</p>
<div class="mt-3 flex gap-2">
<NButton ghost size="small" class="text-xs" @click="previewTemplate(t.name)">查看</NButton>
<NButton ghost size="small" class="text-xs" @click="downloadTemplate(t.name)">
<Icon icon="material-symbols:download" class="size-14px" />下载
</NButton>
<div class="scroll">
<div class="space-y-3 px-4 pb-3">
<div class="grid gap-3 grid-cols-2">
<div v-for="t in list" :key="t.name" class="rounded-lg border p-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-slate-800">{{ t.name }}</span>
<NTag size="small" type="info">{{ t.tag }}</NTag>
</div>
<p class="mt-1.5 text-xs leading-relaxed text-slate-500">{{ t.desc }}</p>
<div class="mt-3 flex gap-2">
<NButton ghost size="small" class="text-xs" @click="previewTemplate(t.name)">查看</NButton>
<NButton ghost size="small" class="text-xs" @click="downloadTemplate(t.name)">
<Icon icon="material-symbols:download" class="size-14px" />下载
</NButton>
</div>
</div>
</div>
<div v-if="list.length === 0" class="py-6 text-center text-xs text-slate-400">未找到匹配的方法模板库</div>
</div>
<div v-if="list.length === 0" class="py-6 text-center text-xs text-slate-400">未找到匹配的方法模板库</div>
</div>
</div>
</div>
</NSpin>
</template>
<style scoped lang="scss">