532 lines
18 KiB
Vue
532 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, h } from 'vue';
|
||
import { Icon } from '@iconify/vue'
|
||
import { useThemeStore } from '@/store/modules/theme';
|
||
import { formatTimestamp, getTimeRangeStamp, downloadFileFun } from "@/utils/common";
|
||
import previewComponent from '@/components/previewFile/index.vue'
|
||
import { systemConfigApi } from '@/service/api/system';
|
||
import { orgListApi } from '@/service/api/user';
|
||
import { docArchiveTreeListApi, docArchiveDetailsApi } from "@/service/api/doc";
|
||
|
||
const themeStore = useThemeStore();
|
||
|
||
const loading = ref<boolean>(false);
|
||
const rightLoading = ref<boolean>(false);
|
||
// 附件在线预览弹窗
|
||
const previewModal = ref<boolean>(false);
|
||
// 附件在线预览文件
|
||
const previewFile = ref<any>(null);
|
||
// 右侧展开的文件夹
|
||
const openFolders = ref<Record<string, boolean>>({});
|
||
// 组织树
|
||
const orgTree = ref<any[]>([]);
|
||
// 展开节点
|
||
const expandedKeys = ref<string[]>([])
|
||
// 选中节点
|
||
const selectedKeys = ref<string[]>([])
|
||
// 当前选中的节点
|
||
const currentNode = ref<any>(null);
|
||
// 变更类型
|
||
const typeList = ref<any[]>([{label:"全部类型",value:""}]);
|
||
// 树组件数据
|
||
const treeList = ref<any[]>([]);
|
||
// 右侧归档包详情
|
||
const rightDetails = ref<any>(null);
|
||
// 当前位置
|
||
const currentPath = ref<string[]>([]);
|
||
// 查询参数
|
||
const formInfo = ref<{org_id:number,type:string,time:string,from:string | number | null,to:string | number | null,change_no:string}>({
|
||
org_id: 1,
|
||
type: "",
|
||
time: "",
|
||
from: null,
|
||
to: null,
|
||
change_no: "",
|
||
});
|
||
const timeOptions = [
|
||
{
|
||
label: "全部时间",
|
||
value: "",
|
||
},
|
||
{
|
||
label: "近一年",
|
||
value: "year",
|
||
},
|
||
{
|
||
label: "近半年",
|
||
value: "half_year",
|
||
},
|
||
{
|
||
label: "近三个月",
|
||
value: "quarter",
|
||
},
|
||
];
|
||
|
||
/**
|
||
* 根据 key 找到节点,返回从自己到最顶层祖先的 label 数组(含自己)
|
||
* @param {Array} tree 树形数据
|
||
* @param {string} key 目标节点的 key
|
||
* @returns {string[] | null} 例如 ["工艺", "测试", "彩色"](从顶层到自身)
|
||
*/
|
||
const findPathByKey = (tree:any, key:string) => {
|
||
function dfs(nodes:any[], path:any[]) {
|
||
for (const node of nodes) {
|
||
const currentPath = [...path, node.name];
|
||
|
||
if (node.key === key) {
|
||
return currentPath; // 找到,直接返回整条路径
|
||
}
|
||
|
||
if (node.children?.length) {
|
||
const res:any = dfs(node.children, currentPath);
|
||
if (res) return res;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
return dfs(tree, []);
|
||
}
|
||
|
||
/**
|
||
* 统一提取并统计文件信息
|
||
* @param {Array} data 原始数据(含 step_no / files,file_size 单位为 KB)
|
||
* @returns {{
|
||
* allFiles: Array,
|
||
* total: number,
|
||
* totalSize: number,
|
||
* formattedSize: string,
|
||
* groups: Array,
|
||
* countByStep: Array
|
||
* }}
|
||
*/
|
||
function extractFiles(data:any[]) {
|
||
const empty = {
|
||
allFiles: [],
|
||
total: 0,
|
||
totalSize: 0,
|
||
formattedSize: '0.00 KB',
|
||
groups: [],
|
||
countByStep: []
|
||
};
|
||
if (!Array.isArray(data)) return empty;
|
||
// 扁平化所有文件
|
||
const allFiles = data.flatMap(item => item.files || []);
|
||
|
||
// 文件总数
|
||
const total = allFiles.length;
|
||
|
||
// 总大小(KB)
|
||
const totalSize = allFiles.reduce(
|
||
(sum, file) => sum + (Number(file.file_size) || 0),
|
||
0
|
||
);
|
||
// 格式化总大小
|
||
const formattedSize = formatSize(totalSize);
|
||
return {
|
||
allFiles,
|
||
total,
|
||
totalSize,
|
||
formattedSize,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 格式化文件大小(输入单位:KB)
|
||
* < 1024 KB → KB
|
||
* ≥ 1024 KB 且 < 1024 MB → MB
|
||
* ≥ 1024 MB → GB
|
||
* @param {number} kb
|
||
* @returns {string}
|
||
*/
|
||
function formatSize(kb:number) {
|
||
if (kb < 1024) {
|
||
return kb.toFixed(2) + ' KB';
|
||
}
|
||
|
||
const mb = kb / 1024;
|
||
if (mb < 1024) {
|
||
return mb.toFixed(2) + ' MB';
|
||
}
|
||
|
||
const gb = mb / 1024;
|
||
return gb.toFixed(2) + ' GB';
|
||
}
|
||
|
||
// 自定义渲染展开图标
|
||
const renderSwitcherIcon = (node:any) => {
|
||
// 如果节点没有 children 或 children 为空数组,则不显示图标
|
||
if (!node.option.children || node.option.children.length === 0) {
|
||
return null; // 返回 null 表示不渲染任何图标
|
||
}
|
||
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
|
||
return h(Icon,{
|
||
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
|
||
class: 'size-12px',
|
||
});
|
||
}
|
||
|
||
// 组织选择改变
|
||
const orgChange = (val:any) => {
|
||
formInfo.value.org_id = val;
|
||
getList();
|
||
}
|
||
// 时间范围切换
|
||
const timeChange = (val:string) => {
|
||
formInfo.value.time = val;
|
||
if(val===""){
|
||
formInfo.value.from = null;
|
||
formInfo.value.to = null;
|
||
}else{
|
||
formInfo.value.from = getTimeRangeStamp(val)?.from ?? null;
|
||
formInfo.value.to = getTimeRangeStamp(val)?.to ?? null;
|
||
}
|
||
getList();
|
||
}
|
||
// 类型切换
|
||
const typeChange = (val:string) => {
|
||
formInfo.value.type = val;
|
||
getList();
|
||
}
|
||
|
||
// 重置
|
||
const reset = () => {
|
||
formInfo.value.change_no = '';
|
||
formInfo.value.org_id = 1;
|
||
formInfo.value.type = '';
|
||
formInfo.value.from = null;
|
||
formInfo.value.to = null;
|
||
getList();
|
||
}
|
||
|
||
// 展开节点
|
||
const handleExpandKeys = (keys: string[],option:any,meta:any) => {
|
||
expandedKeys.value = keys
|
||
if (!meta.node)
|
||
return
|
||
if(meta.action==='expand'){
|
||
meta.node.prefix = () =>h(Icon, {
|
||
icon: 'material-symbols:folder-open-outline',
|
||
class: 'size-14px',
|
||
style: {
|
||
color: themeStore.themeColor,
|
||
}
|
||
})
|
||
}
|
||
if(meta.action==='collapse'){
|
||
meta.node.prefix = () =>h(Icon, {
|
||
icon: 'material-symbols:folder-outline',
|
||
class: 'size-14px',
|
||
})
|
||
}
|
||
}
|
||
// 选中节点
|
||
const handleSelectKeys = (keys: string[],option:any) => {
|
||
// org层和year层不能被选中
|
||
if(option[0] && (option[0].type === 'org' || option[0].type === 'year')){
|
||
return
|
||
}
|
||
// 当前选中的key
|
||
selectedKeys.value = keys;
|
||
// 当前选中的节点
|
||
currentNode.value = option[0];
|
||
// 当前选中的节点路径
|
||
currentPath.value = findPathByKey(treeList.value, currentNode.value?.key) || [];
|
||
getDetails();
|
||
}
|
||
|
||
// 下载
|
||
const downloadFile = (file:any) => {
|
||
downloadFileFun(file?.file_url, file?.file_name || file?.doc_name);
|
||
}
|
||
|
||
// 预览
|
||
const preview = (file:any) => {
|
||
previewFile.value = file;
|
||
previewModal.value = true;
|
||
}
|
||
|
||
/**
|
||
* 将组织-文件夹-文档数据转换为树组件需要的结构
|
||
* 每层添加 key / label / children
|
||
*/
|
||
function buildTreeData(data:any[]) {
|
||
if (!Array.isArray(data)) return [];
|
||
return data.map(org => ({
|
||
label: org.org_name,
|
||
name: org.org_name,
|
||
key: org.org_id+'_org',
|
||
type: 'org',
|
||
prefix: () =>h(Icon, {
|
||
icon: expandedKeys.value.includes(org.org_id+'_org')?'material-symbols:folder-open-outline':'material-symbols:folder-outline',
|
||
class: 'size-14px',
|
||
style: {
|
||
color: expandedKeys.value.includes(org.org_id+'_org')?themeStore.themeColor:'',
|
||
}
|
||
}),
|
||
children: (org.years || []).map((year:any) => ({
|
||
label: year.year+'年'+'('+(year?.changes?.length || 0)+')',
|
||
name: year.year,
|
||
key: year.year+'_year'+org.org_id,
|
||
type: 'year',
|
||
prefix: () =>h(Icon, {
|
||
icon: expandedKeys.value.includes(year.year+'_year'+org.org_id)?'material-symbols:folder-open-outline':'material-symbols:folder-outline',
|
||
class: 'size-14px',
|
||
style: {
|
||
color: expandedKeys.value.includes(year.year+'_year'+org.org_id)?themeStore.themeColor:'',
|
||
}
|
||
}),
|
||
children: (year.changes || []).map((change:any) => ({
|
||
label: () => h('span',{
|
||
style: {
|
||
color: selectedKeys.value.includes(change.change_id+'_change')?themeStore.themeColor:'',
|
||
}
|
||
},change.change_no),
|
||
key: change.change_id+'_change',
|
||
name: change.change_no,
|
||
id: change.change_id,
|
||
type: 'change',
|
||
prefix: () =>h(Icon, {
|
||
icon: 'lucide:folder-archive',
|
||
class: 'size-13px',
|
||
style: {
|
||
color: selectedKeys.value.includes(change.change_id+'_change')?themeStore.themeColor:'',
|
||
}
|
||
}),
|
||
}))
|
||
}))
|
||
}));
|
||
}
|
||
|
||
// 获取树状列表
|
||
const getList = async () => {
|
||
loading.value = true;
|
||
const {data,error} = await docArchiveTreeListApi({
|
||
from: formatTimestamp(formInfo.value.from),
|
||
to: formatTimestamp(formInfo.value.to),
|
||
org_id: formInfo.value.org_id,
|
||
type: formInfo.value.type,
|
||
change_no: formInfo.value.change_no,
|
||
});
|
||
if(!error){
|
||
treeList.value = buildTreeData(data);
|
||
}
|
||
loading.value = false;
|
||
}
|
||
|
||
// 右侧归档包详情
|
||
const getDetails = async () => {
|
||
rightLoading.value = true;
|
||
const {data,error} = await docArchiveDetailsApi(currentNode.value?.id);
|
||
if(!error){
|
||
rightDetails.value = data;
|
||
}
|
||
rightLoading.value = false;
|
||
}
|
||
|
||
// 获取配置
|
||
const getConfig = async () => {
|
||
const {data,error} = await systemConfigApi();
|
||
if(!error){
|
||
// 解析 变更类型设置
|
||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||
if (changeTypeGroup) {
|
||
typeList.value = typeList.value.concat(changeTypeGroup.items);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取组织列表
|
||
const getOrgList = async () => {
|
||
loading.value = true;
|
||
const {data,error} = await orgListApi();
|
||
if(!error){
|
||
orgTree.value = data;
|
||
if(orgTree.value.length > 0){
|
||
getList();
|
||
}
|
||
}
|
||
loading.value = false;
|
||
}
|
||
onMounted(async () => {
|
||
getConfig();
|
||
getOrgList();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<NSpace vertical :size="16">
|
||
<NSpin :show="loading" size="small">
|
||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive class="pb-[12px] border-b">
|
||
<NGi span="24 s:24 m:24">
|
||
<div class="h-100% flex items-center gap-2">
|
||
<Icon icon="mage:book" class="size-20px" :style="{ color: themeStore.themeColor }" />
|
||
<p class="text-base font-semibold">变更归档(一次变更一个归档包 · 关闭即自动归档 · 只读锁定)</p>
|
||
</div>
|
||
</NGi>
|
||
</NGrid>
|
||
<div class="flex items-center gap-2 mt-3">
|
||
<NInput v-model:value="formInfo.change_no" type="text" class="w-full text-xs" clearable placeholder="按变更单号精确查找,或按名称关键词检索(如 MOC-2026-R01-0039)" @clear="formInfo.change_no = ''; getList()">
|
||
<template #suffix>
|
||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" @click="getList" />
|
||
</template>
|
||
</NInput>
|
||
<NButton class="text-sm" @click="reset">
|
||
<Icon icon="system-uicons:reset" class="size-14px" />重置
|
||
</NButton>
|
||
</div>
|
||
<!-- 筛选:时间 / 组织 / 专业 -->
|
||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 mt-3">
|
||
<NTreeSelect
|
||
class="!w-200px"
|
||
:value="formInfo.org_id"
|
||
:options="orgTree"
|
||
size="small"
|
||
default-expand-all
|
||
label-field="label"
|
||
key-field="id"
|
||
:render-switcher-icon="renderSwitcherIcon"
|
||
placeholder="请选择组织层级"
|
||
@update:value="orgChange"
|
||
/>
|
||
<div class="flex flex-wrap items-center gap-1">
|
||
<span class="text-[11px] text-slate-400">时间</span>
|
||
<NButton v-for="time in timeOptions" :key="time.value" size="tiny" round class="text-xs" :type="formInfo.time === time.value ? 'primary' : 'default'" @click="timeChange(time.value)">{{ time.label }}</NButton>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-1">
|
||
<span class="text-[11px] text-slate-400">类型</span>
|
||
<NButton v-for="type in typeList" :key="type.value" size="tiny" round class="text-xs" :type="formInfo.type === type.value ? 'primary' : 'default'" @click="typeChange(type.value)">{{ type.label }}</NButton>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-3 mt-3">
|
||
<!-- 左侧 -->
|
||
<div class="w-300px border rounded-md p-2">
|
||
<div class="scroll-left">
|
||
<NTree
|
||
class="h-100% flex flex-col"
|
||
block-line
|
||
:data="treeList"
|
||
label-field="label"
|
||
key-field="key"
|
||
:expanded-keys="expandedKeys"
|
||
:selected-keys="selectedKeys"
|
||
:render-switcher-icon="renderSwitcherIcon"
|
||
selectable
|
||
:cancelable="false"
|
||
:on-update:expanded-keys="handleExpandKeys"
|
||
:on-update:selected-keys="handleSelectKeys"
|
||
>
|
||
<template #empty>
|
||
<div class="h-100% flex items-center justify-center">
|
||
<NEmpty description="暂无数据" class="text-xs"></NEmpty>
|
||
</div>
|
||
</template>
|
||
</NTree>
|
||
</div>
|
||
</div>
|
||
<!-- 右:归档包详情 -->
|
||
<div class="min-w-0 flex-1">
|
||
<NSpin :show="rightLoading" size="small">
|
||
<template v-if="rightDetails">
|
||
<div class="rounded-md border col-span-2">
|
||
<div class="border-b p-3">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="text-sm font-semibold">{{ rightDetails.change_no }}</span>
|
||
</div>
|
||
<div class="mt-1 text-xs text-slate-600">{{ rightDetails.title }}</div>
|
||
<div class="mt-2 flex flex-wrap justify-between gap-x-4 gap-y-1 text-[11px] text-slate-500">
|
||
<span>归档路径:{{ currentPath.join('/') }}</span>
|
||
<span>关闭时间:{{ rightDetails?.close_time?.slice(0,10) }}</span>
|
||
<span>共 {{ extractFiles(rightDetails.folders)?.total }} 个文件 · {{ extractFiles(rightDetails.folders)?.formattedSize }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="divide-y scroll-right">
|
||
<div v-for="f in rightDetails?.folders" :key="f.step_no">
|
||
<button
|
||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-slate-50 bg-transparent"
|
||
@click="openFolders[f.step_no] = !openFolders[f.step_no]"
|
||
>
|
||
<Icon icon="formkit:right" class="size-14px text-slate-400 transition-transform" :class="openFolders[f.step_no] ? 'rotate-90' : ''" />
|
||
<Icon v-if="openFolders[f.step_no]" icon="quill:folder-open" class="size-16px" :style="{color:themeStore.themeColor}" />
|
||
<Icon v-else icon="material-symbols:folder-outline" class="size-16px text-slate-400" />
|
||
<span v-if="f.step_no===1">1-申请与审批</span>
|
||
<span v-else-if="f.step_no===2">2-风险分析</span>
|
||
<span v-else-if="f.step_no===3">3-培训与考核</span>
|
||
<span v-else-if="f.step_no===4">4-PSSR检查</span>
|
||
<span v-else-if="f.step_no===5">5-验收记录</span>
|
||
<span v-else>6-关闭资料</span>
|
||
<span class="ml-auto text-[11px] font-normal text-slate-400">{{ f.files.length }} 个文件</span>
|
||
</button>
|
||
<template v-if="openFolders[f.step_no]">
|
||
<div v-for="file in f.files" :key="file.name" class="flex items-center gap-2 py-1.5 pl-10 pr-3 text-xs hover:bg-slate-50">
|
||
<Icon icon="akar-icons:file" class="size-14px text-slate-400" />
|
||
<span class="min-w-0 flex-1 truncate">{{ file.file_name }}</span>
|
||
<span class="text-[11px] text-slate-400">{{ formatSize(file.file_size) }}</span>
|
||
<NButton text type="primary" title="预览" @click="preview(file)">
|
||
<Icon icon="lucide:eye" class="size-14px" />
|
||
</NButton>
|
||
<NButton text type="primary" title="下载" @click="downloadFile(file)">
|
||
<Icon icon="material-symbols:download" class="size-16px" />
|
||
</NButton>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="h-100% rounded-md border scroll-empty">
|
||
<div class="h-100% flex items-center justify-center">
|
||
<NEmpty description="暂无归档包详情数据" class="text-xs"></NEmpty>
|
||
</div>
|
||
</div>
|
||
</NSpin>
|
||
</div>
|
||
</div>
|
||
</NCard>
|
||
<!-- 附件在线预览弹窗 -->
|
||
<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?.slice(0, 16) }}</span>
|
||
<span v-if="previewFile?.version">{{ `版本:${previewFile?.version}(受控)` }}</span>
|
||
</div>
|
||
<PreviewComponent v-if="previewFile" :preview-file="previewFile" />
|
||
</div>
|
||
</template>
|
||
</NModal>
|
||
</NSpin>
|
||
</NSpace>
|
||
</template>
|
||
|
||
<style scoped>
|
||
|
||
</style>
|
||
|
||
<style scoped lang="scss">
|
||
.scroll-left {
|
||
height: calc(100vh - 303px);
|
||
overflow-y: auto;
|
||
}
|
||
.scroll-right {
|
||
height: calc(100vh - 416px);
|
||
overflow-y: auto;
|
||
}
|
||
.scroll-empty {
|
||
height: calc(100vh - 281px);
|
||
overflow-y: auto;
|
||
}
|
||
</style>
|