Files
moc/src/views/details/disposal/index.vue
T

1266 lines
66 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, onMounted, computed } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import { useRoute } from 'vue-router';
import { tagTextColor, tagBgColor, tagBorderColor, formatTimestamp, downloadFileFun } from '@/utils/common';
import { AiError, aiTypeJudge } from "@/service/api/ai";
import { systemConfigApi, peopleListApi, templateListApi } from '@/service/api/system';
import { getChangeDetailApi, urgeApi } from '@/service/api/change';
import { myTasksDisposalSubmitApi, myTasksAddItemApi } from '@/service/api/myTasks';
import previewComponent from '@/components/previewFile/index.vue'
const themeStore = useThemeStore();
const { routerPushByKey } = useRouterPush();
const route = useRoute();
const loading = ref<boolean>(false);
// 所有人员列表
const memberList = ref<any[]>([]);
// 判定说明
const typeAiLoading = ref<boolean>(false);
const typeAi = ref<{ type: string; confidence: number; conclusion: string; hit_rules: string[]; exclude_rules: string[]; knowledge_refs: string[] } | null>(null);
// 当前变更详情
const currentInfo = ref<any>(null);
// 当前变更id
const currentId = ref<number>(0);
// 是否开启 AI 辅助功能
const isAiOn = ref<boolean>(false);
// 变更等级选项
const levelList = ref<any>([]);
// 变更类型选项
const typeList = ref<any>([]);
// 变更等级
const level = computed(() => {
return currentInfo.value?.apply_form?.change_level;
});
// 处置申请类型 unExecute:未实施;unUse:未投用;recover:申请恢复;delay:申请延期;transform:申请转为永久
const disposalType = ref<string>('');
// 情况说明
const description = ref<string>('');
// 影响判定
const judge = ref<string>('');
// 延期时间
const delayDate = ref<number | null>(null);
// 审批流程抽屉
const approvalDrawer = ref<boolean>(false);
const flowList = ref<any[]>([]);
const steps = ref<any[]>([]);
// 处置申请模板
const templateList = ref<any[]>([]);
// 专业
const SPECIALTIES = [
{ label: '生产', value: '生产' },
{ label: '技术', value: '技术' },
{ label: '仪表', value: '仪表' },
{ label: '电气', value: '电气' },
{ label: '设备', value: '设备' },
{ label: '安全', value: '安全' },
{ label: '质量', value: '质量' }
];
// 审批链
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 totalScore = computed(() => {
return currentInfo.value?.apply_form?.level_score?.reduce((sum: number, item: any) => {
const n = Number(item.score);
return sum + (Number.isNaN(n) ? 0 : n);
}, 0);
});
// 当前审批所有人员
const allMembers = computed(() => {
return currentInfo.value?.approval_flow?.steps?.map((item: any) => item.members).flat();
});
// pssr 检查内容
const pssrList = ref<{ g: string; type:number; na_flag:number; items: any[] }[]>([]);
// pssr总条数
const totalPssr = computed(() => {
return pssrList.value.flatMap((group:any) => group.items || []).length;
});
// pssr已确认条数
const confirmedPssr = computed(() => {
return pssrList.value.flatMap((group:any) => group.items || []).filter((item:any) => item.confirm_flag).length;
});
// 资料关闭确认列表
const closeConfirmList = ref<any>([]);
// 验收评价
const acceptList = ref<any>([]);
// 当前预览文件
const previewFile = ref<any>(null);
// 预览文件弹框
const previewModal = ref<boolean>(false);
// 等级判断问题
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 describeDrawer = ref<boolean>(false);
const levelDrawer = ref<boolean>(false);
const aiFail = (e: any) => {
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
else window.$message?.warning("AI 分析失败,可手动填写");
};
// 打开变更类型抽屉
const openTypeSheet = async () => {
typeAi.value = null;
describeDrawer.value = true;
if (typeAiLoading.value) return;
typeAiLoading.value = true;
try {
const res = await aiTypeJudge(currentInfo.value?.precheck?.description, (currentInfo.value?.precheck?.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 back = () => {
routerPushByKey('my_mytask');
};
// 导出
const exportFile = (type: string) => {
window.$message?.info('差接口')
// if(type === 'change'){
// window.$message?.success('导出变更申请表成功');
// }
// if(type === 'risk'){
// window.$message?.success('导出风险检查行动项表成功');
// }
};
// 变更附带资料下载/一键下载
const downloadFile = (file: any) => {
if(file){
downloadFileFun(file?.file_url, file?.doc_name || file?.file_name);
}else{
window.$message?.info('差接口')
}
};
// 变更附带资料预览
const openPreview = (file:any) => {
previewFile.value = file;
previewModal.value = true;
}
// 催办
const handleReminder = async (m: any) => {
if(loading.value) return;
loading.value = true;
const {error} = await urgeApi(currentId.value);
if(!error){
window.$message?.success('催办成功');
}
loading.value = false;
}
// 完成设置
const finishApprover = async () => {
steps.value = flowList.value || [];
window.$message?.success("审批流程设置完成");
approvalDrawer.value = false;
}
// 添加检查项
const addItem = (index: number) => {
pssrList.value[index].items.push({
id: 0,
content: "",
group_type: pssrList.value[index].type,
na_flag: 0,
phase: 1,
confirm_flag: 0,
})
}
// 保存检查项
const saveItem = async (index: number, subIndex: number) => {
const item = pssrList.value[index].items[subIndex];
if(item?.content?.trim() === ''){
return window.$message?.warning('请填写检查项内容');
}
const {data,error} = await myTasksAddItemApi(currentId.value,{
content: item?.content,
group_type: item?.group_type,
na_flag: item?.na_flag,
phase: item?.phase,
confirm_flag: item?.confirm_flag,
})
if(!error){
pssrList.value[index].items[subIndex].id = data.id
window.$message?.success('保存成功');
}
}
// 匹配评分依据
const matchBasis = (items:any,score: number) => {
return items.find((item:any) => item.score === score)?.label || '';
}
// 匹配label
const matchLabel = (list:any,v: number) => {
return list.find((item:any) => item.value === v) || null;
}
// 保存/提交处置申请
const submitDisposal = async (type: string) => {
let txt = ''
// 类型
let actType = 0;
// 未实施
if(disposalType.value==='unExecute'){
actType = 4;
txt = '请填写情况说明';
}
// 未投用
if(disposalType.value==='unUse'){
actType = 5;
txt = '请填写情况说明';
}
// 申请恢复
if(disposalType.value==='recover'){
actType = 1;
txt = '请填写申请恢复原因';
}
// 申请延期
if(disposalType.value==='delay'){
actType = 2;
txt = '请填写变更延期原因';
}
// 申请转永久
if(disposalType.value==='transform'){
actType = 3;
txt = '请填写申请转永久原因';
}
if(description.value?.trim() === ''){
return window.$message?.warning(txt);
}
if(disposalType.value==='delay' && !delayDate.value){
return window.$message?.warning('请选择变更延期时间');
}
// 去掉name
const closeItems = closeConfirmList.value.map(({ name, ...rest }: any) => rest);
// 转换格式
const pssrItems = pssrList.value.flatMap((group: any) => group.items || []).map((item: any) => ({
item_id: item.id,
confirm_flag: item.confirm_flag
}));
// 去掉id, step_order, user_name, status
const result = steps.value.map(({ members }:any) => ({
members: members.map(({ id, step_order, user_name, status, ...rest }:any) => rest)
}));
loading.value = true;
const {error} = await myTasksDisposalSubmitApi(currentId.value,{
action: type,
act: actType,
reason: description.value,
risk_text: judge.value,
new_deadline: formatTimestamp(delayDate.value,'yyyy-MM-dd'),
close_docs: closeItems,
pssr: pssrItems,
approval_flow: {
steps: result
}
})
if(!error){
if(type==='save'){
window.$message?.success('保存成功');
}else{
window.$message?.success('提交成功');
back();
}
}
loading.value = false;
}
// 获取变更详情
const getDetail = async () => {
loading.value = true;
const {data,error} = await getChangeDetailApi(currentId.value)
if(!error){
currentInfo.value = data;
// ******审批流程********
// 如果详情有审批流程相关数据,直接使用,否则根据等级、类型、时间类型匹配模板
if(data?.disposal_approval_flow && data?.disposal_approval_flow?.steps?.length){
flowList.value = data?.disposal_approval_flow?.steps || [];
steps.value = data?.disposal_approval_flow?.steps || [];
}else{
let template = templateList.value.find((item: any) =>
item.level === data?.apply_form?.change_level &&
item.category === data?.apply_form?.change_type &&
item.duration === data?.apply_form?.duration_type
) || null;
if(template){
flowList.value = template?.steps || [];
steps.value = template?.steps || [];
}
}
// ******解析 PSSR 检查内容********
const map = new Map();
for (const item of currentInfo.value?.pssr?.items) {
const type = item.group_type;
if (!map.has(type)) {
map.set(type, {
g: item.group_name,
type: item.group_type,
na_flag: item.na_flag,
items: []
});
}
map.get(type).items.push(item);
}
const result = Array.from(map.values());
// 过滤掉不需要的字段
const omitFields = ['created_at', 'updated_at', 'change_id', 'group_name'];
const newResult = result.map((group:any) => ({
...group,
items: group.items.map((item:any) =>
Object.fromEntries(Object.entries(item).filter(([k]) => !omitFields.includes(k)))
)
}));
pssrList.value = newResult;
// ******解析 验收评价********
const omit = (obj:any, fields: string[]) => Object.fromEntries(Object.entries(obj).filter(([k]) => !fields.includes(k)));
const result2 = currentInfo.value?.accept_form?.items.map((item:any) => omit(item, [
'created_at', 'updated_at', 'change_id'
]));
acceptList.value = result2 || [];
// ******解析 等级判断********
// 建立映射:question_id -> score
const scoreMap = new Map(
currentInfo.value?.apply_form?.level_score?.map((item:any) => [item.question_id, item.score])
);
// 遍历第一个数组,匹配到就替换 score
questions.value.forEach((item:any) => {
if (scoreMap.has(item.question_id)) {
item.score = scoreMap.get(item.question_id);
}
});
}
loading.value = false;
};
// 获取系统配置
const getConfig = async () => {
loading.value = true;
const {data,error} = await systemConfigApi();
if(!error){
// 解析 AI 辅助功能
const aiAssistGroup = data.find((group:{group_key:string}) => group.group_key === 'ai_assist');
if (aiAssistGroup) {
const target = aiAssistGroup?.items?.find((item:any) => item.item_key === 'enabled');
if (target) {
isAiOn.value = target.value==1?true:false;
}
}
// 解析 变更类型设置
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
if (changeTypeGroup) {
typeList.value = changeTypeGroup.items.map((item:any) => ({
...item,
value: Number(item.value)
}));
}
// 解析 变更等级设置
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
if (changeLevelGroup) {
levelList.value = changeLevelGroup.items.map((item:any) => ({
...item,
value: Number(item.value)
}));
}
getDetail();
}
loading.value = false;
}
// 人员数据转换
const groupUsersByOrg = (users:any) => {
const orgMap = new Map(); // key: org.id, value: { id, label, children }
users.forEach((user:any) => {
const { id: userId, realname, user_departments } = user;
if (!user_departments || user_departments.length === 0) return;
user_departments.forEach((dept:any) => {
const org = dept.org;
if (!org || !org.id) return;
if (!orgMap.has(org.id)) {
orgMap.set(org.id, {
value: org.id,
label: org.label,
children: []
});
}
const targetOrg = orgMap.get(org.id);
// 避免同一个用户重复加入同一个组织(理论上不会发生,但做去重保护)
if (!targetOrg.children.some((child:any) => child.id === userId)) {
targetOrg.children.push({
value: userId.toString(),
label: realname,
user_id: userId.toString()
});
}
});
});
return Array.from(orgMap.values());
}
// 获取所有人员
const getAllMembers = async () => {
const {data,error} = await peopleListApi();
if(!error){
memberList.value = groupUsersByOrg(data);
}
}
/**
* 在组织树中根据 user_id 查找用户信息
* @param {Array} tree - 组织树数据
* @param {string|number} targetUserId - 要查找的 user_id
* @returns {Object|null} - 返回 { userLabel, parentLabel, parentValue } 或 null
*/
function findUserInTree(tree: any[], targetUserId: string | number) {
for (const org of tree) {
const children = org.children || [];
for (const user of children) {
if (String(user.user_id) === String(targetUserId)) {
return {
user_name: user.label,
org_name: org.label,
org_id: org.value
};
}
}
}
return null; // 未找到
}
// 设置专业会签步骤
const setStepCountersign = (target: any, v: boolean) => {
flowList.value.forEach((s:any) => {
s.countersign = s === target ? v : false;
if (!s.countersign) s.members.forEach((m:any) => { m.specialty = ""; m.is_sign = 0; });
});
}
// 选择专业
const selectSpecialty = (idx: number, mIdx: number, v: string | null) => {
flowList.value[idx].members[mIdx].specialty = v ?? null;
}
// 选择是否必须参与会签
const selectSign = (idx: number, mIdx: number, v: number) => {
flowList.value[idx].members[mIdx].is_sign = v;
}
// 当前打开人员选择器的步骤(步骤行「+」按钮切换)
const pickerStepId = ref<number | null>(null);
const pick = ref<string | null>(null);
const togglePicker = (s: any) => {
pickerStepId.value = pickerStepId.value === s.id ? null : s.id;
pick.value = null;
}
// 添加审批步骤人员
const addMember = (s: any, v: string | null) => {
if (v && !s.members.some((m:any) => m.user_name === v)){
let info = findUserInTree(memberList.value, v);
s.members.push({ user_id: Number(v), user_name: info?.user_name || '', org_id: info?.org_id || '', org_name: info?.org_name || '', specialty: "", is_sign: 0 });
pick.value = null;
pickerStepId.value = null;
}
}
// 删除审批步骤人员
const delMember = (s: any, p: number) => {
s.members = s.members.filter((m:any) => m.user_id !== p);
}
// 获取所有模板
const getTemplateList = async () => {
loading.value = true;
const {data,error} = await templateListApi();
if(!error){
// 过滤出处置申请模板
const result = data.filter((item:any) => item.type === 2);
// ******审批模板********
templateList.value = result || [];
}
loading.value = false;
}
onMounted(async () => {
getAllMembers();
await getTemplateList();
if(route.query && route.query.id){
if(route.query.id){
currentId.value = Number(route.query.id);
getConfig();
}
if(route.query.type){
disposalType.value = route.query.type.toString();
// 未实施
if(disposalType.value==='unExecute'){
closeConfirmList.value = [
{doc_id:1,name:'未实施情况说明(本单)',status:null},
{doc_id:2,name:'原风险分析(归档备查,不重做)',status:null},
{doc_id:3,name:'PSSR 检查项(标注「不涉及」)',status:null},
{doc_id:4,name:'资料归档清单(转「不涉及」确认)',status:null},
];
}
// 未投用
if(disposalType.value==='unUse'){
closeConfirmList.value = [
{doc_id:1,name:'未投用情况说明(本单)',status:null},
{doc_id:2,name:'原风险分析(归档备查,不重做)',status:null},
{doc_id:3,name:'PSSR 检查项(标注「不涉及」)',status:null},
{doc_id:4,name:'资料归档清单(转「不涉及」确认)',status:null},
];
}
// 申请恢复
if(disposalType.value==='recover'){
closeConfirmList.value = [
{doc_id:1,name:'恢复操作票(执行记录与照片)',status:null},
{doc_id:2,name:'恢复后联锁 / 报警设定值回装核对记录',status:null},
{doc_id:3,name:'更新的设备 / 安全阀台账',status:null},
];
}
// 申请延期
if(disposalType.value==='delay'){
closeConfirmList.value = [
{doc_id:1,name:'延期审批子单(随原单归档)',status:null},
{doc_id:2,name:'原风险分析记录(延期期间持续有效确认)',status:null},
{doc_id:3,name:'到期提醒与再评估记录',status:null},
];
}
// 申请转永久
if(disposalType.value==='transform'){
closeConfirmList.value = [
{doc_id:1,name:'转永久审批子单)',status:null},
{doc_id:2,name:'补充风险分析报告(HAZOP / FMEA)',status:null},
{doc_id:3,name:'规程 / 台账正式版',status:null},
{doc_id:4,name:'原临时变更关闭记录',status:null},
];
}
}
}
})
</script>
<template>
<NSpace vertical :size="16">
<!-- 处置申请详情 -->
<NSpin :show="loading" size="small">
<!-- 顶部 -->
<div
class="flex flex-wrap items-center gap-3 px-3 py-2 mb-3 bg-[#fff]"
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
>
<NButton ghost type="primary" @click="back">
<Icon icon="ep:back" class="size-16px mr-1" />返回
</NButton>
<div class="min-w-0 flex-1 truncate text-base font-bold text-slate-800">{{ currentInfo?.title }}</div>
<NTag size="small" type="info" class="font-mono text-xs">编号:{{ currentInfo?.change_no }}</NTag>
</div>
<!-- 主体 -->
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
<!-- 左侧 -->
<NGi span="24 s:24 m:24 l:16">
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center flex-wrap pb-2.5 mb-3 border-b">
<span class="text-slate-800 font-600 text-sm">变更全部信息</span>
<p class="text-slate-400 text-xs ml-1 mt-1px">审批人视角 · 只读参考,本次操作以子单 / 单据形式关联,不回改原单</p>
</div>
<div class="space-y-3 left-scroll">
<!-- 变更申请表 -->
<div class="flex items-center justify-between">
<p
class="rounded-sm bg-[var(--theme-color)] px-3 py-1 text-xs font-semibold text-white"
:style="{'--theme-color': themeStore.themeColor}"
>变更申请表</p>
<NButton ghost size="small" class="text-xs" @click="exportFile('change')"><Icon icon="material-symbols:download" class="size-16px" />导出变更申请表</NButton>
</div>
<div
class="border border-slate-200"
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
>
<div class="grid border-b grid-cols-[100px_1fr_100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更发起人</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.applicant_name }}</div>
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">申请单位</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.org_name }}</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr_100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">发起日期</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.created_at?.slice(0,10) }}</div>
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">计划投用日期</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.plan_use_date?.slice(0,10) }}</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr_100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更时限</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.duration_type === 1 ? '永久变更' : '临时变更' }}</div>
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">紧急</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.urgent === 1 ? '是' : '否' }}</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr_100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更等级</p>
<div class="flex items-center flex-wrap gap-2 px-3 py-1.6 text-sm leading-relaxed text-slate-800">
<NRadioGroup v-model:value="level">
<NRadio v-for="item in levelList" :key="item.value" :value="item.value" :disabled="true">{{item.label}}</NRadio>
</NRadioGroup>
<NTag type="error" size="small">合计 {{totalScore}} 分</NTag>
<NButton ghost type="warning" size="small" round class="text-xs" @click="levelDrawer = true">
<Icon icon="lucide:eye" class="size-12px mr-1" />查看等级判定
</NButton>
</div>
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更类别</p>
<div class="flex items-center gap-2 px-3 py-1.6 text-sm leading-relaxed text-slate-800">
{{matchLabel(typeList,currentInfo?.apply_form?.change_type)?.label}}
<NButton v-if="isAiOn" ghost type="primary" size="small" round class="text-xs" @click="openTypeSheet">
<Icon icon="lucide:sparkles" class="size-12px mr-1" />判定说明
</NButton>
</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更目的</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
{{currentInfo?.apply_form?.purposes?.join('、')}}
</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">预期效果</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
{{currentInfo?.apply_form?.effect}}
</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">主要变更</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
<p v-for="(main,mIndex) in currentInfo?.apply_form?.main_changes" :key="mIndex">{{Number(mIndex)+1}}.{{main}}</p>
</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">连带变更</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
<p v-for="(related,rIndex) in currentInfo?.apply_form?.related_changes" :key="rIndex">{{Number(rIndex)+1}}.{{related}}</p>
</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">风险评估方法</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
{{currentInfo?.apply_form?.risk_tools?.join('、')}}
</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">所需材料</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
{{currentInfo?.apply_form?.materials}}
</div>
</div>
<div class="grid border-b grid-cols-[100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">需更新资料</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
{{currentInfo?.apply_form?.update_docs?.join('、')}}
</div>
</div>
<div class="grid grid-cols-[100px_1fr]">
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">涉及专业</p>
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
{{currentInfo?.apply_form?.disciplines?.join('、')}}
</div>
</div>
</div>
<!-- 变更附带资料 -->
<div class="flex items-center justify-between">
<p
class="rounded-sm bg-[var(--theme-color)] px-3 py-1 text-xs font-semibold text-white"
:style="{'--theme-color': themeStore.themeColor}"
>变更附带资料</p>
<NButton ghost size="small" class="text-xs" @click="downloadFile(null)"><Icon icon="material-symbols:download" class="size-16px" />一键下载</NButton>
</div>
<NTable size="small">
<thead>
<tr>
<th class="w-60px !text-center">序号</th>
<th>文件名</th>
<th class="w-100px !text-center">类型</th>
<th class="w-100px !text-center">文件大小</th>
<th class="w-120px !text-center">上传时间</th>
<th class="w-120px !text-center">上传阶段</th>
<th class="w-90px !text-center">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(doc,dIndex) in currentInfo?.apply_form?.attachments" :key="dIndex">
<td class="text-center">1</td>
<td>{{doc.file_name}}</td>
<td class="text-center text-xs">{{doc?.file_type}}</td>
<td class="text-center text-xs">{{doc?.file_size}}</td>
<td class="text-center text-xs">{{doc?.created_at?.slice(0,16)}}</td>
<td class="text-center text-xs">
<span v-if="doc?.step_no===1">变更申请表</span>
</td>
<td>
<div class="flex items-center justify-center gap-2">
<NButton text type="primary" class="text-xs" @click="openPreview(doc)">预览</NButton>
<NButton text type="primary" class="text-xs" @click="downloadFile(doc)">下载</NButton>
</div>
</td>
</tr>
</tbody>
</NTable>
<!-- 风险分析记录表 -->
<div class="flex items-center justify-between">
<p
class="rounded-sm bg-[var(--theme-color)] px-3 py-1 text-xs font-semibold text-white"
:style="{'--theme-color': themeStore.themeColor}"
>风险分析记录表</p>
<NButton ghost size="small" class="text-xs" @click="exportFile('risk')"><Icon icon="material-symbols:download" class="size-16px" />导出风险分析记录表</NButton>
</div>
<NTable size="small">
<thead>
<tr>
<th class="w-60px !text-center">序号</th>
<th>风险项</th>
<th>场景描述</th>
<th class="w-80px !text-center">固有风险</th>
<th>现有措施</th>
<th>建议措施</th>
<th class="w-80px !text-center">剩余风险</th>
<th class="w-100px !text-center">来源</th>
</tr>
</thead>
<tbody>
<tr v-for="(risk,index) in currentInfo?.risk_records?.records" :key="index">
<td class="text-center">{{Number(index)+1}}</td>
<td>{{risk.item}}</td>
<td>{{risk.scene}}</td>
<td class="text-center">{{risk.inherent_level}}</td>
<td>{{risk.safeguards}}</td>
<td>{{risk.suggestions}}</td>
<td class="text-center">{{risk.residual_level}}</td>
<td class="text-center">
<NTag size="small" type="info" class="text-11px">{{ risk.source==='RISK'?'风险预分析':risk.source==='RISK_CHECK'?'风险检查': risk.source }}</NTag>
</td>
</tr>
</tbody>
</NTable>
<!-- 变更培训内容 -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<Icon icon="lucide:graduation-cap" class="size-16px text-violet-500" />
<span class="text-sm font-semibold text-slate-700">变更培训内容</span>
</div>
<div class="space-y-2.5 border p-3" :style="{borderRadius: themeStore.themeRadius + 'px'}">
<p>{{currentInfo?.train?.content}}</p>
</div>
</div>
<!-- PSSR 检查内容 -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<Icon icon="lucide:list-checks" class="size-16px text-violet-500" />
<span class="text-sm font-semibold text-slate-700">PSSR 检查内容</span>
</div>
<div class="space-y-2.5 border p-3" :style="{borderRadius: themeStore.themeRadius + 'px'}">
<div v-for="(pssr,index) in pssrList" class="border" :key="index" :style="{borderRadius: themeStore.themeRadius + 'px'}">
<div class="bg-slate-100 px-3 py-1.6 font-medium text-slate-600">{{pssr.g}}</div>
<div v-for="item in pssr.items" :key="item.id" class="flex items-center border-t px-3 py-1.6">
<p class="flex-1">{{item.content}}</p>
<NTag v-if="item.phase===1" size="small" type="info">投用前完成</NTag>
<NTag v-else size="small" type="info">关闭前完成</NTag>
</div>
</div>
</div>
</div>
<!-- 验收评价 -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<Icon icon="ix:success" class="size-16px text-emerald-500" />
<span class="text-sm font-semibold text-slate-700">验收评价</span>
</div>
<div class="border p-3" :style="{borderRadius: themeStore.themeRadius + 'px'}">
<NTable size="small">
<thead>
<tr>
<th class="w-56">验收项目</th>
<th>量化验收标准</th>
<th>验收方式</th>
<th class="w-46 !text-center">组织层级</th>
</tr>
</thead>
<tbody>
<tr v-for="(accept,index) in acceptList" :key="index">
<td class="text-xs">{{accept.item}}</td>
<td class="text-xs">{{accept.standard}}</td>
<td class="text-xs">{{accept.method}}</td>
<td class="text-xs text-center">{{accept.org_name}}</td>
</tr>
</tbody>
</NTable>
</div>
</div>
<!-- 审批记录 -->
<div class="flex items-center justify-between">
<p
class="rounded-sm bg-[var(--theme-color)] px-3 py-1 text-xs font-semibold text-white"
:style="{'--theme-color': themeStore.themeColor}"
>审批记录</p>
</div>
<NTable size="small">
<thead>
<tr>
<th class="w-120px">流程阶段</th>
<th class="w-120px">操作人</th>
<th class="w-140px !text-center">操作时间</th>
<th>操作内容和意见</th>
</tr>
</thead>
<tbody>
<tr v-for="(record,index) in currentInfo?.approval_records" :key="index">
<td class="text-xs">{{record.action}}</td>
<td class="text-xs">{{record.user_name}}</td>
<td class="text-center text-xs">{{record.updated_at?.slice(0,16)}}</td>
<td class="text-xs">{{record.remark}}</td>
</tr>
</tbody>
</NTable>
<!-- 审批流程进度 -->
<div class="space-y-2">
<div class="text-sm font-semibold">审批流程{{ matchLabel(levelList,level)?.label? '('+matchLabel(levelList,level)?.label+'变更)':'' }}</div>
<div class="flex flex-wrap items-center gap-1">
<template v-for="(n, i) in currentInfo?.approval_flow?.steps" :key="n.name">
<div class="flex items-center gap-1">
<Icon v-if="i !== 0" icon="formkit:right" class="size-14px text-slate-300" />
<div class="rounded-lg border px-3 py-2 text-center"
:class="n.status === 'PASSED' ? 'border-emerald-300 bg-emerald-50' : n.status === 'PENDING' ? 'border-amber-400 bg-amber-50' : n.status === 'REJECTED' ? 'border-red-300 bg-red-50' :'border-amber-400 bg-amber-50'">
<p class="text-xs font-medium">
{{ n.name }}
</p>
<div class="flex items-center justify-center gap-1">
<span v-for="member in n.members" :key="member.id" class="text-xs text-slate-500">{{ member.user_name }}</span>
</div>
</div>
</div>
</template>
</div>
<div v-if="allMembers?.length" class="rounded-lg border border-amber-200 bg-amber-50/60 p-3">
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs font-semibold text-amber-700">
会签进度(全体通过方可流转)
<span class="font-normal text-amber-600">
{{ allMembers.filter((m) => m.status === 'PASSED').length }}/{{ allMembers.length }} 已签
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span v-for="m in allMembers" :key="m.id" class="flex items-center gap-1">
<NTag size="small" :type="m.status === 'PASSED' ? 'success' : m.status === 'REJECTED' ? 'error' : 'default'">
<template #icon>
<Icon v-if="m.status === 'PASSED'" icon="ix:success" class="size-14px" />
<Icon v-if="m.status === 'PENDING'" icon="ant-design:clock-circle-outlined" class="size-12px text-slate-400" />
<Icon v-if="m.status === 'REJECTED'" icon="ix:close" class="size-12px" />
</template>
{{ m.user_name }}
</NTag>
<div v-if="m.status === 'PENDING'"
class="cursor-pointer inline-flex h-6 items-center gap-1 rounded-md border border-amber-300 bg-white px-2 text-[11px] text-amber-700 transition hover:bg-amber-100"
@click="handleReminder(m)"
>
<Icon icon="lucide:bell" class="size-12px" /> 催办
</div>
</span>
</div>
</div>
</div>
</div>
</NCard>
</NGi>
<!-- 右侧 -->
<NGi span="24 s:24 m:24 l:8">
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between flex-wrap pb-2.5 mb-3 border-b">
<span v-if="disposalType === 'unExecute'" class="text-slate-800 font-600 text-sm" :style="{ color: themeStore.themeColor }">未实施情况说明表</span>
<span v-if="disposalType === 'unUse'" class="text-slate-800 font-600 text-sm" :style="{ color: themeStore.themeColor }">未投用情况说明表</span>
<span v-if="disposalType === 'recover'" class="text-slate-800 font-600 text-sm" :style="{ color: themeStore.themeColor }">变更恢复申请表</span>
<span v-if="disposalType === 'delay'" class="text-slate-800 font-600 text-sm" :style="{ color: themeStore.themeColor }">变更延期申请表</span>
<span v-if="disposalType === 'transform'" class="text-slate-800 font-600 text-sm" :style="{ color: themeStore.themeColor }">临时转永久申请表</span>
<NButton v-if="isAiOn" size="small" type="primary" round class="text-xs">AI辅助填报</NButton>
</div>
<div class="right-scroll">
<!-- 未实施情况说明 -->
<template v-if="disposalType === 'unExecute'">
<!-- 情况说明 -->
<div class="space-y-1 mt-1">
<p class="text-xs font-600 text-slate-600 flex items-center">情况说明<span class="pl-1" :style="{color: themeStore.otherColor.error}">*</span></p>
<NInput v-model:value="description" type="textarea" :autosize="{minRows: 3}" placeholder="填写情况说明(可点击「AI 辅助填报」生成草稿后修改)…" class="w-full"></NInput>
</div>
<!-- 影响判定 -->
<div class="space-y-1 mt-3">
<p class="text-xs font-600 text-slate-600 flex items-center">{{isAiOn?'影响判定(AI 辅助)':'影响判定'}}</p>
<NInput v-model:value="judge" type="textarea" :autosize="{minRows: 3}" placeholder="风险分析内容(AI 预分析草稿,可手动修改)…" class="w-full"></NInput>
</div>
</template>
<!-- 未投用情况说明 -->
<template v-if="disposalType === 'unUse'">
<!-- 情况说明 -->
<div class="space-y-1 mt-1">
<p class="text-xs font-600 text-slate-600 flex items-center">情况说明<span class="pl-1" :style="{color: themeStore.otherColor.error}">*</span></p>
<NInput v-model:value="description" type="textarea" :autosize="{minRows: 3}" placeholder="填写情况说明(可点击「AI 辅助填报」生成草稿后修改)…" class="w-full"></NInput>
</div>
<!-- 影响判定 -->
<div class="space-y-1 mt-3">
<p class="text-xs font-600 text-slate-600 flex items-center">{{isAiOn?'影响判定(AI 辅助)':'影响判定'}}</p>
<NInput v-model:value="judge" type="textarea" :autosize="{minRows: 3}" placeholder="风险分析内容(AI 预分析草稿,可手动修改)…" class="w-full"></NInput>
</div>
</template>
<!-- 申请恢复 -->
<template v-if="disposalType === 'recover'">
<!-- 原因 -->
<div class="space-y-1 mt-1">
<p class="text-xs font-600 text-slate-600 flex items-center">申请恢复原因<span class="pl-1" :style="{color: themeStore.otherColor.error}">*</span></p>
<NInput v-model:value="description" type="textarea" :autosize="{minRows: 3}" placeholder="填写申请恢复原因(可点击「AI 辅助填报」生成草稿后修改)…" class="w-full"></NInput>
</div>
<!-- 风险分析 -->
<div class="space-y-1 mt-3">
<p class="text-xs font-600 text-slate-600 flex items-center">恢复作业风险分析</p>
<NInput v-model:value="judge" type="textarea" :autosize="{minRows: 3}" placeholder="风险分析内容(AI 预分析草稿,可手动修改)…" class="w-full"></NInput>
</div>
</template>
<!-- 申请延期 -->
<template v-if="disposalType === 'delay'">
<!-- 原因 -->
<div class="space-y-1 mt-1">
<p class="text-xs font-600 text-slate-600 flex items-center">变更延期原因<span class="pl-1" :style="{color: themeStore.otherColor.error}">*</span></p>
<NInput v-model:value="description" type="textarea" :autosize="{minRows: 3}" placeholder="填写变更延期原因(可点击「AI 辅助填报」生成草稿后修改)…" class="w-full"></NInput>
</div>
<div class="flex items-center flex-wrap gap-2 mt-3">
<p class="flex items-center">延期至<span class="pl-1" :style="{color: themeStore.otherColor.error}">*</span></p>
<NDatePicker v-model:value="delayDate" type="date" size="small" class="w-120px" />
<p class="text-xs text-slate-400">原到期 2026-08-20 · 至多延期 1 次</p>
</div>
<!-- 风险分析 -->
<div class="space-y-1 mt-3">
<p class="text-xs font-600 text-slate-600 flex items-center">延期风险分析</p>
<NInput v-model:value="judge" type="textarea" :autosize="{minRows: 3}" placeholder="风险分析内容(AI 预分析草稿,可手动修改)…" class="w-full"></NInput>
</div>
</template>
<!-- 申请转永久 -->
<template v-if="disposalType === 'transform'">
<!-- 原因 -->
<div class="space-y-1 mt-1">
<p class="text-xs font-600 text-slate-600 flex items-center">申请转永久原因<span class="pl-1" :style="{color: themeStore.otherColor.error}">*</span></p>
<NInput v-model:value="description" type="textarea" :autosize="{minRows: 3}" placeholder="填写申请转永久原因(可点击「AI 辅助填报」生成草稿后修改)…" class="w-full"></NInput>
</div>
<!-- 风险分析 -->
<div class="space-y-1 mt-3">
<p class="text-xs font-600 text-slate-600 flex items-center">风险分析缺口补充(按永久变更要求)</p>
<NInput v-model:value="judge" type="textarea" :autosize="{minRows: 3}" placeholder="风险分析内容(AI 预分析草稿,可手动修改)…" class="w-full"></NInput>
</div>
</template>
<!-- 风险分析行动项落实 -->
<div class="rounded-md mt-4 border">
<div class="flex items-center gap-2 py-2 px-2 border-b">
<span class="text-xs font-600 text-slate-600 flex items-center">风险分析行动项落实</span>
<p class="text-xs text-slate-400">{{confirmedPssr}}/{{totalPssr}} 已确认</p>
</div>
<div class="p-2 space-y-2">
<div v-for="(pssr,index) in pssrList" class="border" :key="index" :style="{borderRadius: themeStore.themeRadius + 'px'}">
<div class="bg-slate-100 px-2 py-1.5 font-medium text-xs text-slate-600 flex items-center justify-between">
{{pssr.g}}
<NButton text size="small" type="primary" class="text-xs" @click="addItem(index)">
<Icon icon="ic:round-plus" class="size-12px" />添加检查项
</NButton>
</div>
<div v-for="(item,subIndex) in pssr.items" :key="subIndex" class="flex items-center border-t px-2 py-1 gap-2">
<NInput v-if="!item.id" v-model:value="item.content" size="small" type="textarea" :autosize="{minRows: 1}" placeholder="填写检查项内容" class="w-full text-xs"></NInput>
<NButton v-if="!item.id" text type="success" @click="saveItem(index, subIndex)" title="保存检查项">
<Icon icon="boxicons:save" class="size-16px" />
</NButton>
<NButton v-if="!item.id" text type="error" @click="pssrList[index].items.splice(subIndex, 1)" title="删除">
<Icon icon="ep:delete" class="size-14px" />
</NButton>
<p v-if="item.id" class="flex-1 text-xs">{{item.content}}</p>
<NButton v-if="item.confirm_flag" size="tiny" ghost type="success" class="text-xs" @click="item.confirm_flag = 0">已确认</NButton>
<NButton v-else size="tiny" ghost type="warning" class="text-xs" @click="item.confirm_flag = 1">待确认</NButton>
</div>
</div>
</div>
</div>
<!-- 资料关闭确认 -->
<div class="rounded-md mt-4 border">
<div class="flex items-center gap-2 py-2 px-2">
<span class="text-xs font-600 text-slate-600 flex items-center">资料关闭确认</span>
<p class="text-xs text-slate-400">提交后纳入资料关闭流程统一归档</p>
</div>
<div v-for="item in closeConfirmList" :key="item.doc_id" class="flex items-center gap-2 py-2 px-2 border-t">
<p class="flex-1 text-xs flex items-center">{{item.name}}</p>
<NButton :type="item.status===1?'primary':'default'" size="tiny" class="text-xs" @click="item.status = 1">已归档</NButton>
<NButton :type="item.status===2?'primary':'default'" size="tiny" class="text-xs" @click="item.status = 2">待归档</NButton>
<NButton :type="item.status===0?'primary':'default'" size="tiny" class="text-xs" @click="item.status = 0">不涉及</NButton>
</div>
</div>
</div>
</NCard>
</NGi>
</NGrid>
<!-- 底部 -->
<div
class="flex flex-wrap items-center justify-end gap-3 px-3 py-2 mt-3 bg-[#fff]"
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
>
<NButton size="small" @click="approvalDrawer = true">审批流程设置</NButton>
<NButton size="small" :disabled="loading" @click="submitDisposal('save')">保存</NButton>
<NButton size="small" type="primary" :disabled="loading" @click="submitDisposal('submit')">提交</NButton>
</div>
<!-- 变更类型判定说明(右侧抽屉) -->
<NDrawer v-model:show="describeDrawer" :width="576" placement="right" :auto-focus="false">
<NDrawerContent title="变更类型判定说明" closable>
<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] mb-3">
<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 v-if="typeAi?.type" type="info" size="small">判定:{{ typeAi?.type }}</NTag>
<NTag v-if="typeAi" size="small">置信度:{{ `${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>
</NDrawerContent>
</NDrawer>
<!-- 变更等级判定表(右侧抽屉查看) -->
<NDrawer v-model:show="levelDrawer" :width="576" placement="right" :auto-focus="false">
<NDrawerContent title="变更等级判定表" closable>
<div class="space-y-4">
<div class="flex items-center gap-2">
<NTag
size="small"
:color="{
color: tagBgColor(matchLabel(levelList,level)?.color),
textColor: tagTextColor(matchLabel(levelList,level)?.color),
borderColor: tagBorderColor(matchLabel(levelList,level)?.color),
}"
>{{ matchLabel(levelList,level)?.label? matchLabel(levelList,level)?.label+'变更':'' }}</NTag>
<span class="text-xs text-slate-400">合计得分 <b class="text-[#17407F]">{{ totalScore }}</b> 分(&lt;20 分为一般,≥20 分为重要)</span>
</div>
<NTable size="small">
<thead>
<tr class="border-b bg-slate-50">
<th class="w-40">评估维度</th>
<th class="w-20 !text-center">得分</th>
<th>评分依据</th>
</tr>
</thead>
<tbody>
<tr v-for="s in questions" :key="s.dim" class="border-b last:border-b-0">
<td class="font-medium">{{ s.name }}</td>
<td class="font-bold text-[#17407F] text-center">{{ s.score }}</td>
<td class="text-xs leading-relaxed text-slate-600">{{ matchBasis(s.options,s.score) }}</td>
</tr>
</tbody>
</NTable>
</div>
</NDrawerContent>
</NDrawer>
<!-- 审批流程设置 -->
<NDrawer v-model:show="approvalDrawer" placement="right" width="28rem" :auto-focus="false">
<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 v-for="(s, idx) in flowList" :key="s.id" class="rounded-lg border border-slate-100 bg-slate-50/40 p-3">
<div class="flex flex-wrap items-center gap-2">
<NTag type="info" size="small">{{Number(idx)+1}}</NTag>
<p class="text-sm">{{s.name}}</p>
<NCheckbox :checked="s.countersign" size="small" @update:checked="(v) => setStepCountersign(s, v)">
<span class="text-xs text-slate-500">专业会签步骤</span>
</NCheckbox>
<NButton ghost type="primary" size="tiny" @click="togglePicker(s)" title="添加预设人员" class="ml-auto">
<Icon icon="ic:round-plus" class="size-16px" />
</NButton>
</div>
<!-- 预设人员:人员必选;仅会签步骤可选择专业(必选),且人员可勾选「必须进行专业会签」 -->
<div class="mt-2 flex gap-2 pl-8">
<span class="shrink-0 text-xs leading-7 text-slate-400">预设人员:</span>
<div class="min-w-0 flex-1 space-y-1.5">
<div v-for="(m, mIdx) in s.members" :key="m.id" class="flex min-h-7 flex-wrap items-center gap-2">
<span class="shrink-0 truncate text-xs font-medium text-slate-700">{{ m.user_name }}</span>
<template v-if="s.countersign">
<span class="text-xs text-slate-400"><span class="text-red-500">*</span>专业:</span>
<NSelect :value="m.specialty || null" clearable size="small" placeholder="请选择专业" class="w-28"
:options="SPECIALTIES"
@update:value="(v) => selectSpecialty(Number(idx), Number(mIdx), v)"
/>
<NCheckbox :checked="m.is_sign" size="small"
:checked-value="1"
:unchecked-value="0"
@update:checked="(v) => selectSign(Number(idx), Number(mIdx), v)"
>
<span class="text-xs text-slate-500">必须参与会签</span>
</NCheckbox>
</template>
<NButton text type="error" @click="delMember(s, m.user_id)" title="删除该人员">
<Icon icon="iconamoon:trash" class="size-16px" />
</NButton>
</div>
<p v-if="s.countersign && s.members.length" class="text-[11px] leading-5 text-slate-400">
必须参与会签:勾选的人员必须参与专业会签,未勾选的人员为可选会签(按需参加)
</p>
<NCascader v-if="pickerStepId === s.id" :value="pick" :options="memberList"
placeholder="请选择人员" size="small" check-strategy="child" :show-path="false" expand-trigger="click"
class="w-56" :on-update:value="(v) => addMember(s, v)" />
<span v-if="s.members.length === 0" class="text-xs leading-7 text-slate-300">未预设</span>
</div>
</div>
</div>
</div>
<template #footer>
<NButton v-if="flowList.length" type="primary" class="w-full" @click="finishApprover">完成设置</NButton>
</template>
</NDrawerContent>
</NDrawer>
<!-- 附件在线预览弹窗 -->
<NModal
v-model:show="previewModal"
preset="card"
:auto-focus="false"
:style="{ width: '80%', height: 'auto' }"
:segmented="{ content: true, footer: true }"
>
<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>
<previewComponent v-if="previewFile" :previewFile="previewFile" />
</div>
</template>
</NModal>
</NSpin>
</NSpace>
</template>
<style scoped lang="scss">
.left-scroll {
height: calc(100vh - 307px);
overflow-y: auto;
}
.right-scroll {
height: calc(100vh - 315px);
overflow-y: auto;
}
</style>