1266 lines
66 KiB
Vue
1266 lines
66 KiB
Vue
<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> 分(<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>
|