@ -1,74 +1,160 @@
< template >
< t -card title = "文档列表" :bordered ="false" >
<!-- 工具栏 : 搜索 + 筛选 + 批量操作 -- >
< div class = "toolbar" >
< t -input v -model = " keyword " placeholder = "搜索文档标题或文件名..." clearable @change ="debouncedLoad" style = "max-width:260px;" size = "small" / >
< t -select v -model = " filterCategory " :options ="categoryOptions" placeholder = "全部分类" clearable size = "small" style = "width:140px;" @change ="load()" / >
< t -select v -model = " filterStatus " :options ="statusOptions" placeholder = "全部状态" clearable size = "small" style = "width:110px;" @change ="load()" / >
< t -select v -model = " filterTag " :options ="tagOptions" placeholder = "全部标签" clearable size = "small" style = "width:140px;" @change ="load()" / >
< t -button variant = "outline" size = "small" @click ="load()" > 刷新 < / t -button >
< t -tag v-if ="hasProcessing" theme="warning" size="small" > ⏳ 有文档处理中... < / t -tag >
< template v-if ="selectedIds.size > 0" >
< t -divider layout = "vertical" / >
< span class = "selected-count" > 已选 { { selectedIds . size } } 项 < / span >
< t -button theme = "danger" size = "small" @click ="batchRemove" > 批量删除 < / t -button >
< t -button variant = "outline" size = "small" @click ="batchReprocess" > 批量重新处理 < / t -button >
< t -button variant = "outline" size = "small" @click ="batchEnable" > 批量启用 < / t -button >
< t -button variant = "outline" size = "small" @click ="batchDisable" > 批量禁用 < / t -button >
< t -select v -model = " moveCategoryId " :options ="moveCategoryOptions" placeholder = "移动到分类..." size = "small" style = "width:140px;" / >
< t -button variant = "outline" size = "small" @click ="batchMove" : disabled = "moveCategoryId === ''" > 移动 < / t - b u t t o n >
< t -button variant = "text" size = "small" @click ="clearSelection" > 取消选择 < / t -button >
< / template >
< / div >
< t -card class = "doc-list-card" title = "文档列表" :bordered ="false" >
< div class = "doc-layout" >
<!-- 左侧 : 目录树面板 -- >
< aside class = "doc-tree-panel" : style = "{ width: treeWidth + 'px' }" >
< div class = "tree-panel-header" >
< span class = "tree-panel-title" > 📁 文档目录 < / span >
< t -button variant = "text" size = "small" @click ="loadTreeData" > 刷新 < / t -button >
< / div >
< div class = "tree-panel-body" >
< div v-if ="folderTreeLoading" class="tree-loading" > 加载中... < / div >
< t -tree v -else
: data = "treeData"
: keys = "{ value: 'value', label: 'label', children: 'children' }"
: activable = "true"
: active - multiple = "false"
hover
expand - all
empty = "暂无分类与目录"
v - model : actived = "activedKeys"
@ active = "onTreeActive"
>
< template # label = "{ node }" >
< span class = "tree-node" >
< span class = "tree-label-text" :title ="node.data.label" > { { node . data . label } } < / span >
< t -dropdown class = "tree-ops" trigger = "click" :options ="nodeActionOptions(node)" @ click = "(item: any) => onNodeAction(item, node)" >
< t -button size = "small" variant = "text" shape = "square" @ click.stop >
< template # icon > < MoreIcon / > < / template >
< / t - b u t t o n >
< / t - d r o p d o w n >
< / span >
< / template >
< / t - t r e e >
< / div >
< / aside >
<!-- 可拖拽分隔条 -- >
< div class = "tree-resizer" : class = "{ dragging: isResizing }" @mousedown ="startResize" > < / div >
<!-- 右侧 : 工具栏 + 表格 -- >
< div class = "doc-content" >
<!-- 工具栏 : 搜索 + 筛选 + 批量操作 -- >
< div class = "toolbar" >
< t -input v -model = " keyword " placeholder = "搜索文档标题或文件名..." clearable @change ="debouncedLoad" style = "max-width:260px;" size = "small" / >
< t -select v -model = " filterCategory " :options ="categoryOptions" placeholder = "全部分类" clearable size = "small" style = "width:140px;" @change ="onCategoryChange" / >
< t -select v -model = " filterStatus " :options ="statusOptions" placeholder = "全部状态" clearable size = "small" style = "width:110px;" @change ="load()" / >
< t -select v -model = " filterTag " :options ="tagOptions" placeholder = "全部标签" clearable size = "small" style = "width:140px;" @change ="load()" / >
< t -button variant = "outline" size = "small" @click ="load()" > 刷新 < / t -button >
< t -tag v-if ="hasProcessing" theme="warning" size="small" > ⏳ 有文档处理中... < / t -tag >
< t -tag v-if ="filterFolder" theme="primary" variant="light" size="small" :closable="true" @close="clearFolderFilter" > 📁 目录筛选 < / t -tag >
<!-- 表格 -- >
< t -table
: data = "documents" : columns = "columns" row - key = "id" : loading = "loading"
: pagination = "{ current: page, total: total, pageSize: pageSize, showJumper: true }"
: selected - row - keys = "Array.from(selectedIds)"
@ page - change = "onPageChange"
@ select - change = "onSelectChange"
>
< template # title = "{ row }" >
< div > < strong > { { row . title } } < / strong > < / div >
< div style = "font-size:11px;color:var(--td-text-color-placeholder);" > { { row . sourceName || '' } } < / div >
< div v-if ="getDocTags(row).length" style="margin-top:2px;" >
< t -tag v-for ="tag in getDocTags(row)" :key="tag" size="small" variant="light" theme="primary" style="margin-right:2px;" > {{ tag }} < / t -tag >
< template v-if ="selectedIds.size > 0" >
< t -divider layout = "vertical" / >
< span class = "selected-count" > 已选 { { selectedIds . size } } 项 < / span >
< t -button theme = "danger" size = "small" @click ="batchRemove" > 批量删除 < / t -button >
< t -button variant = "outline" size = "small" @click ="batchReprocess" > 批量重新处理 < / t -button >
< t -button variant = "outline" size = "small" @click ="batchEnable" > 批量启用 < / t -button >
< t -button variant = "outline" size = "small" @click ="batchDisable" > 批量禁用 < / t -button >
< t -button variant = "outline" size = "small" @click ="openMove(Array.from(selectedIds))" > 移动 < / t -button >
< t -button variant = "text" size = "small" @click ="clearSelection" > 取消选择 < / t -button >
< / template >
< / div >
< / template >
< template # fileType = "{ row }" > < t -tag size = "small" variant = "light" > { { row . fileType } } < / t - t a g > < / t e m p l a t e >
< template # fileSize = "{ row }" > < span style = "white-space:nowrap;" > { { formatSize ( row . fileSize ) } } < / span > < / template >
< template # categoryId = "{ row }" > < span > { { categoryStore . getCategoryName ( row . categoryId ) } } < / span > < / template >
< template # status = "{ row }" >
< t -tag size = "small" : theme = "row.status === 'READY' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'"
variant = "light" > { { statusLabel ( row . status ) } } < / t - t a g >
< / template >
< template # enabled = "{ row }" >
< t -switch v -model = " row._enabled " size = "small" @change ="toggle(row)" / >
< / template >
< template # op = "{ row }" >
< t -space :size ="2" >
< t -button size = "small" variant = "text" @click ="viewDetail(row.id)" > 查看 < / t -button >
< t -button v-show ="row.filePath" size="small" variant="text" @click="download(row.id)" > 下载 < / t -button >
< t -button size = "small" variant = "text" @click ="reprocess(row.id)" > 重新处理 < / t -button >
< t -button size = "small" variant = "text" theme = "danger" @click ="remove(row.id)" > 删除 < / t -button >
< / t - s p a c e >
< / template >
< / t - t a b l e >
<!-- 表格 : 外层容器撑满剩余高度 , 表格在其中内部滚动 ( 表头固定 ) -- >
< div class = "doc-table-wrap" >
< BaseTable
: max - height = "'calc(100vh - 220px)'"
: data = "documents" : columns = "columns" row - key = "id" : loading = "loading"
: pagination = "{ current: page, total: total, pageSize: pageSize, showJumper: true }"
: selected - row - keys = "Array.from(selectedIds)"
: sort = "sortInfo"
@ page - change = "onPageChange"
@ select - change = "onSelectChange"
@ sort - change = "onSortChange"
>
< template # title = "{ row }" >
< div > < strong > { { row . title } } < / strong > < / div >
< div style = "font-size:11px;color:var(--td-text-color-placeholder);" > { { row . sourceName || '' } } < / div >
< div v-if ="getDocTags(row).length" style="margin-top:2px;" >
< t -tag v-for ="tag in getDocTags(row)" :key="tag" size="small" variant="light" theme="primary" style="margin-right:2px;" > {{ tag }} < / t -tag >
< / div >
< / template >
< template # fileType = "{ row }" > < t -tag size = "small" variant = "light" > { { row . fileType } } < / t - t a g > < / t e m p l a t e >
< template # fileSize = "{ row }" > < span style = "white-space:nowrap;" > { { formatSize ( row . fileSize ) } } < / span > < / template >
< template # categoryId = "{ row }" >
< span class = "doc-pos" :title ="docPath(row)" > { { docPath ( row ) } } < / span >
< / template >
< template # status = "{ row }" >
< t -tag size = "small" : theme = "row.status === 'READY' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'"
variant = "light" > { { statusLabel ( row . status ) } } < / t - t a g >
< / template >
< template # enabled = "{ row }" >
< t -switch v -model = " row._enabled " size = "small" @change ="toggle(row)" / >
< / template >
< template # op = "{ row }" >
< t -space :size ="2" >
< t -button size = "small" variant = "text" @click ="viewDetail(row.id)" > 查看 < / t -button >
< t -button v-show ="row.filePath" size="small" variant="text" @click="download(row.id)" > 下载 < / t -button >
< t -button size = "small" variant = "text" @click ="openMove([row.id])" > 移动 < / t -button >
< t -button size = "small" variant = "text" @click ="reprocess(row.id)" > 重新处理 < / t -button >
< t -button size = "small" variant = "text" theme = "danger" @click ="remove(row.id)" > 删除 < / t -button >
< / t - s p a c e >
< / template >
< / BaseTable >
< / div >
< / div >
< / div >
<!-- 目录创建 / 重命名弹窗 -- >
< t -dialog v -model :visible ="folderDialog.visible" :header ="folderDialog.title" width = "420px" :footer ="false" >
< t -form label -align = " top " >
< t -form -item label = "目录名称" >
< t -input v -model = " folderDialog.name " placeholder = "请输入目录名称" @enter ="submitFolderDialog" / >
< / t - f o r m - i t e m >
< / t - f o r m >
< div class = "dialog-footer" >
< t -button variant = "outline" @ click = "folderDialog.visible = false" > 取消 < / t - b u t t o n >
< t -button theme = "primary" @click ="submitFolderDialog" > 保存 < / t -button >
< / div >
< / t - d i a l o g >
<!-- 移动文档弹窗 : 选择目标位置 ( 分类根或目录 ) -- >
< t -dialog v -model :visible ="moveDialog.visible" header = "移动文档" width = "440px" :footer ="false" >
< div class = "move-tree-tip" > 选择目标位置 ( 分类根或目录 ) : < / div >
< t -tree
v - if = "moveDialog.visible"
: data = "moveTreeData"
: keys = "{ value: 'value', label: 'label', children: 'children' }"
: activable = "true"
: active - multiple = "false"
hover
expand - all
v - model : actived = "moveActiveKeys"
@ active = "onMoveTreeChange"
style = "max-height:360px;overflow:auto;"
/ >
< div class = "dialog-footer" >
< t -button variant = "outline" @ click = "moveDialog.visible = false" > 取消 < / t - b u t t o n >
< t -button theme = "primary" :disabled ="!moveDialog.target" @click ="confirmMove" > 移动 < / t -button >
< / div >
< / t - d i a l o g >
< / t - c a r d >
< / template >
< script setup lang = "ts" >
import { ref , computed , onUnmounted } from 'vue'
import { ref , computed , onMounted , on Unmounted } from 'vue'
import { useDocumentStore } from '@/stores/document'
import { useCategoryStore } from '@/stores/category'
import { useDialogStore } from '@/stores/dialog'
import { listDocuments , deleteDocument , reprocessDocument , batchDeleteDocuments , batchReprocessDocuments , toggleDocument , batchToggleDocuments , batchMoveDocuments , downloadDocument } from '@/api/document'
import { getFolderList , createFolder , updateFolder , deleteFolder } from '@/api/folder'
import { getCategoryTree } from '@/api/category'
import { toast } from '@/utils/toast'
import { formatBytes , formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm'
import { useDebounce } from '@/composables/useDebounce'
import { MoreIcon } from 'tdesign-icons-vue-next'
const { confirm } = useConfirm ( )
const { debounce } = useDebounce ( )
@ -88,9 +174,39 @@ const filterStatus = ref('')
const keyword = ref ( '' )
const filterTag = ref ( '' )
const selectedIds = ref ( new Set < string > ( ) )
const moveCategoryId = ref ( '' )
const hasProcessing = ref ( false )
/ / 移 动 文 档 弹 窗 状 态
const moveDialog = ref ( { visible : false , ids : [ ] as string [ ] , target : null as any } )
const moveActiveKeys = ref < string [ ] > ( [ ] )
/ / 目 录 树 相 关 状 态
const treeData = ref < any [ ] > ( [ ] )
const activedKeys = ref < string [ ] > ( [ ] )
const filterFolder = ref ( '' )
const folderTreeLoading = ref ( false )
/ / 目 录 树 宽 度 ( 可 拖 动 , 不 持 久 化 )
const treeWidth = ref ( 240 )
const isResizing = ref ( false )
const MIN_TREE_WIDTH = 200
const MAX_TREE_WIDTH = 480
let resizeCleanup : ( ( ) => void ) | null = null
/ / 目 录 i d → { n a m e , p a r e n t I d } , 位 置 列 据 此 沿 p a r e n t I d 回 溯 拼 目 录 路 径
const folderMap = ref < Record < string , { name : string ; parentId : string | number } > > ( { } )
/ / 表 格 排 序 状 态 ( 服 务 端 排 序 )
const sortInfo = ref < { sortBy : string ; descending : boolean } | null > ( null )
/ / 目 录 创 建 / 重 命 名 弹 窗
const folderDialog = ref ( {
visible : false ,
title : '' ,
name : '' ,
mode : 'create' as 'create' | 'rename' ,
categoryId : '' ,
parentId : '' ,
folderId : '' ,
} )
/ / = = = = = = = = = = = = = = = = = = = = 轮 询 = = = = = = = = = = = = = = = = = = = =
let pollTimer : any = null
@ -112,27 +228,32 @@ function stopPolling() {
if ( pollTimer ) { clearInterval ( pollTimer ) ; pollTimer = null }
}
onUnmounted ( ( ) => { stopPolling ( ) } )
onUnmounted ( ( ) => { stopPolling ( ) ; if ( resizeCleanup ) resizeCleanup ( ) } )
/ / = = = = = = = = = = = = = = = = = = = = 表 格 列 = = = = = = = = = = = = = = = = = = = =
const columns = [
{ colKey : 'row-select' , type : 'multiple' , width : 50 } ,
{ colKey : 'title' , title : '标题' , width : 220 } ,
{ colKey : 'fileType' , title : '类型' , width : 70 } ,
{ colKey : 'fileSize' , title : '大小' , width : 80 } ,
{ colKey : 'categoryId' , title : '分类 ' , width : 1 00 } ,
{ colKey : 'title' , title : '标题' , width : 220 , sorter : true } ,
{ colKey : 'fileType' , title : '类型' , width : 70 , sorter : true } ,
{ colKey : 'fileSize' , title : '大小' , width : 80 , sorter : true } ,
{ colKey : 'categoryId' , title : '位置 ' , width : 2 00 } ,
{ colKey : 'status' , title : '状态' , width : 80 } ,
{ colKey : 'enabled' , title : '启用' , width : 60 } ,
{ colKey : 'chunkCount' , title : '分块' , width : 60 } ,
{ colKey : 'createTime' , title : '创建时间' , width : 160 , cell : ( _ : any , { row } : any ) => formatDate ( row . createTime ) } ,
{ colKey : 'op' , title : '操作' , width : 20 0 } ,
{ colKey : 'chunkCount' , title : '分块' , width : 60 , sorter : true } ,
{ colKey : 'createTime' , title : '创建时间' , width : 160 , sorter : true , cell : ( _ : any , { row } : any ) => formatDate ( row . createTime ) } ,
{ colKey : 'op' , title : '操作' , width : 24 0 } ,
]
/ / = = = = = = = = = = = = = = = = = = = = 选 项 = = = = = = = = = = = = = = = = = = = =
const categoryOptions = computed ( ( ) => [ { label : '全部分类' , value : '' } , ... categoryStore . categories . map ( c => ( { label : c . name , value : String ( c . id ) } ) ) ] )
const statusOptions = [ { label : '全部状态' , value : '' } , { label : '已完成' , value : 'READY' } , { label : '处理中' , value : 'PROCESSING' } , { label : '失败' , value : 'FAILED' } ]
const tagOptions = computed ( ( ) => [ { label : '全部标签' , value : '' } , ... ( documentStore . tags || [ ] ) . map ( ( t : any ) => ( { label : ` ${ t . tag } ( ${ t . count } ) ` , value : t . tag } ) ) ] )
const moveCategoryOptions = computed ( ( ) => [ { label : '移动到分类...' , value : '' } , ... categoryStore . categories . map ( c => ( { label : c . name , value : String ( c . id ) } ) ) , { label : '未分类' , value : '0' } ] )
/ / 移 动 弹 窗 目 录 树 : 确 保 有 「 未 分 类 」 节 点 , 保 留 移 出 到 未 分 类 的 能 力
const moveTreeData = computed ( ( ) => {
const hasUnclassified = treeData . value . some ( n => n . value === 'cat:0' )
if ( hasUnclassified ) return treeData . value
return [ { value : 'cat:0' , label : '未分类' , nodeType : 'category' , categoryId : '0' , children : [ ] } , ... treeData . value ]
} )
/ / = = = = = = = = = = = = = = = = = = = = 工 具 函 数 = = = = = = = = = = = = = = = = = = = =
function getDocTags ( doc : any ) : string [ ] {
@ -145,15 +266,260 @@ function getDocTags(doc: any): string[] {
function statusLabel ( s : string ) { return s === 'READY' ? '已完成' : s === 'PROCESSING' ? '处理中' : s === 'FAILED' ? '失败' : s }
function formatSize ( b : number ) { return b ? formatBytes ( b ) : '-' }
/** 计算文档所在目录路径(不含分类名);直接挂分类根/无目录时返回「—」 */
function docPath ( row : any ) : string {
let fid = row . folderId != null ? String ( row . folderId ) : '0'
const chain : string [ ] = [ ]
let guard = 0
while ( fid && fid !== '0' && guard ++ < 20 ) {
const f = folderMap . value [ fid ]
if ( ! f ) break
chain . unshift ( f . name )
fid = String ( f . parentId ? ? '0' )
}
return chain . length ? chain . join ( ' / ' ) : '—'
}
/ / = = = = = = = = = = = = = = = = = = = = 搜 索 防 抖 = = = = = = = = = = = = = = = = = = = =
const debouncedLoad = debounce ( ( ) => load ( 1 ) )
/ / = = = = = = = = = = = = = = = = = = = = 目 录 树 : 组 装 数 据 = = = = = = = = = = = = = = = = = = = =
/** 把分类树 + 扁平目录数组组装成 t-tree 所需的数据结构 */
function buildTree ( cats : any [ ] , folders : any [ ] ) : any [ ] {
/ / 目 录 按 c a t e g o r y I d 分 组
const byCat = new Map < string , any [ ] > ( )
for ( const f of folders || [ ] ) {
const cid = String ( f . categoryId ? ? '' )
if ( ! byCat . has ( cid ) ) byCat . set ( cid , [ ] )
byCat . get ( cid ) ! . push ( f )
}
/ / 记 录 已 挂 到 分 类 节 点 的 分 类 i d , 用 于 兜 底 处 理 孤 儿 目 录
const consumed = new Set < string > ( )
/ / 扁 平 目 录 数 组 按 p a r e n t I d 递 归 组 装 成 树
function buildFolderTree ( list : any [ ] ) : any [ ] {
const map = new Map < string , any > ( )
list . forEach ( f => map . set ( String ( f . id ) , { ... f , children : [ ] } ) )
const roots : any [ ] = [ ]
map . forEach ( node => {
const pid = String ( node . parentId ? ? '' )
if ( pid && pid !== '0' && map . has ( pid ) ) map . get ( pid ) ! . children . push ( node )
else roots . push ( node )
} )
return roots
}
/ / 目 录 节 点 → t - t r e e 节 点
function folderNode ( f : any ) : any {
return {
value : 'folder:' + f . id ,
label : f . name ,
nodeType : 'folder' ,
categoryId : f . categoryId ,
folderId : f . id ,
children : ( f . children || [ ] ) . map ( folderNode ) ,
}
}
/ / 分 类 节 点 → t - t r e e 节 点 ( c h i l d r e n = 该 分 类 下 目 录 + 子 分 类 )
function categoryNode ( c : any ) : any {
const cid = String ( c . id )
consumed . add ( cid )
const folderRoots = buildFolderTree ( byCat . get ( cid ) || [ ] ) . map ( folderNode )
const subCats = ( c . children || [ ] ) . map ( categoryNode )
return {
value : 'cat:' + cid ,
label : c . name ,
nodeType : 'category' ,
categoryId : c . id ,
children : [ ... folderRoots , ... subCats ] ,
}
}
const roots = ( cats || [ ] ) . map ( categoryNode )
/ / 兜 底 : 未 匹 配 到 任 何 分 类 的 目 录 挂 到 「 未 分 类 」 节 点 , 避 免 数 据 丢 失
const orphanIds : string [ ] = [ ]
for ( const cid of byCat . keys ( ) ) {
if ( ! consumed . has ( cid ) ) orphanIds . push ( cid )
}
if ( orphanIds . length ) {
const orphanFolders = orphanIds . flatMap ( cid => buildFolderTree ( byCat . get ( cid ) || [ ] ) . map ( folderNode ) )
roots . push ( { value : 'cat:0' , label : '未分类' , nodeType : 'category' , categoryId : '0' , children : orphanFolders } )
}
return roots
}
/** 拉取分类树与目录列表并组装目录树 */
async function loadTreeData ( ) {
folderTreeLoading . value = true
try {
const [ catRes , folderRes ] = await Promise . all ( [ getCategoryTree ( ) , getFolderList ( ) ] )
const cats = catRes . success ? ( catRes . data || [ ] ) : [ ]
const folders = folderRes . success ? ( folderRes . data || [ ] ) : [ ]
treeData . value = buildTree ( cats , folders )
/ / 目 录 映 射 : 位 置 列 只 回 溯 目 录 链 , 无 需 分 类 链 映 射
folderMap . value = { }
for ( const f of folders || [ ] ) folderMap . value [ String ( f . id ) ] = { name : f . name , parentId : f . parentId }
} catch ( e : any ) {
toast ( '加载目录树失败:' + e . message , 'error' )
} finally {
folderTreeLoading . value = false
}
}
/** 目录树节点激活:按类型联动分类/目录筛选 */
function onTreeActive ( value : any [ ] , ctx : any ) {
/ / 程 序 化 清 空 高 亮 ( t r i g g e r = s e t I t e m ) 或 空 选 中 时 不 触 发 过 滤
if ( ! value || value . length === 0 ) return
if ( ctx ? . trigger === 'setItem' ) return
const data = ctx ? . node ? . data
if ( ! data ) return
if ( data . nodeType === 'category' ) {
filterCategory . value = data . categoryId != null ? String ( data . categoryId ) : ''
filterFolder . value = ''
} else if ( data . nodeType === 'folder' ) {
filterFolder . value = String ( data . folderId )
filterCategory . value = data . categoryId != null ? String ( data . categoryId ) : ''
}
load ( 1 )
}
/** 分类下拉变化时:清空目录筛选与树高亮 */
function onCategoryChange ( ) {
filterFolder . value = ''
activedKeys . value = [ ]
load ( 1 )
}
/** 清除目录筛选(点击工具栏标签关闭) */
function clearFolderFilter ( ) {
filterFolder . value = ''
activedKeys . value = [ ]
load ( 1 )
}
/** 目录树宽度拖拽 */
function startResize ( e : MouseEvent ) {
e . preventDefault ( )
const startX = e . clientX
const startWidth = treeWidth . value
isResizing . value = true
const onMove = ( ev : MouseEvent ) => {
treeWidth . value = Math . min ( MAX_TREE_WIDTH , Math . max ( MIN_TREE_WIDTH , startWidth + ( ev . clientX - startX ) ) )
}
const onUp = ( ) => {
document . removeEventListener ( 'mousemove' , onMove )
document . removeEventListener ( 'mouseup' , onUp )
document . body . style . userSelect = ''
document . body . style . cursor = ''
isResizing . value = false
resizeCleanup = null
}
document . addEventListener ( 'mousemove' , onMove )
document . addEventListener ( 'mouseup' , onUp )
document . body . style . userSelect = 'none'
document . body . style . cursor = 'col-resize'
resizeCleanup = onUp
}
/** 表格排序变化:更新排序状态并重新加载(服务端排序) */
function onSortChange ( sort : any ) {
sortInfo . value = sort && sort . sortBy ? { sortBy : sort . sortBy , descending : ! ! sort . descending } : null
load ( 1 )
}
/ / = = = = = = = = = = = = = = = = = = = = 目 录 操 作 = = = = = = = = = = = = = = = = = = = =
/** 目录/分类节点操作菜单项 */
function nodeActionOptions ( node : any ) : any [ ] {
if ( node . data . nodeType === 'folder' ) {
return [
{ content : '新建子目录' , value : 'create' } ,
{ content : '重命名' , value : 'rename' } ,
{ content : '删除' , value : 'remove' , theme : 'error' , divider : true } ,
]
}
return [ { content : '新建目录' , value : 'create' } ]
}
/** 目录/分类节点操作分发 */
function onNodeAction ( item : any , node : any ) {
const d = node . data
if ( item . value === 'create' ) {
openCreateFolder ( d . categoryId , d . nodeType === 'folder' ? d . folderId : undefined )
} else if ( item . value === 'rename' ) {
openRenameFolder ( d )
} else if ( item . value === 'remove' ) {
removeFolder ( d )
}
}
function openCreateFolder ( categoryId : any , parentId ? : any ) {
folderDialog . value = {
visible : true ,
title : parentId != null ? '新建子目录' : '新建目录' ,
name : '' ,
mode : 'create' ,
categoryId : String ( categoryId ) ,
parentId : parentId != null ? String ( parentId ) : '' ,
folderId : '' ,
}
}
function openRenameFolder ( node : any ) {
folderDialog . value = {
visible : true ,
title : '重命名目录' ,
name : node . label || '' ,
mode : 'rename' ,
categoryId : '' ,
parentId : '' ,
folderId : String ( node . folderId ) ,
}
}
async function submitFolderDialog ( ) {
const name = folderDialog . value . name . trim ( )
if ( ! name ) { toast ( '请输入目录名称' , 'error' ) ; return }
try {
if ( folderDialog . value . mode === 'create' ) {
const payload : any = { name , categoryId : folderDialog . value . categoryId }
if ( folderDialog . value . parentId ) payload . parentId = folderDialog . value . parentId
const r = await createFolder ( payload )
if ( r . success ) { toast ( '目录创建成功' , 'success' ) ; folderDialog . value . visible = false ; await loadTreeData ( ) }
else toast ( r . message || '创建失败' , 'error' )
} else {
const r = await updateFolder ( folderDialog . value . folderId , { name } )
if ( r . success ) { toast ( '目录重命名成功' , 'success' ) ; folderDialog . value . visible = false ; await loadTreeData ( ) }
else toast ( r . message || '重命名失败' , 'error' )
}
} catch ( e : any ) { toast ( '操作失败:' + e . message , 'error' ) }
}
async function removeFolder ( node : any ) {
if ( ! await confirm ( ` 确定删除目录「 ${ node . label } 」?目录内文档将被移到分类根 ` ) ) return
try {
const r = await deleteFolder ( node . folderId )
if ( r . success ) {
const moved = r . data ? . movedDocuments
toast ( moved != null ? ` 目录已删除, ${ moved } 个文档已移至分类根 ` : ( r . message || '目录已删除' ) , 'success' )
/ / 若 删 除 的 是 当 前 筛 选 目 录 , 清 空 目 录 筛 选
if ( filterFolder . value === String ( node . folderId ) ) { filterFolder . value = '' ; activedKeys . value = [ ] }
await loadTreeData ( )
loadData ( )
documentStore . loadStats ( )
} else toast ( r . message || '删除失败' , 'error' )
} catch ( e : any ) { toast ( '删除失败:' + e . message , 'error' ) }
}
/ / = = = = = = = = = = = = = = = = = = = = 数 据 加 载 = = = = = = = = = = = = = = = = = = = =
async function loadData ( p = page . value ) {
page . value = p
loading . value = true
try {
const json = await listDocuments ( p , pageSize . value , filterCategory . value || undefined , filterStatus . value || undefined , keyword . value . trim ( ) || undefined , filterTag . value || undefined )
const json = await listDocuments ( p , pageSize . value , filterCategory . value || undefined , filterStatus . value || undefined , keyword . value . trim ( ) || undefined , filterTag . value || undefined , filterFolder . value || undefined , sortInfo . value ? . sortBy || undefined , sortInfo . value ? ( sortInfo . value . descending ? 'desc' : 'asc' ) : undefined )
if ( json . success ) {
documents . value = ( json . data || [ ] ) . map ( ( d : any ) => ( { ... d , _enabled : d . enabled !== false } ) )
total . value = json . total || 0
@ -241,24 +607,91 @@ async function batchDisable() {
} catch ( e : any ) { toast ( '批量禁用失败:' + e . message , 'error' ) }
}
async function batchMove ( ) {
const ids = Array . from ( selectedIds . value )
const catId = moveCategoryId . value
if ( ! catId && catId !== '0' ) { toast ( '请选择目标分类' , 'error' ) ; return }
const catName = catId === '0' ? '未分类' : categoryStore . getCategoryName ( catId )
if ( ! await confirm ( ` 确定将选中的 ${ ids . length } 个文档移动到「 ${ catName } 」? ` ) ) return
function openMove ( ids : string [ ] ) {
moveDialog . value = { visible : true , ids , target : null }
moveActiveKeys . value = [ ]
}
function onMoveTreeChange ( _value : any [ ] , ctx : any ) {
moveDialog . value . target = ctx ? . node ? . data ? ? null
}
async function confirmMove ( ) {
const t = moveDialog . value . target
if ( ! t ) { toast ( '请选择目标位置' , 'error' ) ; return }
/ / 目 标 为 目 录 → f o l d e r I d = 目 录 I D 、 c a t e g o r y I d = 目 录 所 属 分 类 ; 目 标 为 分 类 根 → f o l d e r I d = 0 、 c a t e g o r y I d = 分 类
const folderId = t . nodeType === 'folder' ? String ( t . folderId ? ? '0' ) : '0'
const categoryId = String ( t . categoryId ? ? '0' )
const ids = moveDialog . value . ids
try {
/ / 雪 花 I D 精 度 : 0 保 持 为 字 符 串 0 , 其 余 保 持 字 符 串
const json = await batchMoveDocuments ( ids , catId )
if ( json . success ) { toast ( json . message , 'success' ) ; selectedIds . value = new Set ( ) ; moveCategoryId . value = '' ; loadData ( ) ; documentStore . loadStats ( ) }
else toast ( json . message || '批量移动失败' , 'error' )
} catch ( e : any ) { toast ( '批量移动失败:' + e . message , 'error' ) }
const json = await batchMoveDocuments ( ids , categoryId , folderId )
if ( json . success ) {
toast ( json . message , 'success' )
moveDialog . value . visible = false
moveActiveKeys . value = [ ]
selectedIds . value = new Set ( )
loadData ( )
documentStore . loadStats ( )
} else {
toast ( json . message || '移动失败' , 'error' )
}
} catch ( e : any ) {
toast ( '移动失败:' + e . message , 'error' )
}
}
/ / 初 始 化
onMounted ( async ( ) => { await loadTreeData ( ) } )
loadData ( )
< / script >
< style scoped >
. selected - count { font - size : 12 px ; font - weight : 600 ; color : var ( -- td - text - color - primary ) ; }
/* 位置列:长路径省略号截断,悬停 title 看全 */
. doc - pos { display : inline - block ; max - width : 100 % ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; vertical - align : bottom ; }
/* 左右分栏布局 */
/* 卡片锁定一屏高度:左侧目录树独立滚动,右侧工具栏固定 + 表格内部滚动 */
. doc - list - card { height : 100 % ; min - height : 0 ; overflow : hidden ; display : flex ; flex - direction : column ; }
. doc - list - card : deep ( . t - card__header ) { flex - shrink : 0 ; }
/ * 关 键 修 复 : T D e s i g n 在 h e a d e r 与 b o d y 之 间 有 一 层 . t - l o a d i n g _ _ p a r e n t , 须 让 它 参 与 f l e x 高 度 链 ,
否则 . t - card__body 的 flex : 1 不生效 , 目录树无法在面板内滚动而撑高整页 * /
. doc - list - card : deep ( . t - loading__parent ) { flex : 1 ; min - height : 0 ; display : flex ; flex - direction : column ; overflow : hidden ; }
. doc - list - card : deep ( . t - card__body ) { flex : 1 ; min - height : 0 ; overflow : hidden ; display : flex ; flex - direction : column ; }
. doc - layout { flex : 1 ; min - height : 0 ; display : flex ; align - items : stretch ; }
. doc - tree - panel {
flex - shrink : 0 ;
display : flex ;
flex - direction : column ;
overflow : hidden ;
padding - right : 12 px ;
border - right : 1 px solid var ( -- color - border ) ;
}
. tree - resizer {
flex - shrink : 0 ;
width : 6 px ;
margin - left : - 6 px ;
cursor : col - resize ;
background : transparent ;
transition : background .2 s ;
}
. tree - resizer : hover , . tree - resizer . dragging { background : var ( -- color - primary ) ; }
. tree - panel - body { flex : 1 ; min - height : 0 ; overflow - y : auto ; overflow - x : hidden ; }
. doc - content { flex : 1 ; min - width : 0 ; overflow : hidden ; padding - left : 16 px ; display : flex ; flex - direction : column ; }
/* 表格宿主:撑满工具栏下方剩余高度,表格 max-height:100% 相对它解析,实现内部滚动与固定表头 */
. doc - table - wrap { flex : 1 ; min - height : 0 ; overflow : hidden ; }
. tree - panel - header { display : flex ; justify - content : space - between ; align - items : center ; margin - bottom : 8 px ; }
. tree - panel - title { font - size : 13 px ; font - weight : 600 ; }
. tree - loading { padding : 16 px ; font - size : 13 px ; color : var ( -- td - text - color - placeholder ) ; }
/* 树节点 hover 操作按钮 */
. tree - node { display : flex ; align - items : center ; width : 100 % ; min - width : 0 ; }
. tree - label - text { flex : 1 ; min - width : 0 ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. tree - ops { display : inline - flex ; align - items : center ; flex - shrink : 0 ; margin - left : 4 px ; visibility : hidden ; }
. tree - node : hover . tree - ops { visibility : visible ; }
. dialog - footer { display : flex ; justify - content : flex - end ; gap : 8 px ; margin - top : 8 px ; }
. move - tree - tip { font - size : 12 px ; color : var ( -- td - text - color - placeholder ) ; margin - bottom : 8 px ; }
< / style >