@@ -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-6 00"> { { 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-4 00" / >
< 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 ( 100 vh - 303 px ) ;
overflow - y : auto ;
}
. scroll - right {
height : calc ( 100 vh - 460 px ) ;
height : calc ( 100 vh - 416 px ) ;
overflow - y : auto ;
}
< / style >