Files
moc/src/views/docs/query/index.vue
T

532 lines
18 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, 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>