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

1076 lines
51 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 { getColorWithOpacity, tagTextColor, tagBgColor, tagBorderColor, downloadFileFun } from '@/utils/common';
import { AiError, aiTypeJudge } from "@/service/api/ai";
import { systemConfigApi } from '@/service/api/system';
import { getChangeDetailApi } from '@/service/api/change';
import previewComponent from '@/components/previewFile/index.vue'
import { rejectApi,agreeApi } from '@/service/api/myTasks';
const themeStore = useThemeStore();
const { routerPushByKey } = useRouterPush();
const route = useRoute();
const loading = ref<boolean>(false);
// 驳回弹框
const rejectModal = ref<boolean>(false);
// 驳回原因
const rejectReason = ref<string>('');
// 驳回loading
const rejectSaving = ref<boolean>(false);
// 驳回快捷
const rejectQuick = ref<string[]>([
"变更前后对比不完整",
"缺少支撑材料或核算数据",
"风险分析不充分,请补充识别",
"连带变更未识别完整",
"变更等级判定依据不足",
"所需材料清单不全",
]);
// 审批意见
const comment = ref<string>('');
// 返回键
const backKey = ref<string>('');
// 判定说明
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;
});
// 总得分
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[] }[]>([
{ g: "一、风险分析行动项落实", type:1, na_flag:0, items: [] },
{ g: "二、现场设备安装检查确认", type:2, na_flag:0, items: [] },
{ g: "三、保护措施确认", type:3, na_flag:0, items: [] },
{ g: "四、资料归档确认", type:4, na_flag:0, items: [] },
]);
// 验收评价
const 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 role = ref<string>("applicant");
// 判定说明抽屉
const describeDrawer = ref<boolean>(false);
const levelDrawer = ref<boolean>(false);
// ai 标注抽屉
const aiDrawer = ref<boolean>(false);
const mandClosed = ref<boolean>(false);
const confirmClose = () => {
mandClosed.value = true;
window.$message?.success('强制合规项已确认闭环,操作已留痕');
}
const conflictHide = ref<boolean>(false);
// 关键参数对比(静态示例作 fallback,AI 审批辅助完成后回填)
const paramDiff = ref([
{ k: "反应温度", before: "80℃", after: "90℃", over: false, note: "仍在溶剂沸点与物料分解温度的安全裕度内" },
{ k: "保温时间", before: "8 h", after: "4 h", over: false, note: "终点判断窗口变窄,须修订操作规程" },
{ k: "DCS 温度高报警", before: "85℃", after: "95℃", over: true, note: "超出原报警裕度,须重新整定验证" },
{ k: "联锁高高报", before: "90℃", after: "100℃", over: true, note: "接近设备设计温度上限,投用前须完成联锁测试" },
{ k: "未知杂质含量", before: "0.05%", after: "0.12%", over: false, note: "结构未定,须纳入日常分析计划" },
]);
const aiRead = ref<boolean>(false);
const roleName = ref<string>('张工艺');
const suggs = ref([
{ id: 1, text: "将未知杂质含量纳入日常分析计划(每周 1 次,连续 8 周)", st: "待定" },
{ id: 2, text: "修订操作规程并补充超温应急处置卡后再投用", st: "待定" },
]);
const opinion = ref<string>("");
// 已阅 AI 建议
const readSuggest = () => {
aiRead.value = true;
window.$message?.success(`${roleName.value} 已阅 AI 建议,时间与操作已写入审批留痕`);
}
// 采纳为行动项
const acceptSuggest = (s: any) => {
suggs.value = suggs.value.map((x:any) => (x.id === s.id ? { ...x, st: '已采纳' } : x));
opinion.value = (opinion.value ? `${opinion.value}\n` : '') + `采纳 AI 建议:${s.text}`;
window.$message?.success('已采纳,自动生成 PSSR 行动项(来源:AI 建议),并纳入审批意见');
}
// 不采纳 AI 建议
const rejectSuggest = (s: any) => {
suggs.value = suggs.value.map((x:any) => (x.id === s.id ? { ...x, st: '不采纳' } : x));
window.$message?.info('不采纳理由「现有分析频次已覆盖风险」已记录留痕');
}
// 查看归档包
const viewArchive = (name: string) => {
window.$message?.info(`新窗口打开 ${name} 归档包,不影响当前审批`);
}
// AI 审批辅助(抽屉打开时触发,异步任务轮询;失败保留静态示例,不阻塞页面)
const aiAssistLoading = ref<boolean>(false);
const aiAssistStep = ref<string>('');
const aiAssistProgress = ref<number>(0);
// 强制合规描述(静态示例作 fallback)
const mandText = ref<string>('本次变更涉及 DCS 联锁设定值修改,依据 AQ/T 3034《化工企业工艺安全管理实施导则》,投用前须完成联锁测试并留存记录。');
const mandItem = ref<string>('联锁测试记录须在投用前完成并上传归档');
// 冲突提示
const conflictText = ref<string>('涉及设备 R-101 关联变更 MOC-2026-0038「机封冲洗方案调整」正在审批中,请确认施工时序与隔离方案无冲突。');
// 属地影响摘要
const deptRows = ref([
{ label: '涉及单元:', text: '一车间 R-201 反应釜及配套 DCS 回路,不影响相邻单元。' },
{ label: '现行规程影响:', text: '操作规程第 4.2 节(报警处置)需同步修订,变更关闭前完成受控换版。' },
{ label: '交接班提示:', text: '投用后首个班次需向班组交代新报警值与处置卡位置,建议纳入班前会内容。' },
]);
// 参考信息(相似变更 / 历史经验)
const similarText = ref<string>('相似变更 MOC-2025-0112「酯化反应温度上调」投用后运行正常,无异常记录');
const experienceText = ref<string>('历史经验:温度类变更投用后首周超温报警占比约 38%,建议关注投用首周报警趋势');
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(backKey.value as any);
};
// 导出
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 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 getDetail = async () => {
loading.value = true;
const {data,error} = await getChangeDetailApi(currentId.value)
if(!error){
currentInfo.value = data;
// ******解析 PSSR 检查内容********
// 1. 按 group_type 分组
const map = new Map();
currentInfo.value?.pssr?.items?.forEach((item:any) => {
const t = item.group_type;
if (!map.has(t)) map.set(t, []);
map.get(t).push(item);
});
// 2. 生成目标数组
const result = pssrlist.value.map(({ type, g }:any) => {
const items = map.get(type) || [];
// na_flag 取该组第一个元素的 na_flag,无数据时默认为 0
const na_flag = items.length > 0 ? items[0].na_flag : 0;
return {
g,
type,
na_flag,
items
};
});
// 3. 过滤掉不需要的字段
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 openReject = () => {
rejectReason.value = '';
rejectModal.value = true;
}
// 驳回保存
const rejectSave = async () => {
if (!rejectReason.value) {
return window.$message?.warning('请输入驳回意见');
}
rejectSaving.value = true;
const {error} = await rejectApi({change_id:currentId.value,opinion:rejectReason.value});
if(!error){
rejectModal.value = false;
window.$message?.success('驳回成功');
back();
}
rejectSaving.value = false;
}
// 同意保存
const agreeSave = async () => {
loading.value = true;
const {error} = await agreeApi({change_id:currentId.value,opinion:comment.value});
if(!error){
window.$message?.success('同意成功');
back();
}
loading.value = false;
}
onMounted(() => {
if(route.query){
if(route.query.key){
backKey.value = route.query.key.toString();
}
if(route.query.id){
currentId.value = Number(route.query.id);
getConfig();
}
}
})
</script>
<template>
<NSpace vertical :size="16">
<NSpin :show="loading" size="small">
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div
class="flex flex-wrap items-center gap-3 border border-slate-200 px-3 py-2 mb-3"
: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>
<!-- 审批中 -->
<NButton
v-if="isAiOn"
ghost
size="small"
type="primary"
class="text-xs"
@click="aiDrawer = !aiDrawer"
>
<Icon icon="lucide:sparkles" class="size-12px mr-1" /> AI 标注
</NButton>
</div>
<div class="space-y-3 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">
<template v-for="(item,index) in currentInfo?.apply_form?.risk_tools" :key="index">
<span v-if="index!==0">、</span>
<span v-if="item==='RISK'">风险预分析</span>
<span v-else-if="item==='RISK_CHECK'">风险检查表</span>
<span v-else>{{ item }}</span>
</template>
</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" :key="index" class="border" :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>
</div>
<div class="flex items-center py-3 px-3 border rounded-md mt-3 gap-3">
<NButton v-if="isAiOn" class="text-xs hover:underline" text type="primary" @click="aiDrawer = !aiDrawer">AI辅助</NButton>
<NInput v-model:value="comment" type="textarea" size="small" :autosize="{ minRows: 1}" placeholder="审批意见(采纳的 AI 建议自动纳入,可修改,随审批提交留痕)" />
<div class="ml-auto flex gap-2">
<NButton class="text-xs" size="small" type="error" @click="openReject">驳回</NButton>
<NButton class="text-xs" size="small" type="success" @click="agreeSave">同意</NButton>
</div>
</div>
</NCard>
<!-- 变更类型判定说明(右侧抽屉) -->
<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>
<!-- AI 审批辅助(右侧抽屉查看) -->
<NDrawer v-model:show="aiDrawer" :width="576" placement="right" :auto-focus="false">
<NDrawerContent closable>
<template #header>
<span class="flex items-center gap-1.5 text-sm font-bold text-slate-700">
<Icon icon="lucide:sparkles" class="size-16px text-violet-500 mr-1" />
AI 审批辅助(抽屉式,正文全窗口阅读)
</span>
</template>
<div class="space-y-4">
<!-- AI 分析进度(异步任务轮询) -->
<div v-if="aiAssistLoading" class="rounded-lg border border-violet-200 bg-violet-50 px-3 py-2.5 text-xs text-violet-700">
<div class="flex items-center gap-2">
<Icon icon="eos-icons:loading" class="size-14px" />
<span>{{ aiAssistStep || 'AI 分析中…' }}({{ aiAssistProgress }}%)</span>
</div>
<NProgress type="line" :percentage="aiAssistProgress" :show-indicator="false" class="mt-1.5" processing />
</div>
<!-- 合规警告条(强制合规项,不可关闭;闭环后转绿) -->
<div
class="px-3 py-2.5 text-sm"
:class="mandClosed ? 'text-[var(--success-color)] bg-[var(--success-bg-color)]' : 'text-[var(--warning-color)] bg-[var(--warning-bg-color)]'"
:style="{
borderRadius:themeStore.themeRadius+'px',
'--success-color':themeStore.otherColor.success,
'--success-bg-color':getColorWithOpacity(themeStore.otherColor.success,0.1),
'--warning-color':themeStore.otherColor.warning,
'--warning-bg-color':getColorWithOpacity(themeStore.otherColor.warning,0.1)
}"
>
<div class="flex items-center gap-2">
<Icon icon="proicons:alert-triangle" class="size-16px" />
<span class="font-semibold">强制合规</span>
<span class="ml-auto shrink-0">
<NTag v-if="mandClosed" size="small" type="success">
<template #icon>
<Icon icon="lucide:shield-check" class="size-12px" />
</template>
已闭环
</NTag>
<NTag v-else size="small" type="warning">未闭环</NTag>
</span>
</div>
<p class="mt-1 text-xs leading-relaxed">
{{ mandText }}
</p>
<div v-if="!mandClosed" class="mt-1 text-right">
<NButton text type="primary" class="text-xs" @click="confirmClose">确认闭环(留痕)</NButton>
</div>
</div>
<!-- 变更冲突提示(同设备在途变更,可收起) -->
<div
v-if="!conflictHide"
class="bg-[var(--bg-color)] px-3 py-2.5 text-sm text-[var(--theme-color)]"
:style="{
borderRadius:themeStore.themeRadius+'px',
'--theme-color':themeStore.themeColor,
'--bg-color':getColorWithOpacity(themeStore.themeColor,0.1),
}"
>
<div class="flex items-center gap-2">
<Icon icon="lucide:bell" class="size-14px" />
<span class="font-semibold">冲突提示</span>
<NButton text type="primary" class="ml-auto shrink-0 text-xs" @click="conflictHide = true">知道了</NButton>
</div>
<p class="mt-1 text-xs leading-relaxed">
{{ conflictText }}
</p>
</div>
<!-- 属地影响摘要(仅属地负责人视角) -->
<div v-if="role === 'dept'" class="border" :style="{borderRadius:themeStore.themeRadius+'px'}">
<div class="flex items-center gap-2 border-b bg-slate-50/70 px-3 py-2">
<Icon icon="lucide:sparkles" class="size-16px text-violet-500" />
<span class="text-sm font-semibold text-slate-700">属地影响摘要</span>
<span class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
✨ AI 生成 · 需人工确认
</span>
</div>
<div class="space-y-1.5 px-3 py-2.5 text-xs leading-relaxed text-slate-600">
<p v-for="(r, i) in deptRows" :key="i"><b class="text-slate-700">{{ r.label }}</b>{{ r.text }}</p>
</div>
</div>
<!-- 变更前后关键参数对比(变化标蓝 / 超限标红) -->
<div
class="border"
:style="{
borderRadius:themeStore.themeRadius+'px',
'--error-color':themeStore.otherColor.error,
'--theme-color':themeStore.themeColor
}"
>
<div class="border-b bg-slate-50/70 px-3 py-2 text-xs font-semibold text-slate-600">
关键参数对比(AI 提取) · <span class="font-bold text-[var(--theme-color)]">蓝=变化</span> · <span class="font-bold text-[var(--error-color)]">红=超限</span>
</div>
<div v-for="d in paramDiff" :key="d.k" class="border-b px-3 py-2 last:border-b-0">
<div class="flex flex-wrap items-baseline gap-x-2 text-sm">
<span class="w-24 shrink-0 text-xs text-slate-500">{{ d.k }}</span>
<span class="text-xs text-slate-400">{{ d.before }}</span>
<span class="text-slate-300">→</span>
<span
class="text-sm font-semibold"
:class="d.over ? 'text-[var(--error-color)]' : 'text-[var(--theme-color)]'"
>{{ d.after }}</span>
<NTag v-if="d.over" size="small" type="error">超限</NTag>
</div>
<div
class="mt-0.5 text-[11px] leading-snug"
:class="d.over ? 'text-[var(--error-color)]' : 'text-slate-400'"
>
{{ d.note }}
</div>
</div>
</div>
<!-- AI 审批建议(版本冻结 · 建议分级 · 已阅留痕 · 无感审计) -->
<div
class="border"
:style="{
borderRadius:themeStore.themeRadius+'px',
'--error-color':themeStore.otherColor.error,
'--theme-color':themeStore.themeColor,
'--warning-color':themeStore.otherColor.warning,
}"
>
<div class="flex flex-wrap items-center gap-2 border-b bg-slate-50/70 px-3 py-2">
<Icon icon="lucide:sparkles" class="size-14px text-violet-500" />
<span class="text-sm font-semibold text-slate-700">AI 审批建议</span>
<span class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
✨ AI 生成 · 需人工确认
</span>
<NTag size="small">基于提交版本 V1 一次性分析 · 版本冻结</NTag>
<div class="ml-auto">
<NTag v-if="aiRead" size="small" type="success">✓ {{ roleName }} 已阅 AI 建议(已留痕)</NTag>
<NButton v-else size="small" class="text-xs" @click="readSuggest">已阅 AI 建议</NButton>
</div>
</div>
<div class="space-y-3 p-3">
<!-- 强制合规项 -->
<div class="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[var(--error-color)]"><span class="inline-block h-2 w-2 rounded-full bg-[var(--error-color)]" /> 强制合规项(驳回前必须处理,否则无法通过)</div>
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-red-100 bg-red-50/50 px-3 py-2 text-sm">
<span class="min-w-0 flex-1 text-slate-700">{{ mandItem }}</span>
<NTag v-if="mandClosed" size="small" type="success">已闭环</NTag>
<NTag v-else size="small" type="warning">未闭环</NTag>
</div>
<!-- 推荐改进项 -->
<div>
<div class="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[var(--warning-color)]"><span class="inline-block h-2 w-2 rounded-full bg-[var(--warning-color)]" /> 推荐改进项(可采纳为行动项,可忽略但须填理由)</div>
<div class="space-y-1.5">
<div v-for="s in suggs" :key="s.id" class="rounded-lg border px-3 py-2 text-sm">
<div class="leading-relaxed text-slate-700">{{ s.text }}</div>
<div class="mt-1.5">
<div v-if="s.st === '待定'" class="flex gap-3">
<NButton
text type="success" class="text-xs hover:underline"
@click="acceptSuggest(s)"
>
采纳为行动项
</NButton>
<NButton
text class="text-xs hover:underline"
@click="rejectSuggest(s)"
>
已知悉,不采纳
</NButton>
</div>
<NTag v-else-if="s.st === '已采纳'" size="small" type="success">已采纳 → 已生成 PSSR 行动项</NTag>
<NTag v-else size="small" type="default">已知悉不采纳(理由已留痕)</NTag>
</div>
</div>
</div>
</div>
<!-- 参考信息 -->
<div>
<div class="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[var(--theme-color)]"><span class="inline-block h-2 w-2 rounded-full bg-[var(--theme-color)]" /> 参考信息(纯参考,无强制力)</div>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-[#DFE9F6] bg-[#EAF0F9]/60 px-3 py-2 text-sm">
<span class="min-w-0 flex-1 text-slate-700">{{ similarText }}</span>
<NButton text type="primary" class="hover:underline text-xs" @click="viewArchive('MOC-2025-0112')">查看归档包</NButton>
</div>
<div class="rounded-lg border border-[#DFE9F6] bg-[#EAF0F9]/60 px-3 py-2 text-sm text-slate-700">
{{ experienceText }}
</div>
</div>
</div>
<div class="rounded-lg bg-slate-50 px-3 py-2 text-[11px] leading-5 text-slate-400">
查看、采纳、已阅等操作均由后台自动记录(无感审计),供内审追溯;AI 建议不强制采纳,决策主导权在审批人。驳回重新提交后,AI 将对新版本重新分析一次。
</div>
</div>
</div>
</div>
</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" :preview-file="previewFile" />
</div>
</template>
</NModal>
<!-- 驳回弹框 -->
<NModal
v-model:show="rejectModal"
preset="card"
:auto-focus="false"
:style="{ width: '450px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
>
<template #header>
<h3 class="text-sm font-bold text-slate-800">驳回变更申请</h3>
</template>
<template #default>
<p class="text-xs leading-5 text-slate-500">
快捷批注模板(点选填入,可叠加,仍可手动修改)
</p>
<div class="flex flex-wrap gap-2 mt-1">
<NTag v-for="item in rejectQuick" :key="item" class="cursor-pointer" size="small" type="error" @click="rejectReason += item + ';'">{{ item }}</NTag>
</div>
<NInput
v-model:value="rejectReason"
class="mt-3"
type="textarea"
:autosize="{ minRows: 3 }"
placeholder="驳回意见(必填),将退回至申请人/指定节点…"
/>
</template>
<template #footer>
<div class="flex justify-end gap-[10px]">
<NButton size="small" @click="rejectModal = false">取消</NButton>
<NButton size="small" type="primary" :loading="rejectSaving" @click="rejectSave">确定</NButton>
</div>
</template>
</NModal>
</NSpin>
</NSpace>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 277px);
overflow-y: auto;
}
</style>