变更归档查询完成

This commit is contained in:
2026-09-23 13:23:36 +08:00
parent f2770b752a
commit 0e42842611
6 changed files with 510 additions and 297 deletions
+14 -8
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue';
import { ref, onMounted, h } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { NButton, NTag, NDropdown, useDialog } from 'naive-ui';
import previewComponent from '@/components/previewFile/index.vue'
import { downloadFileFun } from '@/utils/common';
import {
docTreeListApi,
addFolderApi,
@@ -92,7 +93,7 @@ const renderSwitcherIcon = (node:any) => {
const findPathByKey = (tree:any, key:string) => {
function dfs(nodes:any[], path:any[]) {
for (const node of nodes) {
const currentPath = [...path, node.label];
const currentPath = [...path, node.name];
if (node.key === key) {
return currentPath; // 找到,直接返回整条路径
@@ -337,14 +338,19 @@ function buildTreeData(data = []) {
return {
...folder,
key: folder.id+'_folder',
label: folder.name,
name: folder.name,
label: () => h('span',{
style: {
color: selectedKeys.value.includes(folder.id+'_folder')?themeStore.themeColor:'',
}
},folder.name),
type: 'folder',
children,
prefix: () =>h(Icon, {
icon: expandedKeys.value.includes(folder.id+'_folder')?'material-symbols:folder-open-outline':'material-symbols:folder-outline',
class: 'size-14px',
style: {
color: expandedKeys.value.includes(folder.id+'_folder')?themeStore.themeColor:'',
color: (expandedKeys.value.includes(folder.id+'_folder') || selectedKeys.value.includes(folder.id+'_folder'))?themeStore.themeColor:'',
}
}),
};
@@ -354,6 +360,7 @@ function buildTreeData(data = []) {
return data.map((org: any) => ({
...org,
key: org.org_id+'_org',
name: org.org_name,
label: org.org_name,
type: 'org',
children: (org.folders || []).map(transformFolder),
@@ -402,8 +409,8 @@ const preview = (d:any) => {
previewModal.value = true;
}
// 下载资料
const download = (d:any) => {
window.$message?.info('还差接口')
const download = (file:any) => {
downloadFileFun(file.file_url, file.file_name);
}
// 上传资料
const uploadFiles = async (e:any) => {
@@ -523,7 +530,7 @@ onMounted(() => {
<!-- 文件夹树 + 当前文件夹资料 -->
<div v-else class="flex gap-3">
<!-- 左侧 -->
<div class="w-230px border rounded-md p-2">
<div class="w-300px border rounded-md p-2">
<div class="scroll-left">
<NTree
block-line
@@ -659,7 +666,6 @@ onMounted(() => {
</template>
<template #default>
<div class="space-y-3">
{{ previewFile }}
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
<span>上传人:{{ previewFile?.user_name }}</span>
<span>上传时间:{{ previewFile?.created_at?.slice(0, 16) }}</span>
+467 -287
View File
@@ -1,323 +1,499 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useAppStore } from '@/store/modules/app';
import { ref, onMounted, h } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { getColorWithOpacity } from "@/utils/common";
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 appStore = useAppStore();
const themeStore = useThemeStore();
const gap = computed(() => (appStore.isMobile ? 0 : 16));
const aq = ref<string>('');
const uploadFiles = () => {
window.$message?.success('演示:上传归档补充材料(限变更主管部门专责;材料单独存入「7-补充材料」,不改动原归档包,哈希留痕)')
}
interface ArchivePkg {
id: string; name: string; closedAt: string; size: string; files: number;
hash: string; revoked?: boolean; supplement?: boolean;
}
interface ArchiveUnit { unit: string; years: { year: string; pkgs: ArchivePkg[] }[] }
const ARCHIVE_TREE: ArchiveUnit[] = [
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 = [
{
unit: "01-常减压装置",
years: [
{ year: "2024", pkgs: [
{ id: "MOC-2024-R01-0003", name: "减一线换热器 E-108 芯子更换", closedAt: "2024-04-18", size: "12.6MB", files: 19, hash: "3a91d7c4f0e2…5b17" },
{ id: "MOC-2024-R01-0007", name: "常压塔顶回流泵 P-105B 叶轮更换", closedAt: "2024-09-02", size: "9.8MB", files: 16, hash: "8e02b1a6d3c9…42fa" },
] },
{ year: "2025", pkgs: [
{ id: "MOC-2025-R01-0011", name: "初馏塔进料温度联锁值调整(已撤销)", closedAt: "2025-03-11", size: "7.4MB", files: 14, hash: "c5d8f2a1b7e4…9c03", revoked: true },
{ id: "MOC-2025-R01-0019", name: "电脱盐系统注水泵型号替换", closedAt: "2025-07-25", size: "15.2MB", files: 22, hash: "1f4a9c7e2b6d…d831", supplement: true },
{ id: "MOC-2025-R01-0023", name: "常压炉燃料气切断阀改造", closedAt: "2025-11-14", size: "21.0MB", files: 27, hash: "6b3e0d8a5f1c…77e2" },
] },
{ year: "2026", pkgs: [
{ id: "MOC-2026-R01-0028", name: "减顶真空泵 P-107A 电机更换", closedAt: "2026-06-30", size: "18.4MB", files: 24, hash: "b8e3a1c5d2f7…4c60" },
{ id: "MOC-2026-R01-0031", name: "常顶油气冷却器管束更新", closedAt: "2026-07-12", size: "16.9MB", files: 21, hash: "d2a7c4f88b1e…5a96" },
{ id: "MOC-2026-R01-0039", name: "进料泵 P-101A 更换为高效节能泵", closedAt: "2026-08-01", size: "18.4MB", files: 24, hash: "7c4f9be2a1d3…e804" },
] },
],
label: "全部时间",
value: "",
},
{
unit: "02-催化裂化装置",
years: [
{ year: "2025", pkgs: [
{ id: "MOC-2025-R02-0014", name: "再生器旋风分离器更换", closedAt: "2025-05-20", size: "26.3MB", files: 31, hash: "9c1e5b3d7a2f…b450" },
{ id: "MOC-2025-R02-0021", name: "气压机出口压力联锁逻辑修改", closedAt: "2025-10-08", size: "11.7MB", files: 18, hash: "4d8f0a2c6e9b…31cd" },
] },
{ year: "2026", pkgs: [
{ id: "MOC-2026-R02-0025", name: "分馏塔顶循回流管线扩径", closedAt: "2026-06-30", size: "14.5MB", files: 20, hash: "a7b2d9e4c1f8…6e05" },
] },
],
label: "近一年",
value: "year",
},
{
unit: "03-聚合装置",
years: [
{ year: "2026", pkgs: [
{ id: "MOC-2026-R03-0008", name: "聚合釜搅拌器密封形式变更", closedAt: "2026-05-19", size: "13.2MB", files: 17, hash: "e6f1a8c3b5d0…92d7" },
] },
],
label: "近半年",
value: "half_year",
},
{
label: "近三个月",
value: "quarter",
},
];
const ARCHIVE_FOLDERS: { dir: string; files: { name: string; size: string }[] }[] = [
{ dir: "1-申请与审批", files: [
{ name: "变更申请表(终版).pdf", size: "486KB" },
{ name: "审批记录(含会签意见与留痕).pdf", size: "312KB" },
{ name: "等级判定表.pdf", size: "154KB" },
{ name: "原始附件(方案/会议纪要/数据表).zip", size: "2.8MB" },
] },
{ dir: "2-风险分析", files: [
{ name: "AI 风险预分析报告.pdf", size: "268KB" },
{ name: "JSA 作业安全分析表.pdf", size: "196KB" },
{ name: "HAZOP 分析记录.pdf", size: "742KB" },
{ name: "安全检查表(SCL).pdf", size: "188KB" },
] },
{ dir: "3-培训与考核", files: [
{ name: "变更培训材料(终版).pdf", size: "1.1MB" },
{ name: "培训签到表(照片).jpg", size: "2.4MB" },
{ name: "考核成绩单.xlsx", size: "32KB" },
] },
{ dir: "4-PSSR检查", files: [
{ name: "PSSR 检查清单(终版).pdf", size: "356KB" },
{ name: "现场确认照片.zip", size: "6.7MB" },
{ name: "电子签名确认记录.pdf", size: "142KB" },
] },
{ dir: "5-验收记录", files: [
{ name: "变更验收单.pdf", size: "204KB" },
{ name: "整改记录与复验结论.pdf", size: "168KB" },
] },
{ dir: "6-受控文件副本", files: [
{ name: "受控文件更新清单(新旧对照).pdf", size: "96KB" },
{ name: "PID-101_Rev.4_20260805.pdf", size: "3.2MB" },
{ name: "PID-101_Rev.3_20250101.pdf", size: "3.1MB" },
] },
];
const SUPPLEMENT_FOLDER = { dir: "7-补充材料(归档后补充)", files: [
{ name: "补充说明(泵轴振动复测数据).pdf", size: "214KB" },
{ name: "补充审批记录.pdf", size: "88KB" },
] };
const ROLE_SCOPE: Record<any, { text: string; filter: (u: any, p: any) => boolean }> = {
applicant: { text: "本人发起或参与的变更", filter: (u, p) => u.unit.startsWith("01") && ["MOC-2026-R01-0039", "MOC-2026-R01-0028", "MOC-2025-R01-0019"].includes(p.id) },
dept: { text: "本车间(装置)的全部变更", filter: (u) => u.unit.startsWith("01") },
process: { text: "本人参与审批或检查的变更", filter: (u, p) => u.unit.startsWith("01") && p.id >= "MOC-2025" },
process2: { text: "本人参与审批或检查的变更", filter: (u, p) => u.unit.startsWith("01") && p.id >= "MOC-2025" },
safety: { text: "全厂变更(安全监管需要)", filter: () => true },
vp: { text: "全厂变更", filter: () => true },
admin: { text: "全厂变更(系统管理:归档内容只读锁定,不可修改删除)", filter: () => true },
quality: { text: "本人参与会签或检验确认的变更", filter: (u, p) => u.unit.startsWith("01") && p.id >= "MOC-2025" },
viewer: { text: "全厂变更(信息查看角色:只读查阅)", filter: () => true },
};
const tF = ref("全部时间");
const oF = ref("全部组织");
const pF = ref("全部专业");
const FILTER_GROUPS = computed(() => [
{ label: "时间", opts: ["全部时间", "近一年", "近半年", "近三个月"], val: tF.value, set: (v: string) => { tF.value = v; } },
{ label: "组织", opts: ["全部组织", "一车间", "二车间", "三车间"], val: oF.value, set: (v: string) => { oF.value = v; } },
{ label: "专业", opts: ["全部专业", "工艺", "设备", "仪表", "管理"], val: pF.value, set: (v: string) => { pF.value = v; } },
]);
const scope = computed(() => ROLE_SCOPE['applicant'] ?? ROLE_SCOPE.vp);
const searching = computed(() => aq.value.trim().length > 0);
const filtering = computed(() => tF.value !== "全部时间" || oF.value !== "全部组织" || pF.value !== "全部专业");
const UNIT_ORG: Record<string, string> = { "01-常减压装置": "一车间", "02-催化裂化装置": "二车间", "03-聚合装置": "三车间" };
const profOf = (name: string) => /联锁|报警/.test(name) ? "仪表" : /规程|制度|管理/.test(name) ? "管理" : /泵|阀|电机|换热器|搅拌|密封|分离器|管束|冷却器|气压机|叶轮|芯子/.test(name) ? "设备" : "工艺";
const REF = new Date("2026-08-03");
const selId = ref("MOC-2026-R01-0039");
const passF = (p: (typeof allPkgs.value)[number]) => {
const daysOf = tF.value === "近一年" ? 365 : tF.value === "近半年" ? 183 : tF.value === "近三个月" ? 92 : 0;
return (daysOf === 0 || (REF.getTime() - new Date(p.closedAt).getTime()) / 86400000 <= daysOf) &&
(oF.value === "全部组织" || UNIT_ORG[p.unit] === oF.value) &&
(pF.value === "全部专业" || profOf(p.name) === pF.value);
};
const tree = computed(() => ARCHIVE_TREE
.map((u:any) => ({ unit: u.unit, years: u.years.map((y) => ({ year: y.year, pkgs: y.pkgs.filter((p:any) => scope.value.filter(u, p)) })).filter((y:any) => y.pkgs.length > 0) }))
.filter((u:any) => u.years.length > 0));
const allPkgs = computed(() => tree.value.flatMap((u:any) => u.years.flatMap((y:any) => y.pkgs.map((p:any) => ({ ...p, unit: u.unit, year: y.year })))));
const results = computed(() => (searching.value || filtering.value)
? allPkgs.value.filter((p:any) => passF(p) && (!searching.value || p.id.toLowerCase().includes(aq.value.trim().toLowerCase()) || p.name.includes(aq.value.trim())))
: []);
const sel = computed(() => allPkgs.value.find((p:any) => p.id === selId.value) ?? allPkgs.value[0]);
const selFolders = computed(() => (sel.value?.supplement ? [...ARCHIVE_FOLDERS, SUPPLEMENT_FOLDER] : ARCHIVE_FOLDERS));
const openUnits = ref<Record<string, boolean>>({ "01-常减压装置": true });
const openFolders = ref<Record<string, boolean>>({ "1-申请与审批": true });
const downloadZip = (sel:any) => {
window.$message?.success(`演示:下载 ${sel.id} 归档包 ZIP(含归档索引.txt)`)
/**
* 根据 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, []);
}
const exportEvidenceChain = () => {
window.$message?.success('演示:导出证据链(哈希清单 + 操作日志)')
/**
* 统一提取并统计文件信息
* @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,
};
}
const applySupplement = () => {
window.$message?.info('演示:发起归档补充申请(补充材料单独存入 7-补充材料,不改动原归档)')
/**
* 格式化文件大小(输入单位: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 viewIndex = () => {
window.$message?.info('演示:查看归档索引(文件清单 + 逐项 SHA-256)')
// 自定义渲染展开图标
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 previewFile = (file:any) => {
window.$message?.info(`演示:在线预览 ${file.name}`)
// 组织选择改变
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) => {
window.$message?.success(`演示:下载 ${file.name}`)
downloadFileFun(file.file_url, file.file_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);
console.log(treeList.value);
}
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="gap" :y-gap="16" responsive="screen" item-responsive class="pb-[12px] border-b">
<NGi span="24 s:24 m:16">
<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>
<NGi span="24 s:24 m:8" class="flex items-center justify-end gap-12px">
<div class="flex items-center gap-2">
<NButton size="small" type="primary" class="text-xs" @click="uploadFiles">
<Icon icon="material-symbols:upload" class="size-16px" /> 上传归档补充
</NButton>
</div>
</NGi>
</NGrid>
<div
class="mt-4 flex items-center gap-2 rounded-lg bg-[var(--bg-color)] px-3 py-2 text-xs text-[var(--theme-color)]"
:style="{
'--theme-color': themeStore.themeColor,
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1)
}"
>
<Icon icon="uil:lock" class="size-16px" />
当前角色调阅范围:{{ scope.text }} · 归档包关闭时自动生成,内容只读锁定并记录 SHA-256 哈希,任何修改留痕可追责
</div>
<NInput v-model:value="aq" type="text" class="w-40 text-xs mt-3" placeholder="按变更单号精确查找,或按名称关键词检索(如 MOC-2026-R01-0039)">
<template #suffix>
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" />
</template>
<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>
<!-- 筛选:时间 / 组织 / 专业 -->
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 mt-3">
<div v-for="g in FILTER_GROUPS" :key="g.label" class="flex flex-wrap items-center gap-1">
<span class="text-[11px] text-slate-400">{{ g.label }}</span>
<NButton size="tiny" round v-for="o in g.opts" :key="o" @click="g.set(o)" class="text-[11px]" :type="g.val === o ? 'primary' : 'default'">{{ o }}</NButton>
<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
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"
/>
</div>
</div>
<div class="grid gap-3 grid-cols-3 mt-3">
<!-- 左:三层目录树 / 筛选检索结果 -->
<div class="rounded-lg border p-2">
<div class="px-2 pb-1 text-xs font-medium text-slate-500">{{ (searching || filtering) ? `筛选 / 检索结果(${results.length})` : '归档区目录(装置 / 年份 / 变更单号)' }}</div>
<template v-if="searching || filtering">
<div v-if="results.length === 0" class="px-2 py-4 text-center text-xs text-slate-400">未找到匹配的归档包</div>
<button v-for="p in results" :key="p.id" @click="selId = p.id"
class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-[#DFE9F6]/60"
:class="sel?.id === p.id ? 'bg-[#DFE9F6] font-medium text-[#1D4E9C]' : ''">
<FolderArchive class="h-3.5 w-3.5 shrink-0 text-[#6E93C6]" />
<span class="truncate">{{ p.id }}</span>
<Tag v-if="p.revoked" class="border-red-200 bg-red-100 px-1 py-0 text-[10px] text-red-600">已撤销</Tag>
<Tag v-if="p.supplement" class="border-amber-200 bg-amber-100 px-1 py-0 text-[10px] text-amber-700">补充</Tag>
</button>
</template>
<template v-else>
<div v-for="u in tree" :key="u.unit">
<button @click="openUnits[u.unit] = !openUnits[u.unit]"
class="flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs font-medium hover:bg-slate-50 bg-transparent">
<Icon icon="formkit:right" class="size-14px transition-transform" :class="openUnits[u.unit] ? 'rotate-90' : ''" />
<Icon v-if="openUnits[u.unit]" 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" />
{{ u.unit }}
</button>
<template v-if="openUnits[u.unit]">
<div v-for="y in u.years" :key="y.year">
<div class="pl-7 pt-1 text-[11px] font-medium text-slate-400">{{ y.year }} 年({{ y.pkgs.length }})</div>
<button v-for="p in y.pkgs" :key="p.id" @click="selId = p.id"
class="flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-2 text-left text-xs hover:bg-[#DFE9F6]/60"
:class="sel?.id === p.id ? 'bg-[var(--bg-color)] font-medium text-[var(--theme-color)]' : 'bg-transparent'"
:style="{
'--theme-color': themeStore.themeColor,
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1)
}"
>
<Icon icon="lucide:folder-archive" class="size-14px" :style="{ color: themeStore.themeColor }" />
<span class="truncate">{{ p.id }}</span>
<NTag v-if="p.revoked" size="small" type="error" class="text-[10px]">已撤销</NTag>
<NTag v-if="p.supplement" size="small" type="warning" class="text-[10px]">补充</NTag>
</button>
</div>
</template>
<!-- 右:归档包详情 -->
<div class="min-w-0 flex-1">
<NSpin :show="rightLoading" size="small">
<div v-if="rightDetails" 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>
</template>
</div>
<!-- 右:归档包详情 -->
<div v-if="sel" class="rounded-lg 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">{{ sel.id }}</span>
<NTag v-if="sel.revoked" size="small" type="error">已撤销(保留归档备查)</NTag>
<NTag v-else size="small" type="info">
<template #icon>
<Icon icon="uil:lock" class="size-14px" />
</template>
只读锁定
</NTag>
<NTag v-if="sel.supplement" size="small" type="warning">含归档补充</NTag>
</div>
<div class="mt-1 text-xs text-slate-600">{{ sel.name }}</div>
<div class="mt-2 flex flex-wrap justify-between gap-x-4 gap-y-1 text-[11px] text-slate-500">
<span>归档路径:归档区/{{ sel.unit }}/{{ sel.year }}/{{ sel.id }}/</span>
<span>关闭时间:{{ sel.closedAt }}</span>
<span>共 {{ sel.files }} 个文件 · {{ sel.size }}</span>
<span class="flex items-center gap-1 font-mono">
<Icon icon="lucide:hash" class="size-12px" />
{{ sel.hash }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<NButton size="small" class="text-xs" @click="downloadZip(sel)">
<Icon icon="material-symbols:download" class="size-14px" />
下载归档包 ZIP
</NButton>
<NButton size="small" class="text-xs" @click="exportEvidenceChain">导出证据链</NButton>
<NButton v-if="!sel.revoked" size="small" class="text-xs" @click="applySupplement">发起归档补充申请</NButton>
<div class="divide-y scroll-right">
<div v-for="f in rightDetails?.folders" :key="f.step_no">
<button @click="openFolders[f.step_no] = !openFolders[f.step_no]"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-slate-50 bg-transparent">
<Icon icon="formkit:right" class="size-14px text-slate-400 transition-transform" :class="openFolders[f.step_no] ? 'rotate-90' : ''" />
<Icon icon="quill:folder-open" v-if="openFolders[f.step_no]" 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>
<div class="divide-y scroll-right">
<div v-for="f in selFolders" :key="f.dir">
<button @click="openFolders[f.dir] = !openFolders[f.dir]"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-slate-50 bg-transparent">
<Icon icon="formkit:right" class="size-14px text-slate-400 transition-transform" :class="openFolders[f.dir] ? 'rotate-90' : ''" />
<Icon icon="quill:folder-open" v-if="openFolders[f.dir]" class="size-16px text-[#6E93C6]" />
<Icon v-else icon="material-symbols:folder-outline" class="size-16px text-slate-400" />
{{ f.dir }}
<span class="ml-auto text-[11px] font-normal text-slate-400">{{ f.files.length }} 个文件</span>
</button>
<template v-if="openFolders[f.dir]">
<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.name }}</span>
<span class="text-[11px] text-slate-400">{{ file.size }}</span>
<NButton text type="primary" @click="previewFile(file)">
<Icon icon="lucide:eye" class="size-14px" />
</NButton>
<NButton text type="primary" @click="downloadFile(file)">
<Icon icon="material-symbols:download" class="size-16px" />
</NButton>
</div>
</template>
</div>
<div class="flex items-center gap-2 px-3 py-2 text-xs hover:bg-slate-50">
<Icon icon="akar-icons:file" class="size-14px text-slate-400" />
<span class="flex-1 font-mono">归档索引.txt</span>
<span class="text-[11px] text-slate-400">清单与哈希</span>
<NButton text type="primary" @click="viewIndex">
<Icon icon="lucide:eye" class="size-14px" />
</NButton>
</div>
</div>
</div>
<div v-else class="rounded-md border text-center text-xs text-slate-400 py-5">暂无归档包详情数据</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" :previewFile="previewFile" />
</div>
</template>
</NModal>
</NSpin>
</NSpace>
</template>
@@ -325,8 +501,12 @@ const downloadFile = (file:any) => {
</style>
<style scoped lang="scss">
.scroll-left {
height: calc(100vh - 303px);
overflow-y: auto;
}
.scroll-right {
height: calc(100vh - 460px);
height: calc(100vh - 416px);
overflow-y: auto;
}
</style>