Files
moc/src/views/my/initiateChange/modules/changeApply.vue
T

860 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 变更申请表 -->
<script setup lang="ts">
import { ref, computed, h, watch } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { tagTextColor, tagBgColor, tagBorderColor } from '@/utils/common';
import { localStg } from '@/utils/storage';
import { useChangeStore } from '@/store/modules/change';
import { AiError, aiTypeJudge, aiLevelScore, aiApplication } from "@/service/api/ai";
import { uploadFileApi, deleteFileApi } from "@/service/api/file";
import Footer from './footer.vue'
import PDF from 'pdf-vue3'
import { VueOfficeDocx, VueOfficeExcel } from 'vue3-office-preview'
import 'vue3-office-preview/lib/style.css'
const themeStore = useThemeStore();
const changeStore = useChangeStore();
const props = defineProps(['type','orgTree','templateList','typeList','levelList','isAiOpen','title','currentId','formInfo', 'currentForm', 'confirmed'])
const emit = defineEmits(['save','submit','confirmTab','update:title']);
watch(() => props.currentForm, (newVal:any) => {
// 过滤出需要的字段
const {
approval_flow,
attachments,
change_id,
created_at,
id,
updated_at,
...rest
} = newVal;
form.value = rest;
})
const aiFail = (e: any) => {
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
else window.$message?.warning("AI 分析失败,可手动填写");
};
const uploadFileRef = ref<any>(null);
const footerRef = ref<any>(null);
const uploadLoading = ref<boolean>(false);
const specialties = ['生产','技术', '仪表', '电气', '设备', '安全', '质量'];
// 总分数
const totalScore = ref<number>(0);
// 当前等级
const currentLevel = computed(() => props.levelList.find((x:{value:number | null, label:string, color:string}) => x.value === form.value.change_level));
// 当前用户
const userInfo = ref<any>(localStg.get('userInfo'));
// 预览文件
const previewFile = ref<any>(null);
const sheet = ref<string>('');
// 上传文件
const uploadFile = ref<{id:number, file_name:string, file_url:string, uploader_name:string, file_type:string, created_at:string, version:string | null}[]>([]);
// 表单
const form = ref<{
change_type: number | null,
change_level: any,
duration_type: number | null,
restore_deadline: number | null,
org_id: number | null,
urgent: number,
purposes: string[],
effect: string,
main_changes: string[],
related_changes: string[],
materials: string,
update_docs: string[],
disciplines: string[],
plan_use_date: number | null,
risk_tools: string[],
}>({
change_type: null,
change_level: computed(() => totalScore.value >= 20 ? 2 : 1),
duration_type: null,
restore_deadline: null,
org_id: null,
urgent: 0,
purposes: [],
effect: '',
main_changes: [''],
related_changes: [''],
materials: '',
update_docs: [],
disciplines: [],
plan_use_date: null,
risk_tools: [],
});
// 变更目的选项
const PURPOSE_OPTIONS = [
"满足市场需求", "提高生产效能", "改善操作条件", "增加安全性能",
"达到环保要求", "法律合规整改", "减少能耗物损", "稳定系统运行",
"完善管理制度", "达到质量改进", "设备可靠性升级",
];
// 需更新的资料选项
const UPDATE_DOC_OPTIONS = ['PID图纸', '操作规程', '总图', '设备台账', '工艺卡片', '联锁台账', '应急预案'];
// 风险分析选项
const RISK_TOOLS_FORM = [
{ key: "HAZOP", label: "HAZOP" },
{ key: "JSA", label: "JSA" },
{ key: "SCL", label: "设备SCL" },
{ key: "RISK_CHECK", label: "风险检查表" },
];
const questions = ref<any>([
{
question_id: 1,
name: "1. 设备影响",
note: "不合适的材质将会导致",
score: 0,
options: [
{ label: "对设备带来一定的损害及腐蚀", score: 1 },
{ label: "对设备带来中等的损害及腐蚀", score: 2 },
{ label: "对设备带来明显的损害及腐蚀", score: 3 },
{ label: "对设备带来重大的损害及腐蚀", score: 4 },
],
},
{
question_id: 2,
name: "2. 财务影响",
note: "工艺设计错误将会导致",
score: 0,
options: [
{ label: "一定损失,a<1万人民币", score: 1 },
{ label: "中等损失,直接损失1万≤a<10万人民币", score: 2 },
{ label: "大量损失,直接损失10万≤a<100万人民币", score: 3 },
{ label: "巨额损失,直接损失100万人民币≤a", score: 4 },
],
},
{
question_id: 3,
name: "3. 质量影响",
note: "",
score: 0,
options: [
{ label: "对质量带来一定的风险(非关键工席、非关键设备设施的变更)", score: 1 },
{ label: "对质量带来中等风险(B类原料替代、生产辅助设备变更)", score: 2 },
{ label: "对质量带来明显的风险(主要工艺参数、中间体生产工艺变更)", score: 3 },
{ label: "对质量有很高风险(主要工艺路线、关键工艺参数变更)", score: 4 },
],
},
{
question_id: 4,
name: "4. 设计的复杂程度",
note: "工艺设计包含了",
score: 0,
options: [
{ label: "少量的简单系统(单个设备或单个部件)", score: 1 },
{ label: "大量的简单系统(多个单类设备或部件)", score: 2 },
{ label: "至少一个复杂系统(涉及多设备/工艺相应调整)", score: 3 },
{ label: "大量的复杂系统(需要装置配套改造调整)", score: 4 },
],
},
{
question_id: 5,
name: "5. 工艺设计的成熟度",
note: "",
score: 0,
options: [
{ label: "被频繁使用,且被证明为专利方设计", score: 1 },
{ label: "被少量使用,或被证明为专利方设计", score: 2 },
{ label: "被相关工业设计方法或参考文献认可(非专利方)", score: 3 },
{ label: "新的,未发表的", score: 4 },
],
},
{
question_id: 6,
name: "6. 工艺危害",
note: "物料性质和工艺条件",
score: 0,
options: [
{ label: "无危害(常压、-10℃≤温度<40℃、无毒、无腐蚀不燃)", score: 1 },
{ label: "可燃/负压/低正压(0~0.3MPa)、40℃≤温度<80℃", score: 2 },
{ label: "易燃/中压(0.3~1.0MPa)、80℃≤温度<150℃", score: 3 },
{ label: "极度危害/高压(>1.0MPa)、温度≥150℃", score: 4 },
],
},
]);
// 筛选涉及专业的人员和部门
const filterMembersBySpecialties = (data: any, specialties: string[]) => {
const result: any[] = [];
data.steps.forEach((step: any) => {
step.members.forEach((member: any) => {
if (member.specialty && specialties.includes(member.specialty)) {
result.push({
stepName: step.name, // 上级部门名称
member: member // 成员对象
});
}
});
});
return result;
}
// 当前模板
const currentTemplate = computed(() => {
let template = props.templateList.find((item: any) =>
item.level === form.value?.change_level &&
item.category === form.value?.change_type &&
item.duration === form.value?.duration_type
) || null;
footerRef.value?.clearApprovers();
changeStore.setStepsInfo(template?template.steps:[]);
return template?template:null;
});
// 联动专业会签人员
const signers = computed(() => {
if(currentTemplate.value){
const matchedMembers = filterMembersBySpecialties(currentTemplate.value, form.value.disciplines);
return matchedMembers
}
return null
})
// 按等级推荐
const recommendTools = () => {
const lv = totalScore.value >= 20 ? "重要" : "一般";
form.value.risk_tools = totalScore.value >= 20 ? ["HAZOP"] : ["RISK_CHECK"];
window.$message?.success(`已按「${lv}变更」规则匹配风险分析工具,可继续手动重选`);
};
// 风险分析插件选择
const toggleTool = (t: {key:string,label:string}) => {
const { risk_tools } = form.value;
form.value.risk_tools = risk_tools.includes(t.key) ? risk_tools.filter((key:string) => key !== t.key) : [...risk_tools, t.key];
};
// 变更类型判断
const typeAi = ref<{ type: string; confidence: number; conclusion: string; hit_rules: string[]; exclude_rules: string[]; knowledge_refs: string[] } | null>(null);
const typeAiLoading = ref<boolean>(false);
// 打开变更类型抽屉
const openTypeSheet = async () => {
let form = props.formInfo;
if (!form?.description) {
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
}
sheet.value = "type";
if (typeAiLoading.value) return;
typeAiLoading.value = true;
try {
const res = await aiTypeJudge(form.description, (form.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
typeAi.value = {
type: res?.type ?? "",
confidence: res?.confidence ?? 0,
conclusion: res?.conclusion ?? "",
hit_rules: res?.hit_rules ?? [],
exclude_rules: res?.exclude_rules ?? [],
knowledge_refs: res?.knowledge_refs ?? [],
};
} catch (e: any) {
aiFail(e);
} finally {
typeAiLoading.value = false;
}
};
// 变更等级判断
const levelAiText = ref<string>("");
const levelAiBasis = ref<string[]>([]);
const levelAiLoading = ref<boolean>(false);
// 打开等级判定表抽屉
const openLevelSheet = async () => {
let form = props.formInfo;
if (!form?.description && props.isAiOpen) {
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
}
sheet.value = "level";
if (levelAiLoading.value) return;
};
// 使用AI 评分
const useAiLevel = async () => {
let form = props.formInfo;
if (levelAiLoading.value) return;
levelAiLoading.value = true;
try {
const dims = questions.value.map((d:any, i:any) => ({ dim: `DIM_${i + 1}`, name: d.name, max: Math.max(...d.options.map((o:any) => o.score)) }));
const res = await aiLevelScore(form.description, (form.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })), dims);
// 重新评分前清零,避免多次点击分数累加
totalScore.value = 0;
levelAiBasis.value = [];
res?.dims?.forEach((d, i) => {
totalScore.value += d.score ?? 0;
questions.value[i].score = d.score ?? 0;
levelAiBasis.value[i] = d.basis ?? "";
});
levelAiText.value = res?.analysis_text ?? "";
} catch (e: any) {
aiFail(e);
} finally {
levelAiLoading.value = false;
}
}
// AI 申请单结果回填(生成变更申请单 / 重新生成变更申请表共用)
const applyAiResult = (res: any) => {
if (!res) return;
const f = form.value;
const main = (res.main_changes ?? []).filter((x: string) => x && x.trim());
if (main.length) f.main_changes = main;
const related = (res.related_changes ?? []).filter((x: string) => x && x.trim());
if (related.length) f.related_changes = related;
const purposes = (res.purposes ?? []).filter((x: string) => PURPOSE_OPTIONS.includes(x));
if (purposes.length) f.purposes = purposes;
if (res.effect) f.effect = res.effect;
// 变更类型:AI 输出 MOC 中文编码 → typeList 数值
if (res.change_type) {
const t = props.typeList.find((x: any) => x.label === res.change_type || x.label.includes(res.change_type) || res.change_type.includes(x.label));
if (t) f.change_type = t.value;
}
// 变更时限:永久→1,临时→2
if (res.duration_suggestion === '永久') f.duration_type = 1;
else if (res.duration_suggestion === '临时') f.duration_type = 2;
if (res.materials_text) f.materials = res.materials_text;
const docs = (res.update_docs ?? []).filter((x: string) => UPDATE_DOC_OPTIONS.includes(x));
if (docs.length) f.update_docs = docs;
const discs = (res.disciplines ?? []).filter((x: string) => specialties.includes(x));
if (discs.length) f.disciplines = discs;
const aiTools = (res.risk_tools ?? [])
.map((x: string) => RISK_TOOLS_FORM.find((t) => t.key === x || t.label === x)?.key)
.filter((x: string | undefined): x is string => !!x);
if (aiTools.length) f.risk_tools = aiTools;
}
// 重新生成变更申请表(footer 派发)
const onRegenerate = async () => {
const info = props.formInfo;
if (!info?.description) {
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
}
footerRef.value?.setRegenerateLoading(true);
try {
const res = await aiApplication(info.description, (info.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
applyAiResult(res);
if (res?.name) emit('update:title', res.name);
window.$message?.success("变更申请表已由 AI 重新生成,内容均可手动修改");
} catch (e: any) {
aiFail(e);
} finally {
footerRef.value?.setRegenerateLoading(false);
}
}
// 处理等级判定表评分变化
const handleScoreChange = (val:number,index:number) => {
questions.value[index].score = val;
let score = 0;
questions.value.forEach((d:any) => {
score += d.score;
});
totalScore.value = score;
}
// 下载PDF
const downloadPdf = () => window.$message?.success(`演示:${previewFile.value} 已下载`);
// 自定义渲染展开图标
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 handleFileType = (type:string) => {
if(type){
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
const wordExtensions = ['docx']
const excelExtensions = ['xlsx']
if(wordExtensions.includes(type)){
return 'word'
}
if(excelExtensions.includes(type)){
return 'excel'
}
if(imageExtensions.includes(type)){
return 'image'
}
if(type==='pdf'){
return 'pdf'
}
}
return ''
}
// 上传资料
const onUploadFile = async (e:any) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('step', '2');
uploadLoading.value = true;
const {data,error} = await uploadFileApi(props.currentId, formData);
if(!error){
window.$message?.success('上传成功');
uploadFile.value = [...uploadFile.value, data];
}
uploadLoading.value = false;
}
// 删除资料
const removeFile = async (index:number) => {
const {error} = await deleteFileApi(props.currentId, uploadFile.value[index].id);
if(!error){
window.$message?.success('删除成功');
uploadFile.value.splice(index, 1)
}
}
// 保存草稿
const saveDraft = () => {
emit('save');
}
// 提交
const submitConfirm = () => {
emit('submit', props.type);
}
// // 确认标签内容
const confirmTab = (tab:string, val:boolean) => {
emit('confirmTab', tab, val);
}
defineExpose({form, questions, applyAiResult, generateLevel: useAiLevel})
</script>
<template>
<div class="p-4">
<div class="space-y-4 scroll">
<!-- 申请部门 / 申请人 -->
<div class="grid gap-4 grid-cols-2">
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">申请部门 <span class="text-red-500">*</span></div>
<NTreeSelect
v-model:value="form.org_id"
default-expand-all
label-field="label"
key-field="id"
:options="props.orgTree"
:render-switcher-icon="renderSwitcherIcon"
placeholder="请选择申请部门"
/>
</div>
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">申请人</div>
<NInput :value="userInfo?.real_name" disabled />
</div>
</div>
<!-- 变更目的 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">变更目的</div>
<div class="rounded-lg bg-[#EAF0F9]/40 p-3">
<NCheckboxGroup v-model:value="form.purposes">
<div class="flex flex-wrap gap-y-2 gap-x-4">
<NCheckbox v-for="p in PURPOSE_OPTIONS" :key="p" :value="p" :label="p" />
</div>
</NCheckboxGroup>
</div>
</div>
<!-- 预期效果 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">预期效果</div>
<NInput type="textarea" v-model:value="form.effect" rows="2" placeholder="" />
</div>
<!-- 主要变更内容 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">主要变更内容 <span class="text-red-500">*</span></div>
<div class="flex-1 space-y-2">
<div v-for="(_, i) in form.main_changes" :key="i" class="flex items-center gap-2">
<span class="w-5 shrink-0 text-sm text-slate-400">{{ i + 1 }}.</span>
<NInput v-model:value="form.main_changes[i]" type="text" placeholder="" />
<NPopconfirm
v-if="form.main_changes.length > 1"
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="form.main_changes.splice(i, 1)"
>
<template #trigger>
<NButton text type="error">
<Icon icon="ci:close-circle" class="size-16px" />
</NButton>
</template>
确定删除吗
</NPopconfirm>
</div>
<NButton text type="primary" class="text-xs" @click="form.main_changes = [...form.main_changes, '']">
<Icon icon="ic:round-plus" class="size-14px" /> 添加变更点
</NButton>
</div>
</div>
<!-- 连带变更内容 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600">连带变更内容</div>
<div class="flex-1 space-y-2">
<div v-for="(_, i) in form.related_changes" :key="i" class="flex items-center gap-2">
<span class="w-5 shrink-0 text-sm text-slate-400">{{ i + 1 }}.</span>
<NInput v-model:value="form.related_changes[i]" placeholder="连带变更点(如:更新操作规程)" />
<NPopconfirm
v-if="form.related_changes.length > 1"
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="form.related_changes.splice(i, 1)"
>
<template #trigger>
<NButton text type="error">
<Icon icon="ci:close-circle" class="size-16px" />
</NButton>
</template>
确定删除吗
</NPopconfirm>
</div>
<NButton text type="primary" class="text-xs" @click="form.related_changes = [...form.related_changes, '']">
<Icon icon="ic:round-plus" class="size-14px" /> 添加连带变更点
</NButton>
</div>
</div>
<!-- 变更类型 / 变更等级 -->
<div class="grid gap-4 grid-cols-2">
<!-- 变更类型 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">变更类型 <span class="text-red-500">*</span></div>
<div class="flex flex-1 items-center gap-4 rounded-lg bg-slate-50 px-3 py-2">
<NRadioGroup v-model:value="form.change_type">
<div class="flex gap-x-2">
<NRadio v-for="t in props.typeList" :key="t.value" :value="t.value">{{t.label}}</NRadio>
</div>
</NRadioGroup>
<NButton v-if="props.isAiOpen" text type="primary" class="ml-auto text-xs hover:underline" @click="openTypeSheet">
<Icon icon="lucide:sparkles" class="size-12px" />
AI 判定说明
</NButton>
</div>
</div>
<!-- 变更等级 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">变更等级 <span class="text-red-500">*</span></div>
<div class="flex flex-1 items-center gap-4 rounded-lg bg-slate-50 px-3 py-2">
<NRadioGroup v-model:value="form.change_level" disabled>
<div class="flex gap-x-2">
<NRadio v-for="t in props.levelList" :key="t.value" :value="t.value">{{t.label}}</NRadio>
</div>
</NRadioGroup>
<NButton text type="primary" class="ml-auto text-xs hover:underline" @click="openLevelSheet">
<Icon icon="lucide:list-checks" class="size-12px" /> 等级判定表 ({{ totalScore }})
</NButton>
</div>
</div>
</div>
<!-- 变更时限 / 风险分析 -->
<div class="grid gap-4 grid-cols-2">
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">变更时限 <span class="text-red-500">*</span></div>
<div class="flex flex-1 items-center gap-4 rounded-lg bg-slate-50 px-3 py-2">
<NRadioGroup v-model:value="form.duration_type">
<div class="flex gap-x-2">
<NRadio v-for="t in [1, 2]" :key="t" :value="t">
{{ t === 1 ? '永久' : '临时' }}
</NRadio>
</div>
</NRadioGroup>
<NCheckbox v-model:checked="form.urgent" :checked-value="1" :unchecked-value="0">紧急</NCheckbox>
</div>
</div>
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">风险分析</div>
<div class="flex flex-1 flex-wrap items-center gap-2 rounded-lg bg-slate-50 px-3 py-2">
<NButton size="small" round v-for="t in RISK_TOOLS_FORM" :key="t.key" @click="toggleTool(t)" title="点击选用 / 取消;AI 辅助分析在各工具插件内使用"
class="text-sm"
:ghost="form.risk_tools.includes(t.key) ? false : true"
:type="form.risk_tools.includes(t.key) ? 'primary' : 'default'"
>
{{ t.label }}
</NButton>
<NButton type="primary" ghost dashed size="small" round @click="recommendTools" class="text-xs">
按等级重新推荐
</NButton>
</div>
</div>
</div>
<div v-if="form.urgent" class="text-xs text-red-500 ml-27">紧急变更为单独标记走快速通道事后限期补办风险分析与完整审批</div>
<!-- 计划投用时间 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">计划投用时间</div>
<div class="flex flex-1 flex-wrap items-center gap-2">
<NDatePicker v-model:value="form.plan_use_date" type="date" value-format="yyyy-MM-dd" class="!max-w-[220px]" />
<!-- 临时 -->
<template v-if="form.duration_type === 2">
<span class="whitespace-nowrap text-sm text-slate-500">计划恢复时间 <span class="text-red-500">*</span></span>
<NDatePicker v-model:value="form.restore_deadline" type="date" value-format="yyyy-MM-dd" class="!max-w-[220px]" />
<span class="text-xs text-amber-600">临时变更到期系统自动提醒恢复原状</span>
</template>
<!-- 永久 -->
<span v-else class="whitespace-nowrap text-xs text-slate-300">勾选临时后在此并排填写计划恢复时间</span>
</div>
</div>
<!-- 所需材料 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">所需材料</div>
<div class="flex-1 space-y-2">
<div class="text-[11px] text-slate-400">施工所需材料生成变更申请表时系统自动生成支持手动修改</div>
<NInput type="textarea" v-model:value="form.materials" rows="4" placeholder="点击「生成变更申请单」后由系统按变更内容自动生成,也可直接填写" />
</div>
</div>
<!-- 资料上传 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">资料上传</div>
<div class="flex flex-1 flex-wrap items-center gap-2 rounded-lg border border-dashed p-3">
<div
class="relative"
:style="{'--theme-color':themeStore.themeColor}"
v-for="(f,index) in uploadFile" :key="f.id"
>
<div
class="text-xs cursor-pointer flex items-center border py-1.5 px-2 rounded-md hover:border-[var(--theme-color)] hover:text-[var(--theme-color)]"
title="点击在线预览"
@click="previewFile = f"
>
<Icon icon="akar-icons:file" class="size-14px mr-1" />{{ f.file_name }}
</div>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="removeFile(index)"
>
<template #trigger>
<NButton text type="error" class="absolute top-[-8px] right-[-8px] bg-[#fff]">
<Icon icon="ci:close-circle" class="size-16px" />
</NButton>
</template>
确定删除吗
</NPopconfirm>
</div>
<NButton :disabled="uploadLoading" @click="uploadFileRef?.click()">
<Icon v-if="uploadLoading" icon="ri:loader-4-fill" class="size-16px animate-spin" />
<Icon v-else icon="material-symbols:upload" class="size-16px" />
资料上传
</NButton>
<input ref="uploadFileRef" type="file" accept="image/*,.pdf,.docx,.xlsx" class="hidden" @change="onUploadFile" />
<span class="text-xs text-slate-400">点击附件名弹窗预览支持 PDF / Word / 图片在线查看</span>
</div>
</div>
<!-- 需更新的资料 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600">需更新的资料</div>
<div class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg bg-slate-50 px-3 py-2">
<NCheckboxGroup v-model:value="form.update_docs">
<div class="flex flex-wrap gap-y-2 gap-x-4">
<NCheckbox v-for="p in UPDATE_DOC_OPTIONS" :key="p" :value="p" :label="p" />
</div>
</NCheckboxGroup>
<span class="self-center text-xs text-slate-400">多选变更关闭前逐项确认上传最新版本</span>
</div>
</div>
<!-- 涉及专业 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600">涉及专业</div>
<div class="flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg bg-slate-50 px-3 py-2">
<NCheckboxGroup v-model:value="form.disciplines">
<div class="flex flex-wrap gap-y-2 gap-x-4">
<NCheckbox v-for="p in specialties" :key="p" :value="p" :label="p" />
</div>
</NCheckboxGroup>
<span class="self-center text-xs text-slate-400">多选决定审批流中专业会签/审批的路由范围</span>
</div>
<div v-if="signers && signers.length" class="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs text-slate-500">
<Icon icon="lucide:users" class="size-12px" />已联动专业会签
<NTag size="small" type="info" v-for="(p, d) in signers" :key="d">{{ p?.member?.specialty }} · {{ p?.member?.user_name }}{{p?.stepName ? ` (${p.stepName})` : ''}}</NTag>
</div>
</div>
</div>
</div>
</div>
<Footer
ref="footerRef"
:type="props.type"
:currentId="props.currentId"
:title="props.title"
:isAiOpen="props.isAiOpen"
:currentForm="form"
:confirmed="props.confirmed"
@save="saveDraft"
@submit="submitConfirm"
@confirmTab="confirmTab"
@regenerate="onRegenerate"
/>
<!-- 侧边抽屉AI 类型判定 / 等级判定表 -->
<NDrawer :show="sheet !== ''" @update:show="(v) => !v && (sheet = '')" placement="right" :width="sheet === 'level' ? '45rem' : '28rem'">
<NDrawerContent>
<template #header>
<div v-if="sheet === 'type'" class="text-base font-semibold text-slate-800">AI 变更类型判定说明</div>
<div v-if="sheet === 'level'" class="text-base font-semibold text-slate-800">变更等级判定表</div>
</template>
<div class="h-full space-y-4 overflow-y-auto">
<!-- AI 变更类型判定说明 -->
<template v-if="sheet === 'type'">
<NTag v-if="typeAiLoading" type="info" size="small">
<template #icon>
<Icon icon="ri:loader-4-fill" class="size-12px animate-spin" />
</template>
AI 判定中
</NTag>
<div v-else class="flex items-center gap-2 max-h-[22px]">
<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>
<NTag type="info" size="small">判定{{ typeAi?.type || '工艺' }}</NTag>
<NTag size="small">置信度{{ typeAi ? `${Math.round(typeAi.confidence * 100)}%` : '高' }}</NTag>
</div>
<div v-if="typeAi" class="space-y-3 text-sm leading-relaxed text-slate-700">
<p><b>判定结论</b>{{ typeAi.conclusion }}</p>
<p><b>判定依据</b></p>
<ul class="list-disc space-y-1 pl-5">
<li v-for="r in typeAi.hit_rules" :key="r">命中规则{{ r }}</li>
<li v-for="r in typeAi.exclude_rules" :key="r">排除规则{{ r }}</li>
</ul>
<p v-if="typeAi.knowledge_refs.length"><b>知识库引用</b>{{ typeAi.knowledge_refs.join('、') }}</p>
<p class="rounded-lg bg-amber-50 p-3 text-xs text-amber-700">AI 判定结果仅供参考最终类型以人工确认为准如调整为其他类型系统将记录修改留痕</p>
</div>
</template>
<!-- 变更等级判定表 -->
<template v-if="sheet === 'level'">
<div class="flex items-center gap-2">
<NButton v-if="props.isAiOpen" size="small" type="primary">
<div v-if="levelAiLoading" class="flex items-center gap-1 text-xs">
<Icon icon="ri:loader-4-fill" class="size-14px animate-spin" />AI 评分中
</div>
<div v-else class="flex items-center gap-1 text-xs" @click="useAiLevel">
使用AI 评分
</div>
</NButton>
<div class="flex items-center gap-2">
<NTag
v-if="currentLevel"
size="small"
:color="{
color: tagBgColor(currentLevel?.color),
textColor: tagTextColor(currentLevel?.color),
borderColor: tagBorderColor(currentLevel?.color),
}"
>
{{ currentLevel?.label ? currentLevel.label+'变更' : '' }}
</NTag>
<span class="text-xs text-slate-400">合计得分 <b class="text-[#17407F]">{{ totalScore }}</b> &lt;20 分为一般,≥20 分为重要</span>
</div>
</div>
<div class="grid gap-3 grid-cols-2">
<div v-for="(dim, di) in questions" :key="dim.name" class="rounded-lg border p-3">
<div class="mb-2 flex items-baseline justify-between gap-2">
<span class="text-sm font-semibold text-slate-700">
{{ dim.name }}
<span v-if="dim.note" class="ml-1 text-xs font-normal text-slate-400">{{ dim.note }}</span>
</span>
<span class="text-sm text-slate-500">得分 <b class="text-[#17407F]">{{ questions[di].score }}</b></span>
</div>
<div class="space-y-1.5">
<NRadioGroup v-model:value="questions[di].score" size="small" :disabled="levelAiLoading" name="radiogroup" :on-update:value="(v)=>handleScoreChange(v,di)">
<div class="flex flex-col justify-center gap-2">
<NRadio v-for="op in dim.options" :key="op.score" :value="op.score">
<p class="text-xs">{{ op.label }}{{ op.score }}</p>
</NRadio>
</div>
</NRadioGroup>
</div>
</div>
</div>
<div v-if="props.isAiOpen && (levelAiText || levelAiBasis.some((x) => x))" class="rounded-lg border border-violet-200 bg-violet-50/50 p-4">
<div class="flex items-center gap-2 text-sm font-semibold text-violet-700">
<Icon icon="lucide:sparkles" class="size-14px" />
AI 评分分析过程
<span v-if="levelAiLoading" class="flex items-center gap-1 text-xs font-normal text-violet-500">
<Icon icon="ri:loader-4-fill" class="size-12px animate-spin" /> AI 评分中
</span>
</div>
<div class="mt-2 space-y-3 text-xs leading-relaxed text-slate-600">
<p v-if="levelAiText">{{ levelAiText }}</p>
<p v-for="(dim, di) in questions" :key="dim.name">
<template v-if="levelAiBasis[di]"><b class="text-[#17407F]">{{ dim.name }}</b>{{ levelAiBasis[di] }}</template>
</p>
<p class="text-slate-400">各维度得分均可人工改判修改留痕改判后等级自动回填至申请表</p>
</div>
</div>
</template>
</div>
</NDrawerContent>
</NDrawer>
<!-- 附件在线预览弹窗 -->
<NModal
:show="previewFile !== null"
preset="card"
:auto-focus="false"
:style="{ width: '1100px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
@update:show="(v) => !v && (previewFile = null)"
>
<template #header>
<div class="flex items-center gap-2 text-base font-semibold text-slate-800">
<Icon icon="akar-icons:file" class="size-18px" /> {{ previewFile?.file_name }}
</div>
</template>
<template #default>
<div class="space-y-3">
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
<span>上传人{{ previewFile?.uploader_name }}</span>
<span>上传时间{{ previewFile?.created_at }}</span>
<span v-if="previewFile?.version">{{ `版本${previewFile?.version}受控` }}</span>
</div>
<div v-if="previewFile" class="max-h-[70vh] overflow-x-auto rounded-lg border">
<!-- 图片预览 -->
<template v-if="handleFileType(previewFile?.file_type) === 'image'">
<div class="flex items-center justify-center py-2">
<NImage :src="previewFile?.file_url" />
</div>
</template>
<!-- Word 预览 -->
<template v-if="handleFileType(previewFile?.file_type) === 'word'">
<VueOfficeDocx :src="previewFile?.file_url" class="w-full" />
</template>
<!-- Excel 预览 -->
<template v-if="handleFileType(previewFile?.file_type) === 'excel'">
<VueOfficeExcel :src="previewFile?.file_url" class="w-full" />
</template>
<!-- PDF 预览 -->
<template v-if="handleFileType(previewFile?.file_type) === 'pdf'">
<div class="h-[68vh]">
<PDF :src="previewFile?.file_url" class="w-full h-full" />
</div>
</template>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-[10px]">
<NButton ghost size="small" @click="downloadPdf">
<Icon icon="material-symbols:download" class="size-16px" /> 下载
</NButton>
<NButton size="small" type="primary" @click="previewFile = null">关闭</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
:deep(.n-radio) {
display: flex;
align-items: center;
}
.scroll {
height: calc(100vh - 395px);
overflow-y: auto;
}
</style>