发起变更AI功能接线:变更预识别生成申请单、各标签页重新生成(申请表/培训内容/PSSR检查/验收评价)对接ai-server并适配新字段渲染;修复等级判定表AI评分重复点击总分累加问题

This commit is contained in:
2026-09-08 12:01:56 +08:00
parent 6f41d613ba
commit 376f6b7cd5
7 changed files with 1889 additions and 1701 deletions
+205 -228
View File
@@ -1,229 +1,206 @@
<!-- 变更预识别 -->
<script setup lang="ts">
import { inject, ref } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { AiError, aiRecognize, aiRiskPreAnalysis } from "@/service/api/ai";
import ChangeCompareTable from "./ChangeCompareTable.vue";
const themeStore = useThemeStore();
const emit = defineEmits(['save'])
const generalLoading = inject('generalLoading', {saveLoading: ref<boolean>(false),btnDisabled: ref<boolean>(false)})
const {saveLoading,btnDisabled} = generalLoading
const aiFail = (e: any) => {
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
else window.$message?.warning("AI 分析失败,可手动填写");
};
// 是否是ai生成
const isAiGenerated = ref<boolean>(false);
// 变更识别loading
const preLoading = ref<boolean>(false);
// 生成变更申请单loading
const generateLoading = ref<boolean>(false);
// 风险预分析loading
const analyzing = ref<boolean>(false);
// 编辑风险预分析
const preRiskEdit = ref<string>('');
// 是否显示险预分析
const preRiskShow = ref<boolean>(false);
// 表单
const form = ref<{
description: string,
compare_rows: {item: string, before_text: string, after_text: string}[],
severity: string,
probability: string,
protection: string
}>({
description: '',
compare_rows: [],
severity: "",
probability: "",
protection: "",
});
// AI 变更识别
const runIdentify = async () => {
if (!form.value.description.trim()) {
return window.$message?.warning("请先输入变更内容描述");
}
form.value.compare_rows = [];
btnDisabled.value = true;
preLoading.value = true;
try {
const res = await aiRecognize(form.value.description);
form.value.compare_rows = (res?.rows ?? []).map((x, i) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text }));
isAiGenerated.value = true;
window.$message?.success(`AI 变更识别完成,共 ${form.value.compare_rows.length} 项,请逐项人工确认(可直接修改)`);
} catch (e: any) {
form.value.compare_rows = [];
aiFail(e);
} finally {
preLoading.value = false;
btnDisabled.value = false;
}
};
// 运行风险预分析
const runRiskAnalysis = async () => {
if(form.value.description.trim() === '') {
return window.$message?.warning('请先填写变更内容描述');
}
analyzing.value = true;
btnDisabled.value = true;
try {
const res = await aiRiskPreAnalysis(form.value.description, form.value.compare_rows);
form.value = {
...form.value,
severity: res?.severity ?? form.value.severity,
probability: res?.probability ?? form.value.probability,
protection: res?.protection ?? form.value.protection,
};
preRiskShow.value = true;
window.$message?.success("AI 风险预分析报告已生成(危害严重性 / 事件概率 / 保护措施影响),内容支持手动修改");
} catch (e: any) {
aiFail(e);
} finally {
analyzing.value = false;
btnDisabled.value = false;
}
};
// 生成变更申请单
const genForm = async () => {
// window.$message?.info("AI 正在生成变更申请单");
// generateLoading.value = true;
// try {
// const res = await aiApplication(desc.value, (form.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
// const pick = (v: string | null | undefined, opts: string[], cur: string) => (v && opts.includes(v) ? v : cur);
// const main = (res?.main_changes ?? []).filter((x) => x && x.trim());
// const purposes = (res?.purposes ?? []).filter((x) => PURPOSE_OPTIONS.includes(x));
// const docs = (res?.update_docs ?? []).filter((x) => UPDATE_DOC_OPTIONS.includes(x));
// const discs = (res?.disciplines ?? []).filter((x) => DISCIPLINE_OPTIONS.includes(x));
// const aiTools = (res?.risk_tools ?? [])
// .map((x) => RISK_TOOLS_FORM.find((t) => t.key === x || t.label === x)?.key)
// .filter((x): x is string => !!x);
// form.value = {
// ...form.value,
// name: res?.name || form.value.name,
// mainChanges: main.length ? main : form.value.mainChanges,
// related: (res?.related_changes ?? []).filter(Boolean).join("、") || form.value.related,
// purposes: purposes.length ? purposes : form.value.purposes,
// effect: res?.effect || form.value.effect,
// type: pick(res?.change_type, ['工艺', '设备', '管理'], form.value.type),
// duration: pick(res?.duration_suggestion, ['永久', '临时'], form.value.duration),
// };
// if (res?.materials_text) materialsText.value = res.materials_text;
// if (docs.length) updateDocs.value = docs;
// if (discs.length) {
// disciplines.value = discs;
// toggleDiscipline(discs);
// }
// if (res?.signers && Object.keys(res.signers).length) signers.value = { ...res.signers };
// if (aiTools.length) tools.value = aiTools;
// currentPlugin.value = "";
// tab.value = "form";
// window.$message?.success("变更申请单已生成:AI 自动总结名称、预填内容,均可手动修改");
// } catch (e: any) {
// aiFail(e);
// } finally {
// generateLoading.value = false;
// }
};
// 保存草稿
const saveDraft = () => {
emit('save');
}
defineExpose({form})
</script>
<template>
<div class="p-4">
<div class="space-y-4 scroll">
<!-- 变更内容描述 -->
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-2 border-b">
<div class="flex items-center">
<Icon icon="lucide:sparkles" class="size-16px text-violet-500" />
<span class="font-semibold text-base ml-2">变更内容描述</span>
</div>
<NButton ghost type="primary" @click="runIdentify" :disabled="btnDisabled">
<Icon v-if="preLoading" icon="ri:loader-4-fill" class="size-16px animate-spin" />
<Icon v-else icon="fluent:wand-24-regular" class="size-16px" />
变更识别
</NButton>
</div>
<div class="p-4">
<NInput type="textarea" v-model:value="form.description" rows="4" placeholder="请输入变更内容描述,例如:将反应釜R-101的搅拌速度从120rpm提高至180rpm,同时将反应温度从80℃提高至95℃…" />
</div>
</div>
<!-- 变更识别结果 -->
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-2 border-b">
<div class="flex items-center">
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
<span class="font-semibold text-base ml-2">{{form.compare_rows.length ? `变更识别结果(${form.compare_rows.length}项)` : '变更识别结果(空表 · 可手动录入,或由 AI 识别生成)'}}</span>
</div>
<span v-if="isAiGenerated" class="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>
<div class="p-4">
<ChangeCompareTable
:data="form.compare_rows"
:editable="true"
@update:data="(d) => form.compare_rows = d"
/>
</div>
</div>
<!-- 风险预分析报告 -->
<div v-if="preRiskShow" class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-2 border-b">
<div class="flex items-center">
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
<span class="font-semibold text-base ml-2">风险预分析报告</span>
</div>
<span class="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>
<div class="p-4">
<div class="space-y-4">
<div v-for="s in ([{ key: 'severity', name: '危害严重性分析' }, { key: 'probability', name: '事件概率分析' }, { key: 'protection', name: '保护措施影响分析' }])" :key="s.key" class="rounded-lg border">
<div class="flex items-center gap-2 border-b bg-slate-50/60 px-4 py-2">
<span class="text-sm font-semibold text-slate-700">{{ s.name }}</span>
<NButton text type="primary" class="ml-auto text-xs hover:underline" @click="preRiskEdit = preRiskEdit === s.key ? '' : s.key">
{{ preRiskEdit === s.key ? "完成" : "编辑" }}
</NButton>
</div>
<NInput v-model:value="form[s.key]" :readonly="preRiskEdit !== s.key" type="textarea" :bordered="false" autofocus />
</div>
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
AI 风险预分析仅供参考用于申请阶段的快速研判正式风险识别请前往风险分析记录表标签页使用 HAZOP / JSA / 检查表法开展并留存记录
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 bg-[#EAF0F9] px-4 py-2.5 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<span class="text-xs text-slate-400">预识别阶段不设审批人与提交完成识别与预分析后请切换至变更申请表选择审批人并提交</span>
<div class="ml-auto flex shrink-0 gap-2">
<NButton ghost type="success" size="small" :loading="generateLoading" :disabled="btnDisabled" @click="genForm">
<Icon icon="akar-icons:file" class="size-14px" /> 生成变更申请单
</NButton>
<NButton ghost type="warning" size="small" @click="runRiskAnalysis" :disabled="btnDisabled">
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
<Icon v-else icon="mdi:shield-alert-outline" class="size-14px" />风险预分析
</NButton>
<NButton type="primary" size="small" :disabled="btnDisabled" :loading="saveLoading" @click="saveDraft">保存</NButton>
</div>
</div>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 326px);
overflow-y: auto;
}
<!-- 变更预识别 -->
<script setup lang="ts">
import { inject, ref } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { AiError, aiApplication, aiRecognize, aiRiskPreAnalysis } from "@/service/api/ai";
import ChangeCompareTable from "./ChangeCompareTable.vue";
const themeStore = useThemeStore();
const emit = defineEmits(['save', 'apply-generated'])
const generalLoading = inject('generalLoading', {saveLoading: ref<boolean>(false),btnDisabled: ref<boolean>(false)})
const {saveLoading,btnDisabled} = generalLoading
const aiFail = (e: any) => {
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
else window.$message?.warning("AI 分析失败,可手动填写");
};
// 是否是ai生成
const isAiGenerated = ref<boolean>(false);
// 变更识别loading
const preLoading = ref<boolean>(false);
// 生成变更申请单loading
const generateLoading = ref<boolean>(false);
// 风险预分析loading
const analyzing = ref<boolean>(false);
// 编辑风险预分析
const preRiskEdit = ref<string>('');
// 是否显示险预分析
const preRiskShow = ref<boolean>(false);
// 表单
const form = ref<{
description: string,
compare_rows: {item: string, before_text: string, after_text: string}[],
severity: string,
probability: string,
protection: string
}>({
description: '',
compare_rows: [],
severity: "",
probability: "",
protection: "",
});
// AI 变更识别
const runIdentify = async () => {
if (!form.value.description.trim()) {
return window.$message?.warning("请先输入变更内容描述");
}
form.value.compare_rows = [];
btnDisabled.value = true;
preLoading.value = true;
try {
const res = await aiRecognize(form.value.description);
form.value.compare_rows = (res?.rows ?? []).map((x, i) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text }));
isAiGenerated.value = true;
window.$message?.success(`AI 变更识别完成,共 ${form.value.compare_rows.length} 项,请逐项人工确认(可直接修改)`);
} catch (e: any) {
form.value.compare_rows = [];
aiFail(e);
} finally {
preLoading.value = false;
btnDisabled.value = false;
}
};
// 运行风险预分析
const runRiskAnalysis = async () => {
if(form.value.description.trim() === '') {
return window.$message?.warning('请先填写变更内容描述');
}
analyzing.value = true;
btnDisabled.value = true;
try {
const res = await aiRiskPreAnalysis(form.value.description, form.value.compare_rows);
form.value = {
...form.value,
severity: res?.severity ?? form.value.severity,
probability: res?.probability ?? form.value.probability,
protection: res?.protection ?? form.value.protection,
};
preRiskShow.value = true;
window.$message?.success("AI 风险预分析报告已生成(危害严重性 / 事件概率 / 保护措施影响),内容支持手动修改");
} catch (e: any) {
aiFail(e);
} finally {
analyzing.value = false;
btnDisabled.value = false;
}
};
// 生成变更申请单
const genForm = async () => {
if (!form.value.description.trim()) {
return window.$message?.warning("请先输入变更内容描述");
}
generateLoading.value = true;
btnDisabled.value = true;
try {
const res = await aiApplication(form.value.description, form.value.compare_rows);
// 由父组件把 AI 结果回填到「变更申请表」并切换标签
emit('apply-generated', res);
} catch (e: any) {
aiFail(e);
} finally {
generateLoading.value = false;
btnDisabled.value = false;
}
};
// 保存草稿
const saveDraft = () => {
emit('save');
}
defineExpose({form})
</script>
<template>
<div class="p-4">
<div class="space-y-4 scroll">
<!-- 变更内容描述 -->
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-2 border-b">
<div class="flex items-center">
<Icon icon="lucide:sparkles" class="size-16px text-violet-500" />
<span class="font-semibold text-base ml-2">变更内容描述</span>
</div>
<NButton ghost type="primary" @click="runIdentify" :disabled="btnDisabled">
<Icon v-if="preLoading" icon="ri:loader-4-fill" class="size-16px animate-spin" />
<Icon v-else icon="fluent:wand-24-regular" class="size-16px" />
变更识别
</NButton>
</div>
<div class="p-4">
<NInput type="textarea" v-model:value="form.description" rows="4" placeholder="请输入变更内容描述,例如:将反应釜R-101的搅拌速度从120rpm提高至180rpm,同时将反应温度从80℃提高至95℃…" />
</div>
</div>
<!-- 变更识别结果 -->
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-2 border-b">
<div class="flex items-center">
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
<span class="font-semibold text-base ml-2">{{form.compare_rows.length ? `变更识别结果(${form.compare_rows.length}项)` : '变更识别结果(空表 · 可手动录入,或由 AI 识别生成)'}}</span>
</div>
<span v-if="isAiGenerated" class="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>
<div class="p-4">
<ChangeCompareTable
:data="form.compare_rows"
:editable="true"
@update:data="(d) => form.compare_rows = d"
/>
</div>
</div>
<!-- 风险预分析报告 -->
<div v-if="preRiskShow" class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-2 border-b">
<div class="flex items-center">
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
<span class="font-semibold text-base ml-2">风险预分析报告</span>
</div>
<span class="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>
<div class="p-4">
<div class="space-y-4">
<div v-for="s in ([{ key: 'severity', name: '危害严重性分析' }, { key: 'probability', name: '事件概率分析' }, { key: 'protection', name: '保护措施影响分析' }])" :key="s.key" class="rounded-lg border">
<div class="flex items-center gap-2 border-b bg-slate-50/60 px-4 py-2">
<span class="text-sm font-semibold text-slate-700">{{ s.name }}</span>
<NButton text type="primary" class="ml-auto text-xs hover:underline" @click="preRiskEdit = preRiskEdit === s.key ? '' : s.key">
{{ preRiskEdit === s.key ? "完成" : "编辑" }}
</NButton>
</div>
<NInput v-model:value="form[s.key]" :readonly="preRiskEdit !== s.key" type="textarea" :bordered="false" autofocus />
</div>
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
AI 风险预分析仅供参考用于申请阶段的快速研判正式风险识别请前往风险分析记录表标签页使用 HAZOP / JSA / 检查表法开展并留存记录
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 bg-[#EAF0F9] px-4 py-2.5 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<span class="text-xs text-slate-400">预识别阶段不设审批人与提交完成识别与预分析后请切换至变更申请表选择审批人并提交</span>
<div class="ml-auto flex shrink-0 gap-2">
<NButton ghost type="success" size="small" :loading="generateLoading" :disabled="btnDisabled" @click="genForm">
<Icon icon="akar-icons:file" class="size-14px" /> 生成变更申请单
</NButton>
<NButton ghost type="warning" size="small" @click="runRiskAnalysis" :disabled="btnDisabled">
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
<Icon v-else icon="mdi:shield-alert-outline" class="size-14px" />风险预分析
</NButton>
<NButton type="primary" size="small" :disabled="btnDisabled" :loading="saveLoading" @click="saveDraft">保存</NButton>
</div>
</div>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 326px);
overflow-y: auto;
}
</style>