860 lines
36 KiB
Vue
860 lines
36 KiB
Vue
<!-- 变更申请表 -->
|
||
<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> 分(<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> |