Files
moc/src/views/details/closeDoc/index.vue
T
2026-10-10 13:07:10 +08:00

810 lines
42 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 { formatTimestamp, downloadFileFun } from '@/utils/common';
import { uploadFileApi, deleteFileApi, fileListApi } from "@/service/api/file";
import { systemConfigApi, peopleListApi, templateListApi } from '@/service/api/system';
import { getChangeDetailApi } from '@/service/api/change';
import { myTasksCloseMaterialApi } 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 backKey = ref<string>('');
// 当前变更详情
const currentInfo = ref<any>(null);
// 当前变更id
const currentId = ref<number>(0);
// 是否开启 AI 辅助功能
const isAiOn = ref<boolean>(false);
// 培训签到上传文件列表
const trainFiles = ref<any>([]);
// pssr上传文件列表
const pssrFiles = ref<any>([]);
// 验收上传文件列表
const acceptFiles = ref<any>([]);
// 实际投用日期
const useDate = ref<number | null>(null);
// 变更材料和实施费(万元)
const price = ref<number | null>(null);
// 验收状态
const resultStatus = ref<number>(0);
// 验收结论
const acceptDesc = ref<string>('');
// 验收时间
const acceptDate = ref<number | null>(null);
// 附件选择列表弹框
const filesModal = ref<boolean>(false);
// 选中的文件列表
const checkedFiles = ref<any>([]);
// 附件列表
const fileList = ref<any>([]);
// 当前从变更附件选择的row下标
const currentRowIndex = ref<number>(-1);
// 当前预览文件
const previewFile = ref<any>(null);
// 预览文件弹框
const previewModal = ref<boolean>(false);
// 成员列表
const memberList = ref<any[]>([]);
// 模板
const currentTemplate = ref<any[]>([]);
// 审批流程抽屉
const approvalDrawer = ref<boolean>(false);
const flowList = ref<any[]>([]);
// 审批链
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 SPECIALTIES = [
{ label: '生产', value: '生产' },
{ label: '技术', value: '技术' },
{ label: '仪表', value: '仪表' },
{ label: '电气', value: '电气' },
{ label: '设备', value: '设备' },
{ label: '安全', value: '安全' },
{ label: '质量', value: '质量' }
];
// 变更关闭资料归档列表
const list = ref<any>([]);
// 返回
const back = () => {
routerPushByKey(backKey.value as any);
};
// 上传文件
const onUploadFile = async (type:string = '',files:any,index:number = -1) => {
const file = files?.fileList[0]?.file;
if (!file) return;
const formData = new FormData();
formData.append('file', file);
// 3:培训签到;4:PSSR;5:验收
if(type==='3' || type==='4' || type==='5'){
formData.append('step', type);
}else{
formData.append('close_doc_id', list.value[index]?.id?.toString());
}
loading.value = true;
const {data,error} = await uploadFileApi(currentId.value, formData);
if(!error){
window.$message?.success('上传成功');
if(type==='3'){
trainFiles.value.push(data);
}else if(type==='4'){
pssrFiles.value.push(data);
}else if(type==='5'){
acceptFiles.value.push(data);
}else{
list.value[index].attachments.push(data);
list.value[index].status = 1;
}
}
loading.value = false;
}
// 下载文件
const downloadFile = (file:any,type:string) => {
if(file){
downloadFileFun(file?.file_url, file?.doc_name || file?.file_name);
}else{
window.$message?.info('还差接口,空白模板')
}
}
// 删除文件
const removeFileReal = async (type:string, fileIndex:number,index:number = -1) => {
let id = 0;
if(type==='3'){
id = trainFiles.value[fileIndex].id;
}else if(type==='4'){
id = pssrFiles.value[fileIndex].id;
}else if(type==='5'){
id = acceptFiles.value[fileIndex].id;
}else{
id = list.value[index]?.attachments[fileIndex]?.id;
}
loading.value = true;
const {error} = await deleteFileApi(currentId.value,id);
if(!error){
if(type==='3'){
trainFiles.value.splice(fileIndex, 1);
}else if(type==='4'){
pssrFiles.value.splice(fileIndex, 1);
}else if(type==='5'){
acceptFiles.value.splice(fileIndex, 1);
}else{
list.value[index]?.attachments.splice(fileIndex, 1);
if(list.value[index]?.attachments.length === 0){
list.value[index].status = 2;
}
}
window.$message?.success('删除成功');
}
loading.value = false;
}
// 打开预览
const preview = (file:any) => {
previewFile.value = file;
previewModal.value = true;
}
// 打开附件选择
const openFileList = (index:number) => {
currentRowIndex.value = index;
filesModal.value = true;
}
// 选择文件
const chooseFiles = () => {
// 过滤出选中的文件
const ids = new Set(checkedFiles.value);
const result = fileList.value.filter((item:any) => ids.has(item.id));
// 合并文件列表
let files = list.value[currentRowIndex.value]?.attachments.concat(result);
// 按id去重
const uniqueList = [
...new Map(files.map((item:any) => [item.id, item])).values(),
];
list.value[currentRowIndex.value].attachments = uniqueList;
if(uniqueList.length > 0){
list.value[currentRowIndex.value].status = 1;
}
filesModal.value = false;
}
// 保存/申请关闭
const submitCloseDoc = async (type:string) => {
if(!useDate.value){
return window.$message?.error('请选择实际投用日期');
}
if(!resultStatus.value){
return window.$message?.error('请选择验收结论');
}
const result = list.value.map(({ id, status }:{id:number,status:number} ) => ({ id, status }));
// 去掉id, step_order, user_name, status
const steps = flowList.value.map(({ members }:any) => ({
members: members.map(({ id, step_order, user_name, status, ...rest }:any) => rest)
}));
loading.value = true;
const {error} = await myTasksCloseMaterialApi(currentId.value,{
type:type,
result_status:resultStatus.value,
note:acceptDesc.value,
use_time:formatTimestamp(useDate.value,'yyyy-MM-dd'),
accept_time:formatTimestamp(acceptDate.value,'yyyy-MM-dd'),
cost:price.value,
close_docs:result,
approval_flow: {
steps: steps
}
});
if(!error){
if(type==='save'){
window.$message?.success('保存成功');
}
if(type==='close'){
back();
window.$message?.success('申请关闭成功');
}
}
loading.value = false;
}
// 获取变更详情
const getDetail = async () => {
loading.value = true;
const {data,error} = await getChangeDetailApi(currentId.value)
if(!error){
currentInfo.value = data;
list.value = data.close_docs || [];
trainFiles.value = data?.train?.attachments || [];
pssrFiles.value = data?.pssr?.attachments || [];
acceptFiles.value = data?.accept_form?.attachments || [];
if(data?.close_approval_flow && data?.close_approval_flow?.steps?.length){
flowList.value = data?.close_approval_flow?.steps;
}else{
flowList.value = currentTemplate.value;
}
}
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;
}
}
getDetail();
}
loading.value = false;
}
// 获取文件列表
const getFileList = async () => {
const {data,error} = await fileListApi(currentId.value);
if(!error){
fileList.value = data;
}
}
// 人员数据转换
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 finishApprover = async () => {
window.$message?.success("审批流程设置完成");
approvalDrawer.value = false;
}
// 获取所有模板
const getTemplateList = async () => {
loading.value = true;
const {data,error} = await templateListApi();
if(!error){
const result = data.filter((item:any) => item.type === 3);
// ******审批模板********
currentTemplate.value = result[0]?.steps || [];
}
loading.value = false;
}
onMounted(async () => {
useDate.value = new Date().getTime();
getAllMembers();
await getTemplateList();
if(route.query){
if(route.query.key){
backKey.value = route.query.key.toString();
}
if(route.query.id){
currentId.value = Number(route.query.id);
getFileList();
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>
</div>
<div class="space-y-3 scroll">
<!-- 投用前确认 -->
<div class="border rounded-md">
<div class="flex items-center gap-x-3 px-4 py-2.5 border-b">
<p class="font-600 text-slate-800 text-sm">投用前确认(含变更培训、投用前检查)</p>
<span class="text-slate-400 text-xs">上传线下纸质表照片 / 扫描件即视为完成确认,上传后可查阅、下载;空白模板可下载</span>
</div>
<div class="p-3 space-y-3">
<div class="border rounded-lg py-3 px-4 flex items-center gap-3">
<Icon icon="bi:upload" class="size-18px" :style="{ color: trainFiles.length?themeStore.otherColor.success:themeStore.themeColor }" />
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-slate-800 text-sm font-600">上传培训签到</span>
<p v-if="trainFiles.length" class="py-0.5 px-1.5 text-11px bg-emerald-100 rounded-md text-emerald-700">已上传 · 视为完成确认</p>
</div>
<p class="text-slate-400 text-xs mt-1 leading-4">线下培训签到表照片或扫描件;上传后视为培训完成确认,签到记录随单归档</p>
<div v-if="trainFiles.length" class="flex flex-wrap gap-2 mt-1">
<div
v-for="(file, fileIndex) in trainFiles"
:key="fileIndex"
class="text-11px flex items-center gap-2 border py-0.5 px-1.5 rounded-md text-[#17407F] border-[#ACC6E5] bg-[#EAF0F9]"
>
<Icon icon="akar-icons:file" class="size-12px" />
{{file.file_name}}
<NButton text type="primary" class="text-11px" @click="preview(file)">预览</NButton>
<NButton text type="primary" class="text-11px" @click="downloadFile(file,'train')">下载</NButton>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="removeFileReal('3', Number(fileIndex))"
>
<template #trigger>
<NButton text type="error" class="text-11px">删除</NButton>
</template>
确定删除吗?
</NPopconfirm>
</div>
</div>
</div>
<NButton ghost type="primary" size="small" class="text-xs" @click="downloadFile(null,'train')">
<Icon icon="akar-icons:download" class="size-14px mr-1" />
下载培训签到表
</NButton>
<NUpload
class="w-auto"
:key="trainFiles.length?'train_'+trainFiles.length+1:'train'"
accept="image/*,.pdf,.docx,.xlsx,.pptx"
:default-upload="false"
:show-file-list="false"
@change="(files) => onUploadFile('3', files)"
>
<NButton type="primary" size="small" class="text-xs" :disabled="loading">上传</NButton>
</NUpload>
</div>
<div class="border rounded-lg py-3 px-4 flex items-center gap-3">
<Icon icon="bi:upload" class="size-18px" :style="{ color: pssrFiles.length?themeStore.otherColor.success:themeStore.themeColor }" />
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-slate-800 text-sm font-600">上传 PSSR 检查</span>
<p v-if="pssrFiles.length" class="py-0.5 px-1.5 text-11px bg-emerald-100 rounded-md text-emerald-700">已上传 · 视为完成确认</p>
</div>
<p class="text-slate-400 text-xs mt-1 leading-4">PSSR 纸质确认表照片或扫描件;上传后视为 PSSR 全项确认完成,具备投用条件</p>
<div v-if="pssrFiles.length" class="flex flex-wrap gap-2 mt-1">
<div
v-for="(file, fileIndex) in pssrFiles"
:key="fileIndex"
class="text-11px flex items-center gap-2 border py-0.5 px-1.5 rounded-md text-[#17407F] border-[#ACC6E5] bg-[#EAF0F9]"
>
<Icon icon="akar-icons:file" class="size-12px" />
{{file.file_name}}
<NButton text type="primary" class="text-11px" @click="preview(file)">预览</NButton>
<NButton text type="primary" class="text-11px" @click="downloadFile(file,'pssr')">下载</NButton>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="removeFileReal('4', Number(fileIndex))"
>
<template #trigger>
<NButton text type="error" class="text-11px">删除</NButton>
</template>
确定删除吗?
</NPopconfirm>
</div>
</div>
</div>
<NButton ghost type="primary" size="small" class="text-xs" @click="downloadFile(null,'pssr')">
<Icon icon="akar-icons:download" class="size-14px mr-1" />
下载PSSR检查表
</NButton>
<NUpload
class="w-auto"
:key="pssrFiles.length?'pssr_'+pssrFiles.length+1:'pssr'"
accept="image/*,.pdf,.docx,.xlsx,.pptx"
:default-upload="false"
:show-file-list="false"
@change="(files) => onUploadFile('4', files)"
>
<NButton type="primary" size="small" class="text-xs" :disabled="loading">上传</NButton>
</NUpload>
</div>
</div>
</div>
<!-- 变更投用时间 -->
<div class="border rounded-md">
<div class="flex items-center gap-x-3 px-4 py-2.5 border-b">
<p class="font-600 text-slate-800 text-sm">变更投用时间</p>
</div>
<div class="p-3">
<div class="flex items-center gap-2">
<p class="text-sm text-slate-600">实际投用时间</p>
<NDatePicker v-model:value="useDate" type="date" size="small" />
<p class="text-xs text-slate-400 mt-1px">默认为当天日期,可按实际投用日期调整</p>
</div>
<div class="mt-2.5 rounded-lg border px-3 py-2 text-[11px] leading-5 border-amber-200 bg-amber-50/60 text-amber-700"> 投用提示:投用前应完成 PSSR 检查。 未完成的变更不得投用。</div>
</div>
</div>
<!-- 变更验收 -->
<div class="border rounded-md">
<div class="flex items-center gap-x-3 px-4 py-2.5 border-b">
<p class="font-600 text-slate-800 text-sm">变更验收</p>
<span class="text-slate-400 text-xs">投用后组织验收:上传验收资料并选择验收结论;上传后可查阅、下载;验收表可下载</span>
</div>
<div class="p-3 space-y-2.5">
<div class="border rounded-lg py-3 px-4 flex items-center gap-3">
<Icon icon="bi:upload" class="size-18px" :style="{ color: acceptFiles.length?themeStore.otherColor.success:themeStore.themeColor }" />
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-slate-800 text-sm font-600">上传验收资料</span>
<p v-if="pssrFiles.length" class="py-0.5 px-1.5 text-11px bg-emerald-100 rounded-md text-emerald-700">已上传 · 视为完成确认</p>
</div>
<p class="text-slate-400 text-xs mt-0.5 leading-4">验收报告、运行数据记录等验收证明材料;上传后纳入归档清单</p>
<div v-if="acceptFiles.length" class="flex flex-wrap gap-2 mt-1">
<div
v-for="(file, fileIndex) in acceptFiles"
:key="fileIndex"
class="text-11px flex items-center gap-2 border py-0.5 px-1.5 rounded-md text-[#17407F] border-[#ACC6E5] bg-[#EAF0F9]"
>
<Icon icon="akar-icons:file" class="size-12px" />
{{file.file_name}}
<NButton text type="primary" class="text-11px" @click="preview(file)">预览</NButton>
<NButton text type="primary" class="text-11px" @click="downloadFile(file,'accept')">下载</NButton>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="removeFileReal('5', Number(fileIndex))"
>
<template #trigger>
<NButton text type="error" class="text-11px">删除</NButton>
</template>
确定删除吗?
</NPopconfirm>
</div>
</div>
</div>
<NButton ghost type="primary" size="small" class="text-xs" @click="downloadFile(null,'accept')">
<Icon icon="akar-icons:download" class="size-14px mr-1" />
下载验收表
</NButton>
<NUpload
class="w-auto"
:key="acceptFiles.length?'accept_'+acceptFiles.length+1:'accept'"
accept="image/*,.pdf,.docx,.xlsx,.pptx"
:default-upload="false"
:show-file-list="false"
@change="(files) => onUploadFile('5', files)"
>
<NButton type="primary" size="small" class="text-xs" :disabled="loading">上传</NButton>
</NUpload>
</div>
<div class="border rounded-lg py-3 px-4 flex flex-col gap-2">
<div class="flex items-center gap-2">
<p class="text-xs font-600" :style="{ color: themeStore.themeColor }">验收结论(互斥选择)</p>
<NButton size="small" :type="resultStatus === 3?'primary':'default'" class="text-xs" @click="resultStatus = 3">完全达到</NButton>
<NButton size="small" :type="resultStatus === 2?'primary':'default'" class="text-xs" @click="resultStatus = 2">基本达到</NButton>
<NButton size="small" :type="resultStatus === 1?'primary':'default'" class="text-xs" @click="resultStatus = 1">未达到</NButton>
</div>
<NInput v-model:value="acceptDesc" type="textarea" size="small" :autosize="{ minRows: 2 }" placeholder="请输入验收结论" />
</div>
<div class="border rounded-lg py-3 px-4 flex items-center gap-2">
<p class="text-xs font-600" :style="{ color: themeStore.themeColor }">变更材料和实施费(万元)</p>
<NInputNumber v-model:value="price" size="small" :min="0" :show-button="false" placeholder="如 12.6" />
<p class="text-xs text-slate-400 mt-1px">非必填 · 填报本次变更材料与实施费用合计,用于变更投入与效果统计</p>
</div>
<div class="border rounded-lg py-3 px-4 flex items-center gap-2">
<p class="text-xs font-600" :style="{ color: themeStore.themeColor }">验收时间</p>
<NDatePicker v-model:value="acceptDate" type="date" size="small" />
</div>
</div>
</div>
<!-- 变更关闭资料归档确认 -->
<div class="border rounded-md">
<div class="flex items-center gap-x-3 px-4 py-2.5 border-b">
<p class="font-600 text-slate-800 text-sm">变更关闭资料归档确认</p>
<span class="text-slate-400 text-xs">是 = 已归档 · 否 = 待归档(本地上传或从变更附件选择,完成后自动转为「是」)· 不涉及 = 无需处理</span>
<NButton size="small" type="primary" ghost class="ml-auto text-xs">AI辅助判定</NButton>
</div>
<div class="p-3">
<NTable size="small">
<thead>
<tr>
<th>资料名称</th>
<th class="!text-center">是否涉及</th>
<th class="w-200px !text-center">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in list" :key="index">
<td>
<p>{{item.doc_name}}</p>
<div v-if="item.attachments.length" class="flex flex-wrap gap-2">
<div
v-for="(file, fileIndex) in item.attachments"
:key="fileIndex"
class="text-11px flex items-center gap-2 border py-0.5 px-1.5 rounded-md text-[#17407F] border-[#ACC6E5] bg-[#EAF0F9]"
>
<Icon icon="akar-icons:file" class="size-12px" />
{{file.file_name}}
<NButton text type="primary" class="text-11px" @click="preview(file)">预览</NButton>
<NButton text type="primary" class="text-11px" @click="downloadFile(file,'close')">下载</NButton>
<NPopconfirm
positive-text="确定"
:positiveButtonProps="{ size: 'tiny' }"
:negativeButtonProps="{ size: 'tiny' }"
@positive-click="removeFileReal(item.doc_name, Number(fileIndex), Number(index))"
>
<template #trigger>
<NButton text type="error" class="text-11px">删除</NButton>
</template>
确定删除吗?
</NPopconfirm>
</div>
</div>
</td>
<td>
<div class="flex justify-center gap-2">
<NButton size="small" :type="item.status === 1 ? 'primary' : 'default'" class="text-xs" @click="item.status = 1">涉及</NButton>
<NButton size="small" :type="item.status === 2 ? 'primary' : 'default'" class="text-xs" @click="item.status = 2">不涉及</NButton>
</div>
</td>
<td>
<div v-if="item.status === 1" class="flex justify-center gap-2">
<NButton size="small" class="text-xs" @click="openFileList(Number(index))">从变更附件选择</NButton>
<NUpload
class="w-auto"
:key="item.id"
accept="image/*,.pdf,.docx,.xlsx,.pptx"
:default-upload="false"
:show-file-list="false"
@change="(files) => onUploadFile(item.doc_name, files, Number(index))"
>
<NButton type="primary" size="small" ghost class="text-xs" :disabled="loading">
本地上传
</NButton>
</NUpload>
</div>
<div v-if="item.status === 2" class="flex justify-end gap-2">
<span class="text-xs text-slate-400">无需处理</span>
</div>
</td>
</tr>
</tbody>
</NTable>
</div>
</div>
</div>
<div class="flex items-center justify-end border-t pt-3 mt-3">
<div class="flex gap-2">
<NButton size="small" @click="approvalDrawer = true">审批流程设置</NButton>
<NButton ghost size="small" @click="back">取消</NButton>
<NButton ghost type="primary" size="small" @click="submitCloseDoc('save')">保存</NButton>
<NButton type="primary" size="small" @click="submitCloseDoc('close')">申请关闭</NButton>
</div>
</div>
<!-- 审批流程设置 -->
<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="filesModal"
preset="card"
title="从变更附件选择"
:auto-focus="false"
:style="{ width: '450px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
>
<div v-if="fileList.length" class="max-h-60vh overflow-y-auto">
<NCheckboxGroup v-model:value="checkedFiles">
<div class="space-y-2">
<div v-for="item in fileList" :key="item.id">
<NCheckbox :value="item.id" :label="item.file_name" />
</div>
</div>
</NCheckboxGroup>
</div>
<div v-else>
<NEmpty description="暂无附件"></NEmpty>
</div>
<template v-if="fileList.length" #footer>
<div class="flex justify-end gap-[10px]">
<NButton size="small" @click="filesModal = false">取消</NButton>
<NButton size="small" type="primary" @click="chooseFiles">确定选择</NButton>
</div>
</template>
</NModal>
<!-- 附件在线预览弹窗 -->
<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>
</NCard>
</NSpin>
</NSpace>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 263px);
overflow-y: auto;
}
</style>