发起变更AI功能接线:变更预识别生成申请单、各标签页重新生成(申请表/培训内容/PSSR检查/验收评价)对接ai-server并适配新字段渲染;修复等级判定表AI评分重复点击总分累加问题
This commit is contained in:
@@ -59,6 +59,18 @@ const confirmed = ref<{[key: string]: boolean}>({
|
||||
// 变更预分析表单数据
|
||||
const changePreForm = ref<any>(null)
|
||||
const changeApplyForm = ref<any>(null)
|
||||
// 当前申请表所选变更类型的文本编码(供 PSSR 等 AI 接口使用)
|
||||
const applyTypeLabel = computed(() => typeList.value.find((t) => t.value === changeApplyForm.value?.change_type)?.label ?? '');
|
||||
|
||||
// AI 生成变更申请单(变更预识别页派发):回填申请表并切换标签
|
||||
const onApplyGenerated = (res: any) => {
|
||||
if (res?.name) title.value = res.name;
|
||||
changeApplyRef.value?.applyAiResult(res);
|
||||
// 直接切 tab 不经过 tabChange,需同步捕获预识别表单供其他标签页 AI 接口使用
|
||||
changePreForm.value = changePreRef.value?.form;
|
||||
tab.value = 'form';
|
||||
window.$message?.success("变更申请单已生成:AI 自动总结名称、预填内容,均可手动修改");
|
||||
}
|
||||
|
||||
// **********************tab切换***********************
|
||||
const TABS = [
|
||||
@@ -289,6 +301,7 @@ onMounted(() => {
|
||||
:title="title"
|
||||
:currentId="currentId"
|
||||
@save="saveDraft"
|
||||
@apply-generated="onApplyGenerated"
|
||||
/>
|
||||
</div>
|
||||
<!-- ===== 变更申请表 ===== -->
|
||||
@@ -306,7 +319,8 @@ onMounted(() => {
|
||||
:formInfo="changePreForm"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
@confirmTab="confirmTab"
|
||||
@update:title="(v: string) => title = v"
|
||||
/>
|
||||
</div>
|
||||
<!-- ===== 风险分析记录表 ===== -->
|
||||
@@ -330,7 +344,8 @@ onMounted(() => {
|
||||
:type="tab"
|
||||
:isAiOpen="aiEnabled"
|
||||
:title="title"
|
||||
:currentId="currentId"
|
||||
:currentId="currentId"
|
||||
:formInfo="changePreForm"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
@@ -343,7 +358,9 @@ onMounted(() => {
|
||||
:type="tab"
|
||||
:isAiOpen="aiEnabled"
|
||||
:title="title"
|
||||
:currentId="currentId"
|
||||
:currentId="currentId"
|
||||
:formInfo="changePreForm"
|
||||
:changeType="applyTypeLabel"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -1,74 +1,106 @@
|
||||
<!-- 变更培训内容 -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import Footer from './footer.vue';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps(['type','isAiOpen','title','currentId'])
|
||||
const emit = defineEmits(['save','submit','confirmTab']);
|
||||
// 是否是ai生成
|
||||
const isAiGenerated = ref<boolean>(false);
|
||||
const form = ref<{ content: string }>({
|
||||
content: ''
|
||||
});
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// // 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
|
||||
defineExpose({form})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3 min-h-[22px]">
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="lucide:graduation-cap" class="size-16px text-violet-500" />
|
||||
变更培训内容
|
||||
</span>
|
||||
<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>
|
||||
<span v-if="isAiGenerated" class="ml-auto text-xs text-slate-400">AI 生成内容均可点击修改自定义</span>
|
||||
</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="text-sm font-medium text-slate-700">培训内容</span>
|
||||
<span class="text-[11px] text-slate-400">含培训对象、培训方式、变更内容、操作方式、潜在风险与应对措施,一个文本框自动分段换行</span>
|
||||
<span class="ml-auto text-[11px] text-slate-400">内容支持直接修改</span>
|
||||
</div>
|
||||
<NInput v-model:value="form.content" type="textarea" :bordered="false" :autosize="{minRows: 4}" class="font-mono text-sm" placeholder="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
/>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 393px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
<!-- 变更培训内容 -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { AiError, aiTraining } from "@/service/api/ai";
|
||||
import Footer from './footer.vue';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps(['type','isAiOpen','title','currentId','formInfo'])
|
||||
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 分析失败,可手动填写");
|
||||
};
|
||||
// 是否是ai生成
|
||||
const isAiGenerated = ref<boolean>(false);
|
||||
const form = ref<{ content: string }>({
|
||||
content: ''
|
||||
});
|
||||
|
||||
// 重新生成变更培训内容(footer 派发)
|
||||
const onRegenerate = async () => {
|
||||
const info = props.formInfo;
|
||||
if (!info?.description) {
|
||||
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
|
||||
}
|
||||
footerRef.value?.setRegenerateLoading(true);
|
||||
try {
|
||||
const res = await aiTraining(info.description, (info.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
|
||||
if (res?.training_text) {
|
||||
form.value.content = res.training_text;
|
||||
isAiGenerated.value = true;
|
||||
window.$message?.success("变更培训内容已由 AI 生成,可直接修改");
|
||||
} else {
|
||||
window.$message?.warning("AI 未返回培训内容,请手动填写");
|
||||
}
|
||||
} catch (e: any) {
|
||||
aiFail(e);
|
||||
} finally {
|
||||
footerRef.value?.setRegenerateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// // 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
|
||||
defineExpose({form})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3 min-h-[22px]">
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="lucide:graduation-cap" class="size-16px text-violet-500" />
|
||||
变更培训内容
|
||||
</span>
|
||||
<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>
|
||||
<span v-if="isAiGenerated" class="ml-auto text-xs text-slate-400">AI 生成内容均可点击修改自定义</span>
|
||||
</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="text-sm font-medium text-slate-700">培训内容</span>
|
||||
<span class="text-[11px] text-slate-400">含培训对象、培训方式、变更内容、操作方式、潜在风险与应对措施,一个文本框自动分段换行</span>
|
||||
<span class="ml-auto text-[11px] text-slate-400">内容支持直接修改</span>
|
||||
</div>
|
||||
<NInput v-model:value="form.content" type="textarea" :bordered="false" :autosize="{minRows: 4}" class="font-mono text-sm" placeholder="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
@regenerate="onRegenerate"
|
||||
/>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 393px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,240 +1,295 @@
|
||||
<!-- 验收评价 -->
|
||||
<script setup lang="ts">
|
||||
import { h, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getColorWithOpacity } from "@/utils/common";
|
||||
import Footer from './footer.vue';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps(['type','orgTree','isAiOpen','title','currentId','formInfo'])
|
||||
const emit = defineEmits(['save','submit','confirmTab']);
|
||||
|
||||
|
||||
const editing = ref<any>(null);
|
||||
const list = ref<{id:number,item:string,basis:string,standard:string,method:string,org_id:number}[]>([]);
|
||||
|
||||
// 添加验收项
|
||||
const addRow = () => {
|
||||
list.value.push({ id: 0, item: '', basis: '', standard: '', method: '',org_id: 0 });
|
||||
}
|
||||
// 删除验收项
|
||||
const deleteRow = (i:number) => {
|
||||
list.value.splice(i, 1);
|
||||
}
|
||||
|
||||
// 自定义渲染展开图标
|
||||
const renderSwitcherIcon = (node:any) => {
|
||||
// 如果节点没有 children 或 children 为空数组,则不显示图标
|
||||
if (!node.option.children || node.option.children.length === 0) {
|
||||
return null; // 返回 null 表示不渲染任何图标
|
||||
}
|
||||
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
|
||||
return h(Icon,{
|
||||
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
|
||||
class: 'size-12px',
|
||||
});
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// // 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
|
||||
const findNodeById = (tree:any,id:number) => {
|
||||
for (const node of tree) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children && node.children.length) {
|
||||
const found:any = findNodeById(node.children,id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null; // 未找到
|
||||
}
|
||||
|
||||
defineExpose({list})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="ix:success" class="size-18px text-emerald-500" />
|
||||
验收评价(依据预期效果设定可量化验收标准)
|
||||
</span>
|
||||
<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>
|
||||
<span class="ml-auto text-xs text-slate-400">申请阶段由 AI 依据「预期效果」生成量化标准与验收方式,可手动修改;投用运行后由车间和各专业按标准验收并给出评估结论</span>
|
||||
</div>
|
||||
<div
|
||||
class="space-y-1.5 rounded-lg border border-[var(--border-color)] bg-[var(--bg-color)] px-4 py-3 text-xs leading-6 text-slate-700"
|
||||
:style="{
|
||||
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1),
|
||||
'--border-color': getColorWithOpacity(themeStore.themeColor, 0.2)
|
||||
}"
|
||||
>
|
||||
<div><b :style="{color: themeStore.themeColor}">变更目的:</b>{{ formInfo?.purposes?.join('、') || '' }}</div>
|
||||
<div><b :style="{color: themeStore.themeColor}">预期效果:</b>{{ formInfo?.effect || '' }}</div>
|
||||
<div
|
||||
class="border-t border-[var(--border-color)] pt-1.5 text-slate-500"
|
||||
:style="{'--border-color': getColorWithOpacity(themeStore.themeColor, 0.2)}"
|
||||
>
|
||||
<b :style="{color: themeStore.themeColor}">对应说明:</b>验收项目由预期效果逐条拆解设定,各项目对应的预期效果来源见表格「验收项目」列下方灰色小字(点击可修改);验收时须逐项对照预期效果判定达成程度。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<table class="w-full min-w-[900px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-100 text-left text-slate-600">
|
||||
<th class="w-12 px-3 py-2 font-medium">#</th>
|
||||
<th class="w-56 px-3 py-2 font-medium">验收项目(含对应预期效果)</th>
|
||||
<th class="px-3 py-2 font-medium">量化验收标准</th>
|
||||
<th class="px-3 py-2 font-medium">验收方式(可执行)</th>
|
||||
<th class="w-46 px-3 py-2 font-medium">组织层级</th>
|
||||
<th class="w-12 px-2 py-2 font-medium text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="list.length===0">
|
||||
<td colspan="6" class="py-4 px-2 text-center text-slate-400">暂无验收项目</td>
|
||||
</tr>
|
||||
<tr v-for="(row, i) in list" :key="i" class="align-top">
|
||||
<td class="border-t px-3 py-2.5 text-slate-400">{{ i + 1 }}</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NInput
|
||||
v-if="editing === `item-${i}-${row.item}`"
|
||||
class="text-xs"
|
||||
v-model:value="row.item"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `item-${i}-${row.item}`" title="点击修改"
|
||||
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs font-medium leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
|
||||
>
|
||||
{{ row.item || '点击填写验收项目' }}
|
||||
</button>
|
||||
<NInput
|
||||
v-if="editing === `basis-${i}-${row.basis}`"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
v-model:value="row.basis"
|
||||
autofocus
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `basis-${i}-${row.basis}`" title="本项目对应的预期效果来源,点击修改"
|
||||
class="bg-transparent mt-0.5 block w-full rounded px-2 text-left text-[11px] text-slate-400 transition hover:bg-[#EAF0F9] hover:text-slate-500"
|
||||
>
|
||||
对应预期:{{ row.basis || '(点击填写对应预期效果)' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NInput
|
||||
v-if="editing === `standard-${i}-${row.standard}`"
|
||||
class="text-xs"
|
||||
v-model:value="row.standard"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `standard-${i}-${row.standard}`" title="点击修改"
|
||||
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
|
||||
>
|
||||
{{ row.standard || '点击填写量化验收标准' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NInput
|
||||
v-if="editing === `method-${i}-${row.method}`"
|
||||
class="text-xs"
|
||||
v-model:value="row.method"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `method-${i}-${row.method}`" title="点击修改"
|
||||
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
|
||||
>
|
||||
{{ row.method || '点击填写验收方式' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NTreeSelect
|
||||
v-if="editing === `org-${i}-${row.org_id}`"
|
||||
size="small"
|
||||
v-model:value="row.org_id"
|
||||
default-expand-all
|
||||
label-field="label"
|
||||
key-field="id"
|
||||
:options="props.orgTree"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
placeholder="请选择组织层级"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `org-${i}-${row.org_id}`" title="点击修改"
|
||||
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
|
||||
>
|
||||
{{ findNodeById(orgTree,row.org_id)?.label || '点击选择组织层级' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-2 py-2.5 text-center">
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="deleteRow(i)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" class="mt-1.5" title="删除本验收项">
|
||||
<Icon icon="iconamoon:trash" class="size-16px" />
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NButton ghost class="text-xs" @click="addRow">
|
||||
<Icon icon="ic:round-plus" class="size-16px" />添加验收项
|
||||
</NButton>
|
||||
<span class="text-xs text-slate-400">评估结论在验收阶段由责任单位填写:点击选择「完全达到 / 基本达到 / 未达成」,再次点击取消;行尾可删除验收项</span>
|
||||
</div>
|
||||
<div class="rounded-lg bg-emerald-50/80 px-4 py-2.5 text-xs leading-5 text-emerald-700">
|
||||
流程说明:投用运行满验收周期后,系统向车间(属地)与涉及专业推送验收任务;各责任单位按上表逐项给出评估结论并上传佐证(数据报表 / 化验报告 / 现场照片),全部完成后汇总至综合验收结论,结论与佐证资料一并纳入变更关闭校验,验收不通过时退回整改或评估恢复原状。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
/>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 393px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
<!-- 验收评价 -->
|
||||
<script setup lang="ts">
|
||||
import { h, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getColorWithOpacity } from "@/utils/common";
|
||||
import { AiError, aiAcceptance } from "@/service/api/ai";
|
||||
import Footer from './footer.vue';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps(['type','orgTree','isAiOpen','title','currentId','formInfo'])
|
||||
const emit = defineEmits(['save','submit','confirmTab']);
|
||||
|
||||
const footerRef = ref<any>(null);
|
||||
const editing = ref<any>(null);
|
||||
const list = ref<{id:number,item:string,basis:string,standard:string,method:string,org_id:number}[]>([]);
|
||||
|
||||
const aiFail = (e: any) => {
|
||||
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
|
||||
else window.$message?.warning("AI 分析失败,可手动填写");
|
||||
};
|
||||
|
||||
// 重新生成验收评价(footer 派发):依据申请表的变更目的与预期效果生成量化验收标准
|
||||
const onRegenerate = async () => {
|
||||
const purpose = (props.formInfo?.purposes ?? []).filter((x: string) => x && x.trim()).join('、');
|
||||
const effect = props.formInfo?.effect ?? '';
|
||||
if (!purpose && !effect.trim()) {
|
||||
return window.$message?.warning("缺少变更目的与预期效果,请先在 [变更申请表] 填写");
|
||||
}
|
||||
footerRef.value?.setRegenerateLoading(true);
|
||||
try {
|
||||
const res = await aiAcceptance({ purpose, effect });
|
||||
const rows = res?.rows ?? [];
|
||||
if (!rows.length) {
|
||||
return window.$message?.warning("AI 未生成验收项,可手动添加");
|
||||
}
|
||||
list.value = rows.map((r: any) => ({
|
||||
id: 0,
|
||||
item: r.item ?? '',
|
||||
basis: r.basis ?? '',
|
||||
standard: r.standard ?? '',
|
||||
method: r.method ?? '',
|
||||
// 建议责任方文本 → 组织树节点(按名称模糊匹配,匹配不到留空人工选择)
|
||||
org_id: matchOrgId(r.owner_suggest),
|
||||
}));
|
||||
window.$message?.success("验收量化标准已由 AI 生成,均可手动修改");
|
||||
} catch (e: any) {
|
||||
aiFail(e);
|
||||
} finally {
|
||||
footerRef.value?.setRegenerateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 按名称在组织树中查找节点 id
|
||||
const matchOrgId = (name: string): number => {
|
||||
if (!name || !name.trim()) return 0;
|
||||
const hit = findNodeByLabel(props.orgTree ?? [], name.trim());
|
||||
return hit?.id ?? 0;
|
||||
}
|
||||
const findNodeByLabel = (tree: any[], name: string): any => {
|
||||
for (const node of tree) {
|
||||
if (node.label && (name.includes(node.label) || node.label.includes(name))) return node;
|
||||
if (node.children && node.children.length) {
|
||||
const found: any = findNodeByLabel(node.children, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 添加验收项
|
||||
const addRow = () => {
|
||||
list.value.push({ id: 0, item: '', basis: '', standard: '', method: '',org_id: 0 });
|
||||
}
|
||||
// 删除验收项
|
||||
const deleteRow = (i:number) => {
|
||||
list.value.splice(i, 1);
|
||||
}
|
||||
|
||||
// 自定义渲染展开图标
|
||||
const renderSwitcherIcon = (node:any) => {
|
||||
// 如果节点没有 children 或 children 为空数组,则不显示图标
|
||||
if (!node.option.children || node.option.children.length === 0) {
|
||||
return null; // 返回 null 表示不渲染任何图标
|
||||
}
|
||||
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
|
||||
return h(Icon,{
|
||||
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
|
||||
class: 'size-12px',
|
||||
});
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// // 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
|
||||
const findNodeById = (tree:any,id:number) => {
|
||||
for (const node of tree) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children && node.children.length) {
|
||||
const found:any = findNodeById(node.children,id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null; // 未找到
|
||||
}
|
||||
|
||||
defineExpose({list})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="ix:success" class="size-18px text-emerald-500" />
|
||||
验收评价(依据预期效果设定可量化验收标准)
|
||||
</span>
|
||||
<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>
|
||||
<span class="ml-auto text-xs text-slate-400">申请阶段由 AI 依据「预期效果」生成量化标准与验收方式,可手动修改;投用运行后由车间和各专业按标准验收并给出评估结论</span>
|
||||
</div>
|
||||
<div
|
||||
class="space-y-1.5 rounded-lg border border-[var(--border-color)] bg-[var(--bg-color)] px-4 py-3 text-xs leading-6 text-slate-700"
|
||||
:style="{
|
||||
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1),
|
||||
'--border-color': getColorWithOpacity(themeStore.themeColor, 0.2)
|
||||
}"
|
||||
>
|
||||
<div><b :style="{color: themeStore.themeColor}">变更目的:</b>{{ formInfo?.purposes?.join('、') || '' }}</div>
|
||||
<div><b :style="{color: themeStore.themeColor}">预期效果:</b>{{ formInfo?.effect || '' }}</div>
|
||||
<div
|
||||
class="border-t border-[var(--border-color)] pt-1.5 text-slate-500"
|
||||
:style="{'--border-color': getColorWithOpacity(themeStore.themeColor, 0.2)}"
|
||||
>
|
||||
<b :style="{color: themeStore.themeColor}">对应说明:</b>验收项目由预期效果逐条拆解设定,各项目对应的预期效果来源见表格「验收项目」列下方灰色小字(点击可修改);验收时须逐项对照预期效果判定达成程度。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<table class="w-full min-w-[900px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-100 text-left text-slate-600">
|
||||
<th class="w-12 px-3 py-2 font-medium">#</th>
|
||||
<th class="w-56 px-3 py-2 font-medium">验收项目(含对应预期效果)</th>
|
||||
<th class="px-3 py-2 font-medium">量化验收标准</th>
|
||||
<th class="px-3 py-2 font-medium">验收方式(可执行)</th>
|
||||
<th class="w-46 px-3 py-2 font-medium">组织层级</th>
|
||||
<th class="w-12 px-2 py-2 font-medium text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="list.length===0">
|
||||
<td colspan="6" class="py-4 px-2 text-center text-slate-400">暂无验收项目</td>
|
||||
</tr>
|
||||
<tr v-for="(row, i) in list" :key="i" class="align-top">
|
||||
<td class="border-t px-3 py-2.5 text-slate-400">{{ i + 1 }}</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NInput
|
||||
v-if="editing === `item-${i}-${row.item}`"
|
||||
class="text-xs"
|
||||
v-model:value="row.item"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `item-${i}-${row.item}`" title="点击修改"
|
||||
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs font-medium leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
|
||||
>
|
||||
{{ row.item || '点击填写验收项目' }}
|
||||
</button>
|
||||
<NInput
|
||||
v-if="editing === `basis-${i}-${row.basis}`"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
v-model:value="row.basis"
|
||||
autofocus
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `basis-${i}-${row.basis}`" title="本项目对应的预期效果来源,点击修改"
|
||||
class="bg-transparent mt-0.5 block w-full rounded px-2 text-left text-[11px] text-slate-400 transition hover:bg-[#EAF0F9] hover:text-slate-500"
|
||||
>
|
||||
对应预期:{{ row.basis || '(点击填写对应预期效果)' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NInput
|
||||
v-if="editing === `standard-${i}-${row.standard}`"
|
||||
class="text-xs"
|
||||
v-model:value="row.standard"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `standard-${i}-${row.standard}`" title="点击修改"
|
||||
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
|
||||
>
|
||||
{{ row.standard || '点击填写量化验收标准' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NInput
|
||||
v-if="editing === `method-${i}-${row.method}`"
|
||||
class="text-xs"
|
||||
v-model:value="row.method"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `method-${i}-${row.method}`" title="点击修改"
|
||||
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
|
||||
>
|
||||
{{ row.method || '点击填写验收方式' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NTreeSelect
|
||||
v-if="editing === `org-${i}-${row.org_id}`"
|
||||
size="small"
|
||||
v-model:value="row.org_id"
|
||||
default-expand-all
|
||||
label-field="label"
|
||||
key-field="id"
|
||||
:options="props.orgTree"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
placeholder="请选择组织层级"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `org-${i}-${row.org_id}`" title="点击修改"
|
||||
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
|
||||
>
|
||||
{{ findNodeById(orgTree,row.org_id)?.label || '点击选择组织层级' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-2 py-2.5 text-center">
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="deleteRow(i)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" class="mt-1.5" title="删除本验收项">
|
||||
<Icon icon="iconamoon:trash" class="size-16px" />
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NButton ghost class="text-xs" @click="addRow">
|
||||
<Icon icon="ic:round-plus" class="size-16px" />添加验收项
|
||||
</NButton>
|
||||
<span class="text-xs text-slate-400">评估结论在验收阶段由责任单位填写:点击选择「完全达到 / 基本达到 / 未达成」,再次点击取消;行尾可删除验收项</span>
|
||||
</div>
|
||||
<div class="rounded-lg bg-emerald-50/80 px-4 py-2.5 text-xs leading-5 text-emerald-700">
|
||||
流程说明:投用运行满验收周期后,系统向车间(属地)与涉及专业推送验收任务;各责任单位按上表逐项给出评估结论并上传佐证(数据报表 / 化验报告 / 现场照片),全部完成后汇总至综合验收结论,结论与佐证资料一并纳入变更关闭校验,验收不通过时退回整改或评估恢复原状。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
@regenerate="onRegenerate"
|
||||
/>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 393px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,208 +1,212 @@
|
||||
<!-- 通用底部操作 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useChangeStore } from '@/store/modules/change';
|
||||
|
||||
const changeStore = useChangeStore();
|
||||
const { getStepsInfo, getFlowInfo } = storeToRefs(changeStore);
|
||||
|
||||
const props = defineProps([
|
||||
'type',
|
||||
'currentId',
|
||||
'title',
|
||||
'isAiOpen',
|
||||
'currentForm'
|
||||
])
|
||||
const emit = defineEmits(['save','submit','confirmTab']);
|
||||
|
||||
const generalLoading = inject('generalLoading', {saveLoading: ref<boolean>(false),btnDisabled: ref<boolean>(false)})
|
||||
const {saveLoading,btnDisabled} = generalLoading
|
||||
|
||||
const approvalDrawerShow = ref<boolean>(false);
|
||||
const downloadLoading = ref<boolean>(false);
|
||||
const regenerateLoading = ref<boolean>(false);
|
||||
const submitLoading = ref<boolean>(false);
|
||||
const confirmed = ref<boolean>(false);
|
||||
|
||||
// 存储每个步骤选中的审核人 user_id
|
||||
const approvers = ref<Record<number, number[]>>({})
|
||||
const flowList = ref<any[]>([]);
|
||||
// 审批链
|
||||
const approvalFlowText = computed(() => {
|
||||
if (!flowList.value || flowList.value.length === 0) return '暂无步骤'
|
||||
// 按 step_order 排序
|
||||
const sorted = [...flowList.value].sort((a, b) => a.step_order - b.step_order)
|
||||
return sorted.map(item => item.name).join(' → ')
|
||||
})
|
||||
|
||||
// 清空审批人
|
||||
const clearApprovers = () => {
|
||||
approvers.value = {};
|
||||
}
|
||||
|
||||
// 打开审批流程抽屉
|
||||
const openApprovalDrawer = () => {
|
||||
if(props.type==='form'){
|
||||
if(!props.currentForm?.change_type || !props.currentForm?.change_level || !props.currentForm?.duration_type){
|
||||
return window.$message?.warning('请先选择变更类型、变更等级、变更时限');
|
||||
}
|
||||
}
|
||||
flowList.value = getStepsInfo.value;
|
||||
if(Object.keys(approvers.value).length === 0){
|
||||
flowList.value.forEach((item: any) => {
|
||||
if (item.members && item.members.length > 0) {
|
||||
// 优先选 is_sign=1 的成员 ,否则选第一个
|
||||
const defaultMembers = item.members
|
||||
.filter((m: any) => m.is_sign === 1)
|
||||
.map((m: any) => m.user_id);
|
||||
approvers.value[item.id] = defaultMembers.length > 0 ? defaultMembers : [item.members[0].user_id];
|
||||
}
|
||||
})
|
||||
}
|
||||
approvalDrawerShow.value = true;
|
||||
}
|
||||
|
||||
// 审批链
|
||||
const approvalChain = computed(() => {
|
||||
let template = getFlowInfo.value;
|
||||
if (!template || template.length === 0) return '暂无审批步骤'
|
||||
const parts = template.map((step: any) => {
|
||||
// 根据 countersign 决定显示名称
|
||||
const displayName = step.countersign ? '专业会签' : step.name
|
||||
const members = step.selectedMember
|
||||
let memberText;
|
||||
if (members && members.length > 0) {
|
||||
memberText = members.map((m: any) => `${m.user_name}(${m.org_name})`).join('、');
|
||||
} else {
|
||||
memberText = '未指定';
|
||||
}
|
||||
return `${displayName}:${memberText}`;
|
||||
})
|
||||
return parts.join(' → ')
|
||||
})
|
||||
|
||||
// 下载
|
||||
const download = async () => {
|
||||
|
||||
}
|
||||
// 重新生成
|
||||
const regenerate = async () => {
|
||||
|
||||
}
|
||||
|
||||
// 确认标签内容
|
||||
const confirmTab = async () => {
|
||||
confirmed.value = !confirmed.value;
|
||||
emit('confirmTab', props.type, confirmed.value);
|
||||
}
|
||||
// 保存
|
||||
const saveDraft = async () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitApply = () => {
|
||||
emit('submit');
|
||||
}
|
||||
|
||||
// 更新选中审批人 user_id
|
||||
const updateApprover = (stepId: number, usersId: number[]) => {
|
||||
approvers.value[stepId] = usersId
|
||||
}
|
||||
// 匹配审批人
|
||||
function matchApprovers(steps: any[], mapping: Record<number, number[]>) {
|
||||
const result = [];
|
||||
for (const [stepId, usersId] of Object.entries(mapping)) {
|
||||
const step = steps.find(s => s.id === Number(stepId));
|
||||
if (step) {
|
||||
const members = step.members.filter((m: any) => usersId.includes(m.user_id));
|
||||
if (members.length > 0) {
|
||||
result.push({
|
||||
countersign: step.countersign,
|
||||
stepId: step.id,
|
||||
name: step.name,
|
||||
selectedMember: members
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// 完成设置
|
||||
const finishApprover = async () => {
|
||||
const matched = matchApprovers(flowList.value, approvers.value);
|
||||
changeStore.updateFlowInfo(matched);
|
||||
window.$message?.success("审批流程设置完成");
|
||||
approvalDrawerShow.value = false;
|
||||
}
|
||||
|
||||
defineExpose({clearApprovers})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-t p-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<NButton ghost type="success" :disabled="btnDisabled" :loading="downloadLoading" @click="download">
|
||||
<Icon icon="material-symbols:download" class="size-16px" />
|
||||
<span v-if="props.type==='form'">下载变更申请表</span>
|
||||
<span v-if="props.type==='risk'">下载风险分析记录表</span>
|
||||
<span v-if="props.type==='train'">下载变更培训内容</span>
|
||||
<span v-if="props.type==='pssr'">下载PSSR检查内容</span>
|
||||
<span v-if="props.type==='evaluation'">下载验收评价</span>
|
||||
<span v-if="props.type==='close'">下载变更关闭确认表</span>
|
||||
</NButton>
|
||||
<NButton v-if="props.isAiOpen" ghost type="warning" :disabled="btnDisabled" :loading="regenerateLoading" @click="regenerate">
|
||||
<Icon icon="tdesign:refresh" class="size-14px mr-1" />
|
||||
<span v-if="props.type==='form'">重新生成变更申请表</span>
|
||||
<span v-if="props.type==='risk'">重新生成风险分析记录表</span>
|
||||
<span v-if="props.type==='train'">重新生成变更培训内容</span>
|
||||
<span v-if="props.type==='pssr'">重新生成PSSR检查内容</span>
|
||||
<span v-if="props.type==='evaluation'">重新生成验收评价</span>
|
||||
<span v-if="props.type==='close'">重新生成变更关闭确认表</span>
|
||||
</NButton>
|
||||
<NButton v-if="props.isAiOpen" ghost :type="confirmed ? 'success' : 'primary'" :disabled="btnDisabled" @click="confirmTab">
|
||||
<Icon icon="ix:success" class="size-16px mr-1" /> {{ confirmed ? '已确认完毕' : '本标签内容确认完毕' }}
|
||||
</NButton>
|
||||
</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)]">
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500">
|
||||
<Icon icon="lucide:users" class="size-14px" />
|
||||
<span class="min-w-0 truncate">
|
||||
部门审核:{{ approvalChain }}
|
||||
</span>
|
||||
<NButton v-if="getStepsInfo && getStepsInfo.length" ghost size="small" type="primary" :disabled="btnDisabled" @click="openApprovalDrawer">
|
||||
<Icon icon="octicon:sliders-24" class="size-14px" />
|
||||
审批流程设置
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<NButton size="small" type="primary" :disabled="btnDisabled" :loading="saveLoading" @click="saveDraft">保存</NButton>
|
||||
<NButton size="small" type="primary" @click="submitApply" :disabled="btnDisabled" :loading="submitLoading">
|
||||
提交 <Icon icon="formkit:right" class="size-14px" />
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 审批流程设置 -->
|
||||
<NDrawer v-model:show="approvalDrawerShow" placement="right" width="28rem">
|
||||
<NDrawerContent>
|
||||
<template #header>
|
||||
<div class="text-base font-semibold text-slate-800">审批流程设置</div>
|
||||
</template>
|
||||
<div class="h-full space-y-4 overflow-y-auto">
|
||||
<p class="text-xs leading-relaxed text-slate-400">审批链:{{ approvalFlowText }}。专业会签人由申请表「涉及专业」勾选自动增减,可在此调整人选;重要变更自动追加安全审查与分管领导终批。</p>
|
||||
<div class="rounded-lg border p-3" v-for="(item,index) in flowList" :key="item.id">
|
||||
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<NTag type="info" size="small">{{Number(index)+1}}</NTag> {{item.name}} {{item.countersign ? '(专业会签)' : ''}}
|
||||
</div>
|
||||
<NSelect :value="approvers[item.id]" multiple :options="item.members" label-field="user_name" value-field="user_id"
|
||||
@update:value="(v) => updateApprover(item.id, v)" />
|
||||
</div>
|
||||
<NButton type="primary" class="w-full" @click="finishApprover">完成设置</NButton>
|
||||
</div>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
<!-- 通用底部操作 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useChangeStore } from '@/store/modules/change';
|
||||
|
||||
const changeStore = useChangeStore();
|
||||
const { getStepsInfo, getFlowInfo } = storeToRefs(changeStore);
|
||||
|
||||
const props = defineProps([
|
||||
'type',
|
||||
'currentId',
|
||||
'title',
|
||||
'isAiOpen',
|
||||
'currentForm'
|
||||
])
|
||||
const emit = defineEmits(['save','submit','confirmTab','regenerate']);
|
||||
|
||||
const generalLoading = inject('generalLoading', {saveLoading: ref<boolean>(false),btnDisabled: ref<boolean>(false)})
|
||||
const {saveLoading,btnDisabled} = generalLoading
|
||||
|
||||
const approvalDrawerShow = ref<boolean>(false);
|
||||
const downloadLoading = ref<boolean>(false);
|
||||
const regenerateLoading = ref<boolean>(false);
|
||||
const submitLoading = ref<boolean>(false);
|
||||
const confirmed = ref<boolean>(false);
|
||||
|
||||
// 存储每个步骤选中的审核人 user_id
|
||||
const approvers = ref<Record<number, number[]>>({})
|
||||
const flowList = ref<any[]>([]);
|
||||
// 审批链
|
||||
const approvalFlowText = computed(() => {
|
||||
if (!flowList.value || flowList.value.length === 0) return '暂无步骤'
|
||||
// 按 step_order 排序
|
||||
const sorted = [...flowList.value].sort((a, b) => a.step_order - b.step_order)
|
||||
return sorted.map(item => item.name).join(' → ')
|
||||
})
|
||||
|
||||
// 清空审批人
|
||||
const clearApprovers = () => {
|
||||
approvers.value = {};
|
||||
}
|
||||
|
||||
// 打开审批流程抽屉
|
||||
const openApprovalDrawer = () => {
|
||||
if(props.type==='form'){
|
||||
if(!props.currentForm?.change_type || !props.currentForm?.change_level || !props.currentForm?.duration_type){
|
||||
return window.$message?.warning('请先选择变更类型、变更等级、变更时限');
|
||||
}
|
||||
}
|
||||
flowList.value = getStepsInfo.value;
|
||||
if(Object.keys(approvers.value).length === 0){
|
||||
flowList.value.forEach((item: any) => {
|
||||
if (item.members && item.members.length > 0) {
|
||||
// 优先选 is_sign=1 的成员 ,否则选第一个
|
||||
const defaultMembers = item.members
|
||||
.filter((m: any) => m.is_sign === 1)
|
||||
.map((m: any) => m.user_id);
|
||||
approvers.value[item.id] = defaultMembers.length > 0 ? defaultMembers : [item.members[0].user_id];
|
||||
}
|
||||
})
|
||||
}
|
||||
approvalDrawerShow.value = true;
|
||||
}
|
||||
|
||||
// 审批链
|
||||
const approvalChain = computed(() => {
|
||||
let template = getFlowInfo.value;
|
||||
if (!template || template.length === 0) return '暂无审批步骤'
|
||||
const parts = template.map((step: any) => {
|
||||
// 根据 countersign 决定显示名称
|
||||
const displayName = step.countersign ? '专业会签' : step.name
|
||||
const members = step.selectedMember
|
||||
let memberText;
|
||||
if (members && members.length > 0) {
|
||||
memberText = members.map((m: any) => `${m.user_name}(${m.org_name})`).join('、');
|
||||
} else {
|
||||
memberText = '未指定';
|
||||
}
|
||||
return `${displayName}:${memberText}`;
|
||||
})
|
||||
return parts.join(' → ')
|
||||
})
|
||||
|
||||
// 下载
|
||||
const download = async () => {
|
||||
|
||||
}
|
||||
// 重新生成:AI 调用逻辑由各标签页组件实现(按 type 分发),此处仅派发事件
|
||||
const regenerate = async () => {
|
||||
emit('regenerate', props.type);
|
||||
}
|
||||
// 供父组件控制「重新生成」按钮 loading
|
||||
const setRegenerateLoading = (v: boolean) => {
|
||||
regenerateLoading.value = v;
|
||||
}
|
||||
|
||||
// 确认标签内容
|
||||
const confirmTab = async () => {
|
||||
confirmed.value = !confirmed.value;
|
||||
emit('confirmTab', props.type, confirmed.value);
|
||||
}
|
||||
// 保存
|
||||
const saveDraft = async () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitApply = () => {
|
||||
emit('submit');
|
||||
}
|
||||
|
||||
// 更新选中审批人 user_id
|
||||
const updateApprover = (stepId: number, usersId: number[]) => {
|
||||
approvers.value[stepId] = usersId
|
||||
}
|
||||
// 匹配审批人
|
||||
function matchApprovers(steps: any[], mapping: Record<number, number[]>) {
|
||||
const result = [];
|
||||
for (const [stepId, usersId] of Object.entries(mapping)) {
|
||||
const step = steps.find(s => s.id === Number(stepId));
|
||||
if (step) {
|
||||
const members = step.members.filter((m: any) => usersId.includes(m.user_id));
|
||||
if (members.length > 0) {
|
||||
result.push({
|
||||
countersign: step.countersign,
|
||||
stepId: step.id,
|
||||
name: step.name,
|
||||
selectedMember: members
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// 完成设置
|
||||
const finishApprover = async () => {
|
||||
const matched = matchApprovers(flowList.value, approvers.value);
|
||||
changeStore.updateFlowInfo(matched);
|
||||
window.$message?.success("审批流程设置完成");
|
||||
approvalDrawerShow.value = false;
|
||||
}
|
||||
|
||||
defineExpose({clearApprovers, setRegenerateLoading})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-t p-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<NButton ghost type="success" :disabled="btnDisabled" :loading="downloadLoading" @click="download">
|
||||
<Icon icon="material-symbols:download" class="size-16px" />
|
||||
<span v-if="props.type==='form'">下载变更申请表</span>
|
||||
<span v-if="props.type==='risk'">下载风险分析记录表</span>
|
||||
<span v-if="props.type==='train'">下载变更培训内容</span>
|
||||
<span v-if="props.type==='pssr'">下载PSSR检查内容</span>
|
||||
<span v-if="props.type==='evaluation'">下载验收评价</span>
|
||||
<span v-if="props.type==='close'">下载变更关闭确认表</span>
|
||||
</NButton>
|
||||
<NButton v-if="props.isAiOpen" ghost type="warning" :disabled="btnDisabled" :loading="regenerateLoading" @click="regenerate">
|
||||
<Icon icon="tdesign:refresh" class="size-14px mr-1" />
|
||||
<span v-if="props.type==='form'">重新生成变更申请表</span>
|
||||
<span v-if="props.type==='risk'">重新生成风险分析记录表</span>
|
||||
<span v-if="props.type==='train'">重新生成变更培训内容</span>
|
||||
<span v-if="props.type==='pssr'">重新生成PSSR检查内容</span>
|
||||
<span v-if="props.type==='evaluation'">重新生成验收评价</span>
|
||||
<span v-if="props.type==='close'">重新生成变更关闭确认表</span>
|
||||
</NButton>
|
||||
<NButton v-if="props.isAiOpen" ghost :type="confirmed ? 'success' : 'primary'" :disabled="btnDisabled" @click="confirmTab">
|
||||
<Icon icon="ix:success" class="size-16px mr-1" /> {{ confirmed ? '已确认完毕' : '本标签内容确认完毕' }}
|
||||
</NButton>
|
||||
</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)]">
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500">
|
||||
<Icon icon="lucide:users" class="size-14px" />
|
||||
<span class="min-w-0 truncate">
|
||||
部门审核:{{ approvalChain }}
|
||||
</span>
|
||||
<NButton v-if="getStepsInfo && getStepsInfo.length" ghost size="small" type="primary" :disabled="btnDisabled" @click="openApprovalDrawer">
|
||||
<Icon icon="octicon:sliders-24" class="size-14px" />
|
||||
审批流程设置
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<NButton size="small" type="primary" :disabled="btnDisabled" :loading="saveLoading" @click="saveDraft">保存</NButton>
|
||||
<NButton size="small" type="primary" @click="submitApply" :disabled="btnDisabled" :loading="submitLoading">
|
||||
提交 <Icon icon="formkit:right" class="size-14px" />
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 审批流程设置 -->
|
||||
<NDrawer v-model:show="approvalDrawerShow" placement="right" width="28rem">
|
||||
<NDrawerContent>
|
||||
<template #header>
|
||||
<div class="text-base font-semibold text-slate-800">审批流程设置</div>
|
||||
</template>
|
||||
<div class="h-full space-y-4 overflow-y-auto">
|
||||
<p class="text-xs leading-relaxed text-slate-400">审批链:{{ approvalFlowText }}。专业会签人由申请表「涉及专业」勾选自动增减,可在此调整人选;重要变更自动追加安全审查与分管领导终批。</p>
|
||||
<div class="rounded-lg border p-3" v-for="(item,index) in flowList" :key="item.id">
|
||||
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<NTag type="info" size="small">{{Number(index)+1}}</NTag> {{item.name}} {{item.countersign ? '(专业会签)' : ''}}
|
||||
</div>
|
||||
<NSelect :value="approvers[item.id]" multiple :options="item.members" label-field="user_name" value-field="user_id"
|
||||
@update:value="(v) => updateApprover(item.id, v)" />
|
||||
</div>
|
||||
<NButton type="primary" class="w-full" @click="finishApprover">完成设置</NButton>
|
||||
</div>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
@@ -1,171 +1,222 @@
|
||||
<!-- PSSR检查内容 -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import Footer from './footer.vue';
|
||||
import { uploadFileApi } from "@/service/api/file";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps(['type','isAiOpen','title','currentId'])
|
||||
const emit = defineEmits(['save','submit','confirmTab']);
|
||||
|
||||
const uploadLoading = ref<boolean>(false);
|
||||
const uploadFileRef = ref<any>(null);
|
||||
const isUploaded = ref<boolean>(false);
|
||||
const editing = ref<any>(null);
|
||||
|
||||
const list = ref<{ g: string; type:number; na_flag:number; items: any[] }[]>([
|
||||
{ g: "一、风险分析行动项落实", type:1, na_flag:0, items: [] },
|
||||
{ g: "二、现场设备安装检查确认", type:2, na_flag:0, items: [] },
|
||||
{ g: "三、保护措施确认", type:3, na_flag:0, items: [] },
|
||||
{ g: "四、资料归档确认", type:4, na_flag:0, items: [] },
|
||||
]);
|
||||
const pssrNa = ref<Record<string, boolean>>({});
|
||||
|
||||
// 添加检查项
|
||||
const addItem = (g:any, gi: number) => {
|
||||
list.value[gi].items.push({id:0, group_type:g.type, content: '新检查项(点击直接编辑内容)', phase: 1, na_flag: 0 });
|
||||
}
|
||||
|
||||
// 上传纸质确认表
|
||||
const uploadFile = async (e: any) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('step', '5');
|
||||
uploadLoading.value = true;
|
||||
const {error} = await uploadFileApi(props.currentId, formData);
|
||||
if(!error){
|
||||
isUploaded.value = true;
|
||||
window.$message?.success(`已上传 PSSR 纸质确认表:不做内容识别,上传即视为全部检查项完成确认(「不涉及」组除外),原件归档至变更档案`);
|
||||
}
|
||||
uploadLoading.value = false;
|
||||
};
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// // 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
|
||||
defineExpose({list})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="tabler:clipboard-check" class="size-16px text-violet-500" />
|
||||
PSSR 投用前安全检查内容
|
||||
</span>
|
||||
<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>
|
||||
<span class="flex items-center gap-2">
|
||||
<NButton ghost size="small" :disabled="uploadLoading" class="text-xs" title="上传 PSSR 纸质确认表照片 / 扫描件(JPG / PNG / PDF),系统不做内容识别,上传即视为全部检查项完成确认" @click="uploadFileRef?.click()">
|
||||
<Icon v-if="uploadLoading" icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
||||
<Icon v-else icon="material-symbols:upload" class="size-14px" />
|
||||
上传纸质确认表
|
||||
</NButton>
|
||||
<input ref="uploadFileRef" type="file" accept="image/*,.pdf" class="hidden" @change="uploadFile" />
|
||||
<span class="hidden text-xs text-slate-400 xl:inline">申请阶段:点击条目内容直接编辑,可增删项</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isUploaded" class="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2 text-xs text-emerald-700">
|
||||
<Icon icon="ix:success" class="size-16px" />
|
||||
已上传 PSSR 纸质确认表:上传即视为全部检查项完成确认(未做内容识别),原件已归档至变更档案
|
||||
</div>
|
||||
|
||||
<div v-for="(g, gi) in list" :key="g.g" class="border" :class="g.na_flag ? 'opacity-70' : ''" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700">
|
||||
{{ g.g }}
|
||||
<NTag v-if="g.na_flag" size="small" class="ml-2">不涉及</NTag>
|
||||
<div class="ml-auto flex items-center">
|
||||
<NButton v-if="g.items.length" text type="primary" class="text-xs font-normal hover:underline"
|
||||
:title="pssrNa[g.g] ? '恢复本组检查清单' : '本次变更不涉及本组内容时点击标记,标记后不计入完成统计'"
|
||||
@click="g.na_flag = g.na_flag ? 0 : 1">
|
||||
{{ g.na_flag ? '恢复清单' : '标记不涉及' }}
|
||||
</NButton>
|
||||
<NButton text type="primary" v-if="!g.na_flag" class="ml-3 text-xs font-normal hover:underline" @click="addItem(g,gi)">
|
||||
<Icon icon="ic:round-plus" class="size-14px" /> 添加检查项
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="g.na_flag" class="border-t bg-slate-50 px-3 py-3 text-xs text-slate-400">
|
||||
本组内容经评估本次变更不涉及(如:无现场安装作业 / 不改变保护措施 / 无需归档资料),已不计入 PSSR 完成统计;如需恢复检查清单,点击右上角「恢复清单」。
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="g.items.length===0" class="py-3 px-2 text-center text-slate-400 text-xs">暂无检查项</div>
|
||||
<div v-for="(it, i) in g.items" :key="`${gi}-${i}`" class="flex flex-wrap items-center gap-3 border-t px-3 py-2.5 text-sm">
|
||||
<span class="w-5 text-slate-400">{{ i + 1 }}</span>
|
||||
<NInput
|
||||
v-if="editing === `pssr-c-${gi}-${i}`"
|
||||
class="flex-1"
|
||||
v-model:value="it.content"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `pssr-c-${gi}-${i}`" title="点击编辑内容"
|
||||
class="min-w-0 flex-1 rounded-md px-1 text-left text-slate-800 transition hover:bg-[#EAF0F9] bg-transparent"
|
||||
>
|
||||
{{ it.content }}
|
||||
</button>
|
||||
<span class="flex items-center gap-1 py-1">
|
||||
<NRadio :checked="it.phase === 1" :value="1" label="投用前完成"
|
||||
@change="list = list.map((x, xi) => xi === gi ? { ...x, items: x.items.map((y, yi) => yi === i ? { ...y, phase: 1 } : y) } : x)" />
|
||||
<NRadio :checked="it.phase === 2" :value="2" label="关闭前完成"
|
||||
@change="list = list.map((x, xi) => xi === gi ? { ...x, items: x.items.map((y, yi) => yi === i ? { ...y, phase: 2 } : y) } : x)" />
|
||||
</span>
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="list = list.map((x, xi) => xi === gi ? { ...x, items: x.items.filter((_, yi) => yi !== i) } : x)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" title="删除该项">
|
||||
<Icon icon="iconamoon:trash" class="size-16px" />
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="rounded-lg bg-violet-50/70 px-4 py-2.5 text-xs text-violet-700">
|
||||
完成时点说明:「投用前完成 / 关闭前完成」为互斥选择,点击即可切换。投用前完成的项未确认时属地负责人无法确认投用;关闭前完成的项不阻塞投用,但纳入关闭校验清单,未完成时变更主管部门专责无法确认关闭。
|
||||
</div>
|
||||
<div class="rounded-lg bg-violet-50/70 px-4 py-2.5 text-xs text-violet-700">
|
||||
清单由 AI 依据变更类型与风险分析自动生成,专业人员审核后随审批发布;确认方式不作硬性要求——可直接点击确认;资料归档组确认后自动同步「变更关闭确认表」显示已归档。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
/>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 393px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
<!-- PSSR检查内容 -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import Footer from './footer.vue';
|
||||
import { AiError, aiPssr } from "@/service/api/ai";
|
||||
import { uploadFileApi } from "@/service/api/file";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps(['type','isAiOpen','title','currentId','formInfo','changeType'])
|
||||
const emit = defineEmits(['save','submit','confirmTab']);
|
||||
|
||||
const footerRef = ref<any>(null);
|
||||
const uploadLoading = ref<boolean>(false);
|
||||
const uploadFileRef = ref<any>(null);
|
||||
const isUploaded = ref<boolean>(false);
|
||||
const editing = ref<any>(null);
|
||||
|
||||
const aiFail = (e: any) => {
|
||||
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
|
||||
else window.$message?.warning("AI 分析失败,可手动填写");
|
||||
};
|
||||
|
||||
const list = ref<{ g: string; type:number; na_flag:number; items: any[] }[]>([
|
||||
{ g: "一、风险分析行动项落实", type:1, na_flag:0, items: [] },
|
||||
{ g: "二、现场设备安装检查确认", type:2, na_flag:0, items: [] },
|
||||
{ g: "三、保护措施确认", type:3, na_flag:0, items: [] },
|
||||
{ g: "四、资料归档确认", type:4, na_flag:0, items: [] },
|
||||
]);
|
||||
const pssrNa = ref<Record<string, boolean>>({});
|
||||
|
||||
// 重新生成 PSSR 检查内容(footer 派发):AI 输出按固定四组名称匹配回填
|
||||
const onRegenerate = async () => {
|
||||
const info = props.formInfo;
|
||||
if (!info?.description) {
|
||||
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
|
||||
}
|
||||
footerRef.value?.setRegenerateLoading(true);
|
||||
try {
|
||||
const res = await aiPssr({
|
||||
content: info.description,
|
||||
...(props.changeType ? { change_type: props.changeType } : {}),
|
||||
});
|
||||
const groups = (res?.groups ?? []).filter((g: any) => g && Array.isArray(g.items));
|
||||
if (!groups.length) {
|
||||
return window.$message?.warning("AI 未生成检查项,可手动添加");
|
||||
}
|
||||
const used = new Set<number>();
|
||||
list.value = list.value.map((g) => {
|
||||
// 优先按分组名匹配,兜底按剩余顺序对齐
|
||||
let gi = groups.findIndex((x: any, i: number) => !used.has(i) && (x.g === g.g || x.g.includes(g.g) || g.g.includes(x.g)));
|
||||
if (gi < 0) gi = groups.findIndex((_: any, i: number) => !used.has(i));
|
||||
if (gi < 0) return g;
|
||||
used.add(gi);
|
||||
return {
|
||||
...g,
|
||||
na_flag: 0,
|
||||
items: groups[gi].items.map((it: any) => ({
|
||||
id: 0,
|
||||
group_type: g.type,
|
||||
content: it.content ?? '',
|
||||
phase: it.phase_value ?? (it.phase === '关闭前' ? 2 : 1),
|
||||
na_flag: 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
window.$message?.success("PSSR 检查清单已由 AI 生成,点击条目可直接编辑");
|
||||
} catch (e: any) {
|
||||
aiFail(e);
|
||||
} finally {
|
||||
footerRef.value?.setRegenerateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加检查项
|
||||
const addItem = (g:any, gi: number) => {
|
||||
list.value[gi].items.push({id:0, group_type:g.type, content: '新检查项(点击直接编辑内容)', phase: 1, na_flag: 0 });
|
||||
}
|
||||
|
||||
// 上传纸质确认表
|
||||
const uploadFile = async (e: any) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('step', '5');
|
||||
uploadLoading.value = true;
|
||||
const {error} = await uploadFileApi(props.currentId, formData);
|
||||
if(!error){
|
||||
isUploaded.value = true;
|
||||
window.$message?.success(`已上传 PSSR 纸质确认表:不做内容识别,上传即视为全部检查项完成确认(「不涉及」组除外),原件归档至变更档案`);
|
||||
}
|
||||
uploadLoading.value = false;
|
||||
};
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// // 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
|
||||
defineExpose({list})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="tabler:clipboard-check" class="size-16px text-violet-500" />
|
||||
PSSR 投用前安全检查内容
|
||||
</span>
|
||||
<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>
|
||||
<span class="flex items-center gap-2">
|
||||
<NButton ghost size="small" :disabled="uploadLoading" class="text-xs" title="上传 PSSR 纸质确认表照片 / 扫描件(JPG / PNG / PDF),系统不做内容识别,上传即视为全部检查项完成确认" @click="uploadFileRef?.click()">
|
||||
<Icon v-if="uploadLoading" icon="ri:loader-4-fill" class="size-14px animate-spin" />
|
||||
<Icon v-else icon="material-symbols:upload" class="size-14px" />
|
||||
上传纸质确认表
|
||||
</NButton>
|
||||
<input ref="uploadFileRef" type="file" accept="image/*,.pdf" class="hidden" @change="uploadFile" />
|
||||
<span class="hidden text-xs text-slate-400 xl:inline">申请阶段:点击条目内容直接编辑,可增删项</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isUploaded" class="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2 text-xs text-emerald-700">
|
||||
<Icon icon="ix:success" class="size-16px" />
|
||||
已上传 PSSR 纸质确认表:上传即视为全部检查项完成确认(未做内容识别),原件已归档至变更档案
|
||||
</div>
|
||||
|
||||
<div v-for="(g, gi) in list" :key="g.g" class="border" :class="g.na_flag ? 'opacity-70' : ''" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700">
|
||||
{{ g.g }}
|
||||
<NTag v-if="g.na_flag" size="small" class="ml-2">不涉及</NTag>
|
||||
<div class="ml-auto flex items-center">
|
||||
<NButton v-if="g.items.length" text type="primary" class="text-xs font-normal hover:underline"
|
||||
:title="pssrNa[g.g] ? '恢复本组检查清单' : '本次变更不涉及本组内容时点击标记,标记后不计入完成统计'"
|
||||
@click="g.na_flag = g.na_flag ? 0 : 1">
|
||||
{{ g.na_flag ? '恢复清单' : '标记不涉及' }}
|
||||
</NButton>
|
||||
<NButton text type="primary" v-if="!g.na_flag" class="ml-3 text-xs font-normal hover:underline" @click="addItem(g,gi)">
|
||||
<Icon icon="ic:round-plus" class="size-14px" /> 添加检查项
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="g.na_flag" class="border-t bg-slate-50 px-3 py-3 text-xs text-slate-400">
|
||||
本组内容经评估本次变更不涉及(如:无现场安装作业 / 不改变保护措施 / 无需归档资料),已不计入 PSSR 完成统计;如需恢复检查清单,点击右上角「恢复清单」。
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="g.items.length===0" class="py-3 px-2 text-center text-slate-400 text-xs">暂无检查项</div>
|
||||
<div v-for="(it, i) in g.items" :key="`${gi}-${i}`" class="flex flex-wrap items-center gap-3 border-t px-3 py-2.5 text-sm">
|
||||
<span class="w-5 text-slate-400">{{ i + 1 }}</span>
|
||||
<NInput
|
||||
v-if="editing === `pssr-c-${gi}-${i}`"
|
||||
class="flex-1"
|
||||
v-model:value="it.content"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `pssr-c-${gi}-${i}`" title="点击编辑内容"
|
||||
class="min-w-0 flex-1 rounded-md px-1 text-left text-slate-800 transition hover:bg-[#EAF0F9] bg-transparent"
|
||||
>
|
||||
{{ it.content }}
|
||||
</button>
|
||||
<span class="flex items-center gap-1 py-1">
|
||||
<NRadio :checked="it.phase === 1" :value="1" label="投用前完成"
|
||||
@change="list = list.map((x, xi) => xi === gi ? { ...x, items: x.items.map((y, yi) => yi === i ? { ...y, phase: 1 } : y) } : x)" />
|
||||
<NRadio :checked="it.phase === 2" :value="2" label="关闭前完成"
|
||||
@change="list = list.map((x, xi) => xi === gi ? { ...x, items: x.items.map((y, yi) => yi === i ? { ...y, phase: 2 } : y) } : x)" />
|
||||
</span>
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="list = list.map((x, xi) => xi === gi ? { ...x, items: x.items.filter((_, yi) => yi !== i) } : x)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" title="删除该项">
|
||||
<Icon icon="iconamoon:trash" class="size-16px" />
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="rounded-lg bg-violet-50/70 px-4 py-2.5 text-xs text-violet-700">
|
||||
完成时点说明:「投用前完成 / 关闭前完成」为互斥选择,点击即可切换。投用前完成的项未确认时属地负责人无法确认投用;关闭前完成的项不阻塞投用,但纳入关闭校验清单,未完成时变更主管部门专责无法确认关闭。
|
||||
</div>
|
||||
<div class="rounded-lg bg-violet-50/70 px-4 py-2.5 text-xs text-violet-700">
|
||||
清单由 AI 依据变更类型与风险分析自动生成,专业人员审核后随审批发布;确认方式不作硬性要求——可直接点击确认;资料归档组确认后自动同步「变更关闭确认表」显示已归档。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
@regenerate="onRegenerate"
|
||||
/>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 393px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user