发起变更,底部封装通用组件

This commit is contained in:
2026-09-04 11:24:27 +08:00
parent cb918ea562
commit 7d05236399
18 changed files with 2260 additions and 1608 deletions
+180 -73
View File
@@ -3,12 +3,14 @@
import { ref } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { AiError, aiRecognize } from "@/service/api/ai";
import { AiError, aiRecognize, aiRiskPreAnalysis } from "@/service/api/ai";
import { precheckSaveApi } from "@/service/api/change";
import ChangeCompareTable from "./ChangeCompareTable.vue";
const themeStore = useThemeStore();
const emit = defineEmits(['updateBtn']);
const props = defineProps(['title','currentId'])
const aiFail = (e: any) => {
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
@@ -18,6 +20,12 @@ const aiFail = (e: any) => {
const isAiGenerated = ref<boolean>(false);
// 变更识别loading
const preLoading = ref<boolean>(false);
// 生成变更申请单loading
const generateLoading = ref<boolean>(false);
// 保存loading
const saveLoading = ref<boolean>(false);
// 风险预分析loading
const analyzing = ref<boolean>(false);
// 编辑风险预分析
const preRiskEdit = ref<string>('');
// 是否显示险预分析
@@ -44,105 +52,204 @@ const runIdentify = async () => {
return window.$message?.warning("请先输入变更内容描述");
}
form.value.compare_rows = [];
emit('updateBtn', true);
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;
emit('updateBtn', false);
btnDisabled.value = false;
}
};
// 更新表单
const updateForm = (res: any, isShow: boolean) => {
form.value = {
...form.value,
severity: res?.severity ?? form.value.severity,
probability: res?.probability ?? form.value.probability,
protection: res?.protection ?? form.value.protection,
};
preRiskShow.value = isShow;
}
// 更新按钮禁用状态
const updateBtnDisabled = (disabled: boolean) => {
btnDisabled.value = disabled;
// 运行风险预分析
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 = async () => {
// 变更预识别
if(props.title?.trim()===''){
return window.$message?.warning('请填写变更名称');
}
if(form.value.description?.trim() === ''){
return window.$message?.warning('请填写变更内容描述');
}
saveLoading.value = true;
btnDisabled.value = true;
const {error} = await precheckSaveApi({
change_id: props.currentId,
title: props.title,
description: form.value.description,
severity: form.value.severity,
probability: form.value.probability,
protection: form.value.protection,
compare_rows: form.value.compare_rows,
})
if(!error){
window.$message?.success("已保存为草稿,可在左侧「我的草稿」中继续编辑");
}
saveLoading.value = false;
btnDisabled.value = false;
}
defineExpose({form,updateForm,updateBtnDisabled})
defineExpose({form})
</script>
<template>
<div class="space-y-4">
<!-- 变更内容描述 -->
<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 || preLoading">
<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="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: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>
<Icon icon="lucide:sparkles" class="size-16px text-violet-500" />
<span class="font-semibold text-base ml-2">变更内容描述</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>
<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">
<ChangeCompareTable
:data="form.compare_rows"
:editable="true"
@update:data="(d) => form.compare_rows = d"
/>
<NInput type="textarea" v-model:value="form.description" rows="4" placeholder="请输入变更内容描述,例如:将反应釜R-101的搅拌速度从120rpm提高至180rpm,同时将反应温度从80℃提高至95℃…" />
</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-if="preRiskEdit === s.key" type="textarea" :bordered="false" v-model:value="form[s.key]" autofocus rows="6" />
<p v-else class="whitespace-pre-wrap px-3 py-1.5 text-sm leading-relaxed text-slate-700">{{ form[s.key] }}</p>
<!-- 变更识别结果 -->
<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>
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
AI 风险预分析仅供参考用于申请阶段的快速研判正式风险识别请前往风险分析记录表标签页使用 HAZOP / JSA / 检查表法开展并留存记录
<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>
</template>
<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>