admin-vue3/src/views/project/acceptance/detail/index.vue

1055 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="p-4 bg-gray-50 min-h-screen">
<div v-loading="loading">
<!-- 头部 Hero Section -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div>
<div class="flex items-center gap-3 mb-2">
<h2 class="text-2xl font-bold text-gray-800 tracking-tight">
{{ projectInfo.projectName || '项目验收' }}
</h2>
<el-tag
v-if="acceptance.acceptanceType === 'PRE'"
type="info"
effect="plain"
class="!rounded-full !px-3 font-medium border-gray-300"
>
预验收
</el-tag>
<el-tag
v-else-if="acceptance.acceptanceType === 'FINAL'"
type="primary"
effect="plain"
class="!rounded-full !px-3 font-medium border-blue-200"
>
终验
</el-tag>
</div>
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500">
<div class="flex items-center gap-1">
<Icon icon="ep:collection-tag" class="text-gray-400" />
<span>项目编号:<span class="text-gray-700 font-medium">{{ projectInfo.projectCode }}</span></span>
</div>
<div class="hidden md:block w-px h-3.5 bg-gray-300"></div>
<div class="flex items-center gap-1">
<Icon icon="ep:hash" class="text-gray-400" />
<span>验收ID<span class="text-gray-700 font-medium">{{ acceptance.id }}</span></span>
</div>
<div class="hidden md:block w-px h-3.5 bg-gray-300"></div>
<div class="flex items-center gap-1">
<Icon icon="ep:refresh" class="text-gray-400" />
<span>轮次:<span class="text-gray-700 font-medium">R{{ acceptance.round }}</span></span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<el-tag :type="getStatusType(acceptance.status)" size="large" effect="dark" class="!px-4 !py-1.5 !text-base !font-semibold !rounded-lg shadow-sm">
{{ getStatusLabel(acceptance.status) }}
</el-tag>
</div>
</div>
<!-- 流程步骤 -->
<div class="px-4 py-2 bg-gray-50 rounded-lg border border-gray-100">
<el-steps :active="activeStep" finish-status="success" align-center class="custom-steps">
<el-step title="提交材料" description="待提交/完善材料" />
<el-step title="初步审核" description="对口人/组长审核" />
<el-step title="终验申请" description="确认进入终验" />
<el-step title="终验评审" description="会议评审与整改" />
<el-step title="归档完成" description="流程结束" />
</el-steps>
</div>
</div>
<!-- 主要内容区域 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左侧:详细信息 (占据 2/3 宽度) -->
<div class="lg:col-span-2 space-y-6">
<!-- Tab切换 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden min-h-[500px]">
<el-tabs v-model="activeTab" class="custom-tabs">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="info">
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
<div class="space-y-4">
<h3 class="text-base font-bold text-gray-900 flex items-center gap-2 pb-2 border-b border-gray-100">
<Icon icon="ep:user" /> 人员信息
</h3>
<div class="grid grid-cols-[80px_1fr] gap-y-3 text-sm">
<span class="text-gray-500 text-right">项目联络人:</span>
<span class="font-medium text-gray-800">{{ liaisonUserName || '-' }}</span>
<span class="text-gray-500 text-right">对口人:</span>
<span class="font-medium text-gray-800">{{ serviceUserName || '-' }}</span>
</div>
</div>
<div class="space-y-4">
<h3 class="text-base font-bold text-gray-900 flex items-center gap-2 pb-2 border-b border-gray-100">
<Icon icon="ep:timer" /> 时间及流程
</h3>
<div class="grid grid-cols-[100px_1fr] gap-y-3 text-sm">
<span class="text-gray-500 text-right">开始时间:</span>
<span class="font-medium text-gray-800">{{ formatDate(acceptance.startTime) }}</span>
<span class="text-gray-500 text-right">流程实例ID:</span>
<a class="text-blue-600 font-mono underline cursor-pointer hover:text-blue-800" :title="acceptance.processInstanceId">{{ acceptance.processInstanceId || '-' }}</a>
<span class="text-gray-500 text-right">创建时间:</span>
<span class="font-medium text-gray-800">{{ formatDate(acceptance.createTime) }}</span>
</div>
</div>
</div>
</div>
</el-tab-pane>
<!-- 验收材料 -->
<el-tab-pane label="验收材料" name="materials">
<div class="p-6">
<!-- 联络人视图:显示当前阶段材料 -->
<template v-if="isLiaisonUser">
<div class="mb-6" v-if="canUploadMaterial">
<el-alert
type="primary"
:closable="false"
show-icon
class="!bg-blue-50 !border-blue-100 !text-blue-800"
>
<template #title>
<span class="font-bold text-base">待办任务:上传验收材料</span>
</template>
<template #default>
<p class="mt-1 text-sm text-blue-600">请完成下方所有必填材料的上传,确认无误后点击底部的【提交材料】按钮进入审核流程。</p>
</template>
</el-alert>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<template v-for="(item, index) in materials" :key="index">
<div class="group relative border border-gray-200 rounded-xl p-4 transition-all duration-300 hover:shadow-md hover:border-blue-200 hover:bg-blue-50/20 bg-white">
<div class="flex items-start gap-4">
<!-- 文件图标 -->
<div class="w-12 h-12 flex items-center justify-center rounded-lg shrink-0 transition-transform group-hover:scale-105"
:class="[getFileIconColor(item.fileName), 'bg-opacity-10']">
<Icon :icon="getFileIcon(item.fileName)" size="26" :class="item.fileUrl ? '' : 'grayscale opacity-50'" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start">
<h4 class="font-semibold text-gray-800 truncate pr-2 text-[15px] group-hover:text-blue-700" :title="item.materialName">{{ item.materialName }}</h4>
<span v-if="item.isRequired" class="text-[10px] font-bold text-red-500 bg-red-50 px-1.5 py-0.5 rounded border border-red-100 shrink-0">必填</span>
</div>
<div class="mt-1 mb-3 h-5">
<template v-if="item.fileUrl">
<div class="flex items-center gap-1 text-xs text-gray-500 truncate">
<Icon icon="ep:document" class="text-gray-400" />
<span class="truncate underline cursor-pointer hover:text-blue-600" @click="handlePreview(item)">{{ item.fileName }}</span>
</div>
</template>
<span v-else class="text-xs text-gray-400 italic">暂未上传</span>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-2 pt-2 border-t border-gray-50 mt-2 opacity-60 group-hover:opacity-100 transition-opacity">
<!-- 预览 -->
<el-tooltip content="预览" placement="top" v-if="item.fileUrl">
<button @click="handlePreview(item)" class="p-2 rounded-full hover:bg-blue-50 text-gray-500 hover:text-blue-600 transition-all border-none outline-none focus:outline-none">
<Icon icon="ep:view" size="20" />
</button>
</el-tooltip>
<!-- 下载 -->
<el-tooltip content="下载" placement="top" v-if="item.fileUrl">
<button @click="handleDownload(item)" class="p-2 rounded-full hover:bg-green-50 text-gray-500 hover:text-green-600 transition-all border-none outline-none focus:outline-none">
<Icon icon="ep:download" size="20" />
</button>
</el-tooltip>
<!-- 上传/重新上传 -->
<div v-if="canUploadMaterial">
<el-upload
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="(res, file) => handleUploadSuccess(res, file, item)"
>
<el-tooltip :content="item.fileUrl ? '重新上传' : '上传文件'" placement="top">
<button class="p-2 rounded-full hover:bg-blue-50 text-gray-500 hover:text-blue-600 transition-all border-none outline-none focus:outline-none">
<Icon :icon="item.fileUrl ? 'ep:refresh' : 'ep:upload'" size="20" />
</button>
</el-tooltip>
</el-upload>
</div>
</div>
</div>
</div>
<!-- Corner Status Mark -->
<div v-if="item.fileUrl" class="absolute -top-[1px] -right-[1px] w-0 h-0 border-t-[32px] border-r-[32px] border-t-transparent border-r-green-500 rounded-tr-xl">
<Icon icon="ep:check" class="absolute -right-[26px] -top-[26px] text-white text-xs"/>
</div>
</div>
</template>
</div>
</template>
<!-- 其他角色视图:分组显示预验收和终验材料 -->
<template v-else>
<!-- 预验收材料区域 -->
<div class="mb-8">
<h3 class="text-lg font-bold mb-4">
<span class="inline-block px-4 py-2 rounded-lg bg-blue-100 text-blue-800 w-40 text-center">预验收材料</span>
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<template v-for="(item, index) in preMaterials" :key="'pre-' + index">
<div class="group relative border border-gray-200 rounded-xl p-4 transition-all duration-300 hover:shadow-md hover:border-blue-200 hover:bg-blue-50/20 bg-white">
<div class="flex items-start gap-4">
<!-- 文件图标 -->
<div class="w-12 h-12 flex items-center justify-center rounded-lg shrink-0 transition-transform group-hover:scale-105"
:class="[getFileIconColor(item.fileName), 'bg-opacity-10']">
<Icon :icon="getFileIcon(item.fileName)" size="26" :class="item.fileUrl ? '' : 'grayscale opacity-50'" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start">
<h4 class="font-semibold text-gray-800 truncate pr-2 text-[15px] group-hover:text-blue-700" :title="item.materialName">{{ item.materialName }}</h4>
<span v-if="item.isRequired" class="text-[10px] font-bold text-red-500 bg-red-50 px-1.5 py-0.5 rounded border border-red-100 shrink-0">必填</span>
</div>
<div class="mt-1 mb-3 h-5">
<template v-if="item.fileUrl">
<div class="flex items-center gap-1 text-xs text-gray-500 truncate">
<Icon icon="ep:document" class="text-gray-400" />
<span class="truncate underline cursor-pointer hover:text-blue-600" @click="handlePreview(item)">{{ item.fileName }}</span>
</div>
</template>
<span v-else class="text-xs text-gray-400 italic">暂未上传</span>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-2 pt-2 border-t border-gray-50 mt-2 opacity-60 group-hover:opacity-100 transition-opacity">
<!-- 预览 -->
<el-tooltip content="预览" placement="top" v-if="item.fileUrl">
<button @click="handlePreview(item)" class="p-2 rounded-full hover:bg-blue-50 text-gray-500 hover:text-blue-600 transition-all border-none outline-none focus:outline-none">
<Icon icon="ep:view" size="20" />
</button>
</el-tooltip>
<!-- 下载 -->
<el-tooltip content="下载" placement="top" v-if="item.fileUrl">
<button @click="handleDownload(item)" class="p-2 rounded-full hover:bg-green-50 text-gray-500 hover:text-green-600 transition-all border-none outline-none focus:outline-none">
<Icon icon="ep:download" size="20" />
</button>
</el-tooltip>
<!-- 历史版本 -->
<el-tooltip :content="'历史版本 (' + item.versionCount + ')'" placement="top" v-if="item.versionCount > 1">
<button @click="showVersionHistory(item)" class="p-2 rounded-full hover:bg-orange-50 text-gray-500 hover:text-orange-600 transition-all relative border-none outline-none focus:outline-none">
<Icon icon="ep:clock" size="20" />
<!-- 小红点提示 -->
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 bg-orange-500 rounded-full border border-white"></span>
</button>
</el-tooltip>
</div>
</div>
</div>
<!-- Corner Status Mark -->
<div v-if="item.fileUrl" class="absolute -top-[1px] -right-[1px] w-0 h-0 border-t-[32px] border-r-[32px] border-t-transparent border-r-green-500 rounded-tr-xl">
<Icon icon="ep:check" class="absolute -right-[26px] -top-[26px] text-white text-xs"/>
</div>
</div>
</template>
<div v-if="preMaterials.length === 0" class="col-span-2 text-center py-8 text-gray-400">
暂无预验收材料
</div>
</div>
</div>
<!-- 终验材料区域 -->
<div>
<h3 class="text-lg font-bold mb-4">
<span class="inline-block px-4 py-2 rounded-lg bg-purple-100 text-purple-800 w-40 text-center">终验材料</span>
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<template v-for="(item, index) in finalMaterials" :key="'final-' + index">
<div class="group relative border border-gray-200 rounded-xl p-4 transition-all duration-300 hover:shadow-md hover:border-blue-200 hover:bg-blue-50/20 bg-white">
<div class="flex items-start gap-4">
<!-- 文件图标 -->
<div class="w-12 h-12 flex items-center justify-center rounded-lg shrink-0 transition-transform group-hover:scale-105"
:class="[getFileIconColor(item.fileName), 'bg-opacity-10']">
<Icon :icon="getFileIcon(item.fileName)" size="26" :class="item.fileUrl ? '' : 'grayscale opacity-50'" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-start">
<h4 class="font-semibold text-gray-800 truncate pr-2 text-[15px] group-hover:text-blue-700" :title="item.materialName">{{ item.materialName }}</h4>
<span v-if="item.isRequired" class="text-[10px] font-bold text-red-500 bg-red-50 px-1.5 py-0.5 rounded border border-red-100 shrink-0">必填</span>
</div>
<div class="mt-1 mb-3 h-5">
<template v-if="item.fileUrl">
<div class="flex items-center gap-1 text-xs text-gray-500 truncate">
<Icon icon="ep:document" class="text-gray-400" />
<span class="truncate underline cursor-pointer hover:text-blue-600" @click="handlePreview(item)">{{ item.fileName }}</span>
</div>
</template>
<span v-else class="text-xs text-gray-400 italic">暂未上传</span>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-2 pt-2 border-t border-gray-50 mt-2 opacity-60 group-hover:opacity-100 transition-opacity">
<!-- 预览 -->
<el-tooltip content="预览" placement="top" v-if="item.fileUrl">
<button @click="handlePreview(item)" class="p-2 rounded-full hover:bg-blue-50 text-gray-500 hover:text-blue-600 transition-all border-none outline-none focus:outline-none">
<Icon icon="ep:view" size="20" />
</button>
</el-tooltip>
<!-- 下载 -->
<el-tooltip content="下载" placement="top" v-if="item.fileUrl">
<button @click="handleDownload(item)" class="p-2 rounded-full hover:bg-green-50 text-gray-500 hover:text-green-600 transition-all border-none outline-none focus:outline-none">
<Icon icon="ep:download" size="20" />
</button>
</el-tooltip>
<!-- 历史版本 -->
<el-tooltip :content="'历史版本 (' + item.versionCount + ')'" placement="top" v-if="item.versionCount > 1">
<button @click="showVersionHistory(item)" class="p-2 rounded-full hover:bg-orange-50 text-gray-500 hover:text-orange-600 transition-all relative border-none outline-none focus:outline-none">
<Icon icon="ep:clock" size="20" />
<!-- 小红点提示 -->
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 bg-orange-500 rounded-full border border-white"></span>
</button>
</el-tooltip>
</div>
</div>
</div>
<!-- Corner Status Mark -->
<div v-if="item.fileUrl" class="absolute -top-[1px] -right-[1px] w-0 h-0 border-t-[32px] border-r-[32px] border-t-transparent border-r-green-500 rounded-tr-xl">
<Icon icon="ep:check" class="absolute -right-[26px] -top-[26px] text-white text-xs"/>
</div>
</div>
</template>
<div v-if="finalMaterials.length === 0" class="col-span-2 text-center py-8 text-gray-400">
暂无终验材料
</div>
</div>
</div>
</template>
</div>
</el-tab-pane>
<!-- 审批记录 -->
<el-tab-pane label="审批记录" name="opinions">
<div class="p-6">
<el-timeline class="pl-2">
<el-timeline-item
v-for="(opinion, index) in opinions"
:key="index"
:type="getOpinionType(opinion.result)"
:color="getOpinionColor(opinion.result)"
:timestamp="formatDate(opinion.createTime)"
placement="top"
size="large"
>
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow relative">
<!-- Arrow -->
<div class="absolute w-3 h-3 bg-white border-l border-b border-gray-200 transform rotate-45 -left-[7px] top-4"></div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-gray-900">{{ opinion.reviewerRole || '审核人' }}</span>
<span class="text-gray-500 text-sm">- {{ opinion.reviewerName || '未知' }}</span>
<el-tag v-if="opinion.isLeader" type="warning" size="small" effect="dark" class="rounded-full">组长</el-tag>
</div>
<el-tag :type="getOpinionType(opinion.result)" effect="plain" class="font-bold uppercase tracking-wider">
{{ getOpinionLabel(opinion.result) }}
</el-tag>
</div>
<p class="text-gray-600 text-sm leading-relaxed mb-3 bg-gray-50 p-2 rounded">
{{ opinion.opinion || '无意见' }}
</p>
<div v-if="opinion.signatureUrl" class="border-t border-gray-100 pt-2 flex justify-end">
<el-image
:src="opinion.signatureUrl"
class="h-10 w-auto opacity-80 hover:opacity-100"
fit="contain"
/>
</div>
</div>
</el-timeline-item>
<el-empty v-if="opinions.length === 0" description="暂无审批记录" :image-size="100" />
</el-timeline>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
<!-- 右侧:操作区 (占据 1/3 宽度) -->
<div class="lg:col-span-1 space-y-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 sticky top-4">
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
<Icon icon="ep:operation" class="text-blue-500" /> 快捷操作
</h3>
<div class="space-y-3 flex flex-col">
<el-button
v-if="canSubmitMaterial"
type="primary"
size="large"
class="!w-full !justify-start !text-left !py-5"
@click="handleSubmitMaterial"
>
<Icon icon="ep:upload-filled" class="mr-2 text-xl" />
<div class="flex flex-col items-start leading-tight">
<span class="font-bold">提交材料</span>
<span class="text-xs opacity-80 font-normal">确认所有必填材料已上传</span>
</div>
</el-button>
<el-button
v-if="canAudit"
type="success"
size="large"
class="!w-full !justify-start !text-left !py-5"
@click="openAuditDialog"
>
<Icon icon="ep:checked" class="mr-2 text-xl" />
<div class="flex flex-col items-start leading-tight">
<span class="font-bold">审核通过/驳回</span>
<span class="text-xs opacity-80 font-normal">对提交内容进行审批</span>
</div>
</el-button>
<el-button
v-if="canSubmitRectify"
type="warning"
size="large"
class="!w-full !justify-start !text-left !py-5"
@click="handleSubmitRectify"
>
<Icon icon="ep:refresh-right" class="mr-2 text-xl" />
<div class="flex flex-col items-start leading-tight">
<span class="font-bold">提交整改</span>
<span class="text-xs opacity-80 font-normal">整改完成后再次提交</span>
</div>
</el-button>
<el-button
v-if="canSubmitFinal"
type="primary"
plain
size="large"
class="!w-full !justify-start !text-left !py-5"
@click="handleSubmitFinal"
>
<Icon icon="ep:flag" class="mr-2 text-xl" />
<div class="flex flex-col items-start leading-tight">
<span class="font-bold">提交终验申请</span>
<span class="text-xs opacity-80 font-normal">初审通过,进入终验</span>
</div>
</el-button>
<el-divider class="!my-4" />
<el-button @click="goBack" class="!w-full" plain>
<Icon icon="ep:back" class="mr-1" /> 返回列表
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 审核对话框 -->
<el-dialog
v-model="auditDialogVisible"
title="验收审核"
width="500px"
:close-on-click-modal="false"
class="rounded-xl overflow-hidden"
>
<div class="p-2">
<el-form
ref="auditFormRef"
:model="auditForm"
:rules="auditFormRules"
label-width="100px"
label-position="top"
>
<el-form-item label="审核结果" prop="result">
<div class="flex w-full gap-4">
<div
class="flex-1 border rounded-lg p-4 cursor-pointer text-center transition-all hover:shadow-md"
:class="auditForm.result === 'PASS' ? 'border-green-500 bg-green-50 text-green-700' : 'border-gray-200 text-gray-500 hover:border-green-200'"
@click="auditForm.result = 'PASS'"
>
<Icon icon="ep:circle-check-filled" class="text-3xl mb-2" :class="auditForm.result === 'PASS' ? 'text-green-500' : 'text-gray-300'" />
<div class="font-bold">通过</div>
</div>
<div
class="flex-1 border rounded-lg p-4 cursor-pointer text-center transition-all hover:shadow-md"
:class="auditForm.result === 'REJECT' ? 'border-red-500 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500 hover:border-red-200'"
@click="auditForm.result = 'REJECT'"
>
<Icon icon="ep:circle-close-filled" class="text-3xl mb-2" :class="auditForm.result === 'REJECT' ? 'text-red-500' : 'text-gray-300'" />
<div class="font-bold">驳回</div>
</div>
</div>
</el-form-item>
<el-form-item label="审核意见" prop="opinion">
<el-input
v-model="auditForm.opinion"
type="textarea"
:rows="4"
:placeholder="auditForm.result === 'REJECT' ? '请务必填写驳回理由...' : '请填写审核意见(可选)...'"
class="!bg-gray-50"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="flex justify-end gap-2 pt-2">
<el-button @click="auditDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleAudit" class="px-6">确认提交</el-button>
</div>
</template>
</el-dialog>
<!-- 历史版本对话框 -->
<el-dialog
v-model="versionHistoryVisible"
:title="'历史版本 - ' + currentMaterialName"
width="600px"
:close-on-click-modal="false"
class="rounded-xl overflow-hidden"
>
<div class="p-4 max-h-[60vh] overflow-y-auto">
<el-timeline>
<el-timeline-item
v-for="(version, index) in currentVersions"
:key="version.id"
:timestamp="formatDate(version.createTime)"
placement="top"
:type="index === 0 ? 'primary' : ''"
:hollow="index !== 0"
>
<div class="bg-gray-50 rounded-lg p-4 border border-gray-100 hover:shadow-sm transition-shadow">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="font-bold text-gray-800">V{{ currentVersions.length - index }}</span>
<el-tag v-if="index === 0" size="small" type="success" effect="dark" class="rounded-full">最新</el-tag>
</div>
<div class="flex gap-2">
<el-button size="small" text bg type="primary" @click="handlePreview(version)">
<Icon icon="ep:view" class="mr-1" /> 预览
</el-button>
<el-button size="small" text bg type="success" @click="handleDownload(version)">
<Icon icon="ep:download" class="mr-1" /> 下载
</el-button>
</div>
</div>
<div class="text-sm text-gray-600 grid grid-cols-2 gap-2 mt-2">
<div class="flex items-center gap-1">
<Icon icon="ep:document" class="text-gray-400" />
<span class="truncate" :title="version.fileName">{{ version.fileName }}</span>
</div>
<div class="flex items-center gap-1">
<Icon icon="ep:data-line" class="text-gray-400" />
<span>{{ formatFileSize(version.fileSize) }}</span>
</div>
</div>
</div>
</el-timeline-item>
</el-timeline>
</div>
<template #footer>
<div class="flex justify-end pt-2">
<el-button @click="versionHistoryVisible = false"></el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { formatDate } from '@/utils/formatTime'
import * as AcceptanceApi from '@/api/project/acceptance'
import * as ProjectApi from '@/api/project/project'
import * as AcceptanceMaterialApi from '@/api/project/acceptanceMaterial'
import * as AcceptanceMaterialDefApi from '@/api/project/acceptanceMaterialDef'
import * as AcceptanceOpinionApi from '@/api/project/acceptanceOpinion'
import { getAccessToken } from '@/utils/auth'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'AcceptanceDetail' })
const route = useRoute()
const router = useRouter()
const message = useMessage()
const loading = ref(true)
// 从路由参数读取默认tab默认显示基本信息tab
const activeTab = ref((route.query.tab as string) || 'info')
// 验收信息
const acceptance = ref<AcceptanceApi.AcceptanceVO>({} as AcceptanceApi.AcceptanceVO)
// 项目信息
const projectInfo = ref<any>({})
// 用户名称
const liaisonUserName = ref('')
const serviceUserName = ref('')
// 验收材料(联络人使用)
const materials = ref<any[]>([])
// 预验收材料(其他角色使用)
const preMaterials = ref<any[]>([])
// 终验材料(其他角色使用)
const finalMaterials = ref<any[]>([])
// 审批意见
const opinions = ref<any[]>([])
// 判断当前用户是否为项目联络人
const isLiaisonUser = computed(() => {
const currentUserId = useUserStore().getUser.id
return projectInfo.value.liaisonUserId === currentUserId
})
// 上传配置
const uploadUrl = import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/infra/file/upload'
const uploadHeaders = { Authorization: 'Bearer ' + getAccessToken() }
// 状态映射
const statusMap: Record<string, { label: string; type: string }> = {
'00': { label: '草稿', type: 'info' },
'05': { label: '待提交材料', type: 'warning' },
'10': { label: '对口人初审中', type: 'primary' },
'11': { label: '待整改', type: 'danger' },
'20': { label: '组长审核中', type: 'primary' },
'30': { label: '待终验申请', type: 'info' },
'40': { label: '管理员初审中', type: 'primary' }, // 对应 FINAL_ADMIN_REVIEW
'50': { label: '待会议评审', type: 'info' }, // WAIT_MEETING
'60': { label: '待整改', type: 'danger' }, // FINAL_RECTIFY
'61': { label: '整改审核中', type: 'warning' }, // FINAL_RECTIFY_REVIEW
'62': { label: '待专家复核', type: 'warning' }, // WAIT_EXPERT_CHECK
'98': { label: '已取消', type: 'info' },
'99': { label: '已归档', type: 'success' }
}
const getStatusLabel = (status: string) => statusMap[status]?.label || status
const getStatusType = (status: string) => statusMap[status]?.type || 'info'
// 审核结果映射
const getOpinionLabel = (result: string) => {
if (result === 'PASS') return '通过'
if (result === 'REJECT') return '驳回'
return result || '未知'
}
const getOpinionType = (result: string) => {
if (result === 'PASS') return 'success'
if (result === 'REJECT') return 'danger'
return 'info'
}
const getOpinionColor = (result: string) => {
if (result === 'PASS') return '#67C23A'
if (result === 'REJECT') return '#F56C6C'
return '#909399'
}
// 权限判断
// 权限判断
const canUploadMaterial = computed(() => {
const currentUserId = useUserStore().getUser.id
// 必须是联络人,且状态符合:待提交材料(05)、预验收整改(11)、待终验申请(30)、终验整改(60)
return projectInfo.value.liaisonUserId === currentUserId && ['05', '11', '30', '60'].includes(acceptance.value.status)
})
const canSubmitMaterial = computed(() => {
const currentUserId = useUserStore().getUser.id
return projectInfo.value.liaisonUserId === currentUserId && acceptance.value.status === '05'
})
const canSubmitRectify = computed(() => {
const currentUserId = useUserStore().getUser.id
return projectInfo.value.liaisonUserId === currentUserId && ['11', '60'].includes(acceptance.value.status)
})
const canSubmitFinal = computed(() => {
const currentUserId = useUserStore().getUser.id
return projectInfo.value.liaisonUserId === currentUserId && acceptance.value.status === '30' // WAIT_FINAL_APPLY
})
const showActions = computed(() => {
return canSubmitMaterial.value || canSubmitRectify.value || canSubmitFinal.value || canAudit.value
})
// 是否可以审核 (对口人初审: 10, 专家组长审核: 20, 管理员终验初审: 40)
const canAudit = computed(() => {
const currentUserId = useUserStore().getUser.id
const status = acceptance.value.status
// 状态 10: 对口人初审,只有对口人可审核
if (status === '10') {
return projectInfo.value.serviceUserId === currentUserId
}
// 状态 20: 专家组长审核,显示审核按钮(后端会校验权限)
if (status === '20') {
return true
}
// 状态 40: 管理员终验初审,显示审核按钮(后端会校验权限)
if (status === '40') {
return true
}
return false
})
// 审核对话框
const auditDialogVisible = ref(false)
const auditForm = ref({
result: '',
opinion: ''
})
const auditFormRef = ref()
const auditFormRules = {
result: [{ required: true, message: '请选择审核结果', trigger: 'change' }],
opinion: [{ required: true, message: '请填写审核意见', trigger: 'blur' }]
}
// 历史版本对话框
const versionHistoryVisible = ref(false)
const currentVersions = ref<any[]>([])
const currentMaterialName = ref('')
/** 显示历史版本 */
const showVersionHistory = (item: any) => {
currentMaterialName.value = item.materialName
currentVersions.value = item.versions || []
versionHistoryVisible.value = true
}
/** 格式化文件大小 */
const formatFileSize = (bytes: number) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
/** 打开审核对话框 */
const openAuditDialog = () => {
auditForm.value = { result: '', opinion: '' }
auditDialogVisible.value = true
}
/** 提交审核 */
const handleAudit = async () => {
if (!auditFormRef.value) return
const valid = await auditFormRef.value.validate().catch(() => false)
if (!valid) return
try {
const auditData = {
acceptanceId: acceptance.value.id,
result: auditForm.value.result,
opinion: auditForm.value.opinion
}
// 根据状态调用不同的审核接口
const status = acceptance.value.status
if (status === '40') {
// 管理员终验初审
await AcceptanceApi.auditFinalAdmin(auditData)
} else {
// 预验收审核对口人初审10、组长审核20
await AcceptanceApi.auditPreAcceptance(auditData)
}
message.success('审核提交成功')
auditDialogVisible.value = false
await getDetail()
} catch (error) {
// Error handled by request interceptor
}
}
/** 获取验收详情 */
const getDetail = async () => {
loading.value = true
try {
const id = Number(route.params.id)
acceptance.value = await AcceptanceApi.getAcceptance(id)
// 获取项目信息(后端已填充用户名称)
if (acceptance.value.projectId) {
projectInfo.value = await ProjectApi.getProject(acceptance.value.projectId)
// 直接使用后端返回的用户名称
liaisonUserName.value = projectInfo.value.liaisonUserName || '-'
serviceUserName.value = projectInfo.value.serviceUserName || '-'
}
// 获取验收材料
// 2. 获取已上传材料
const uploads = await AcceptanceMaterialApi.getListByAcceptanceId(id) as unknown as any[]
// 材料合并辅助函数
const mergeMaterials = (defs: any[]) => {
return defs.map((def: any) => {
// 查找该材料代码对应的所有上传记录
const allUploads = uploads.filter((u: any) => u.materialCode === def.materialCode)
// 按ID倒序排序最新的在前
const sortedUploads = allUploads.sort((a: any, b: any) => b.id - a.id)
// 取最新的一条
const upload = sortedUploads.length > 0 ? sortedUploads[0] : undefined
return {
...def,
materialType: def.requiredFlag ? '必填' : '可选',
isRequired: def.requiredFlag, // 方便模板使用
fileUrl: upload?.fileUrl,
fileName: upload?.fileName,
fileSize: upload?.fileSize,
uploadId: upload?.id,
acceptanceId: id,
// 新增:保存所有历史版本
versions: sortedUploads, // 包含所有上传记录
versionCount: sortedUploads.length // 版本数量
}
})
}
// 判断当前用户是否为联络人
const currentUserId = useUserStore().getUser.id
const isLiaison = projectInfo.value.liaisonUserId === currentUserId
if (isLiaison) {
// 联络人:只显示当前阶段材料
// 1. 获取材料定义状态30时需要显示终验材料
const materialType = acceptance.value.status === '30' ? 'FINAL' : acceptance.value.acceptanceType
const defs = await AcceptanceMaterialDefApi.getListByType(materialType)
// 3. 合并数据
materials.value = mergeMaterials(defs)
} else {
// 其他角色:显示预验收和终验两个阶段的材料
// 1. 获取预验收和终验材料定义
const preDefs = await AcceptanceMaterialDefApi.getListByType('PRE')
const finalDefs = await AcceptanceMaterialDefApi.getListByType('FINAL')
// 3. 分别合并数据
preMaterials.value = mergeMaterials(preDefs)
finalMaterials.value = mergeMaterials(finalDefs)
}
// 获取审批意见列表
// 获取审批意见列表
const res = await AcceptanceOpinionApi.getListByAcceptanceId(id)
opinions.value = res.sort((a: any, b: any) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime())
} catch (error) {
console.error(error)
message.error('获取验收详情失败')
} finally {
loading.value = false
}
}
/** 返回 */
const goBack = () => {
router.back()
}
/** 获取文件完整URL */
const getFileUrl = (url: string) => {
if (!url) return ''
if (url.startsWith('http') || url.startsWith('https')) {
return url
}
// 处理相对路径,拼接后端地址
return import.meta.env.VITE_BASE_URL + url
}
/** 预览文件 */
const handlePreview = (row: any) => {
const url = getFileUrl(row.fileUrl)
if (url) {
window.open(url, '_blank')
}
}
/** 下载文件 */
const handleDownload = (row: any) => {
const url = getFileUrl(row.fileUrl)
if (url) {
const link = document.createElement('a')
link.href = url
link.download = row.fileName || row.materialName
link.click()
}
}
/** 上传成功 */
const handleUploadSuccess = async (response: any, file: any, row: any) => {
if (response.code === 0) {
try {
// 调用后端保存材料信息
await AcceptanceMaterialApi.createAcceptanceMaterial({
acceptanceId: row.acceptanceId,
materialCode: row.materialCode,
fileName: file.name, // 使用原始文件名
fileUrl: response.data,
fileSize: file.size || 0,
fileType: file.name.split('.').pop(),
version: 1,
remark: ''
} as any)
message.success('上传成功')
// 刷新列表
getDetail()
} catch (error) {
message.error('保存材料记录失败')
}
} else {
message.error('上传失败: ' + response.msg)
}
}
/** 提交材料 */
const handleSubmitMaterial = async () => {
try {
// 校验必填项
const missing = materials.value.filter((m: any) => m.isRequired && !m.fileUrl)
if (missing.length > 0) {
message.warning(`请先上传:${missing.map((m: any) => m.materialName).join('、')}`)
return
}
await message.confirm('确定提交材料吗?提交后将进入审核流程。')
await AcceptanceApi.submitMaterials(acceptance.value.id)
message.success('提交成功')
await getDetail()
} catch {}
}
/** 提交整改 */
const handleSubmitRectify = async () => {
try {
await message.confirm('确定提交整改吗?')
if (acceptance.value.acceptanceType === 'PRE') {
await AcceptanceApi.submitPreRectify(acceptance.value.id)
} else {
await AcceptanceApi.submitFinalRectify(acceptance.value.id)
}
message.success('提交成功')
await getDetail()
} catch {}
}
/** 提交终验申请 */
const handleSubmitFinal = async () => {
try {
// 校验终验必填材料(前端预校验,后端也会校验)
const missing = materials.value.filter((m: any) => m.isRequired && !m.fileUrl)
if (missing.length > 0) {
message.warning(`请先上传终验必填材料:${missing.map((m: any) => m.materialName).join('、')}`)
return
}
await message.confirm('确定提交终验申请吗?提交后将进入终验审核流程。')
await AcceptanceApi.submitFinalAcceptance({ acceptanceId: acceptance.value.id })
message.success('终验申请提交成功')
await getDetail()
} catch {}
}
/** 流程步骤 */
const activeStep = computed(() => {
const status = acceptance.value.status
if (['00', '05'].includes(status)) return 0
if (['10', '11', '20'].includes(status)) return 1
if (['30'].includes(status)) return 2
if (['40', '50', '60', '61', '62'].includes(status)) return 3
if (['99'].includes(status)) return 4
return 0
})
/** 获取文件图标 */
const getFileIcon = (fileName?: string) => {
if (!fileName) return 'ep:document'
const ext = fileName.split('.').pop()?.toLowerCase()
if (['doc', 'docx'].includes(ext)) return 'ep:document'
if (['xls', 'xlsx'].includes(ext)) return 'ep:data-analysis'
if (['pdf'].includes(ext)) return 'ep:document-copy'
if (['jpg', 'jpeg', 'png', 'gif'].includes(ext)) return 'ep:picture'
if (['zip', 'rar'].includes(ext)) return 'ep:folder'
return 'ep:document'
}
/** 获取文件图标背景色 */
const getFileIconColor = (fileName?: string) => {
if (!fileName) return 'bg-gray-400'
const ext = fileName.split('.').pop()?.toLowerCase()
if (['doc', 'docx'].includes(ext)) return 'bg-blue-500'
if (['xls', 'xlsx'].includes(ext)) return 'bg-green-500'
if (['pdf'].includes(ext)) return 'bg-red-500'
if (['jpg', 'jpeg', 'png', 'gif'].includes(ext)) return 'bg-purple-500'
if (['zip', 'rar'].includes(ext)) return 'bg-orange-500'
return 'bg-gray-400'
}
/** 初始化 */
onMounted(() => {
if (route.query.tab) {
activeTab.value = route.query.tab as string
}
getDetail()
})
</script>
<style lang="scss" scoped>
.inline-block {
display: inline-block;
}
/* 覆盖 el-steps 进行中状态的颜色为绿色 */
:deep(.el-step__head.is-process) {
color: #3b82f6;
border-color: #3b82f6;
}
:deep(.el-step__title.is-process) {
color: #3b82f6;
font-weight: bold;
}
:deep(.el-step__description.is-process) {
color: #3b82f6;
}
/* Custom Tabs Styling */
:deep(.el-tabs__header) {
margin-bottom: 0;
border-bottom: 1px solid #f3f4f6;
background-color: #f9fafb;
}
:deep(.el-tabs__item) {
height: 50px;
font-size: 15px;
font-weight: 500;
color: #6b7280;
}
:deep(.el-tabs__item.is-active) {
color: #2563eb;
background-color: #fff;
border-top: 2px solid #2563eb;
}
:deep(.el-tabs__nav-wrap::after) {
height: 0;
}
</style>