611 lines
22 KiB
Vue
611 lines
22 KiB
Vue
<!-- 文档库:P04 左树右表,参考 system/user/index.vue -->
|
||
<!-- eslint-disable vue/no-v-model-argument -->
|
||
<template>
|
||
<div class="page-container doclib-page">
|
||
<section class="panel">
|
||
<div class="doclib-layout">
|
||
<aside class="doclib-aside" v-loading="treeLoading">
|
||
<div class="doclib-aside-heading"
|
||
><el-icon><FolderOpened /></el-icon><span>文档目录</span></div
|
||
>
|
||
<el-input
|
||
v-model="treeKeyword"
|
||
placeholder="搜索文件夹"
|
||
clearable
|
||
:prefix-icon="Search"
|
||
/>
|
||
<el-tree
|
||
ref="treeRef"
|
||
class="doclib-tree"
|
||
:data="nodes"
|
||
:props="{ label: 'name', children: 'children' }"
|
||
node-key="id"
|
||
highlight-current
|
||
default-expand-all
|
||
:expand-on-click-node="false"
|
||
:filter-node-method="filterNode"
|
||
@node-click="selectFolder"
|
||
>
|
||
<template #default="{ data }"
|
||
><span class="doclib-tree-node" :title="data.fullPath"
|
||
><el-icon
|
||
><Share v-if="data.scope === 'shared' && !data.folderId" /><Folder
|
||
v-else /></el-icon
|
||
><span>{{ data.name }}</span
|
||
><small>{{ data.count }}</small></span
|
||
></template
|
||
>
|
||
</el-tree>
|
||
<p class="doclib-aside-note"
|
||
><el-icon><Lock /></el-icon>文档默认仅自己可见。<br />分享后,指定人员可查看和下载。</p
|
||
>
|
||
</aside>
|
||
<div class="doclib-main">
|
||
<div class="panel-top doclib-panel-top">
|
||
<div
|
||
><h2 class="title-bar-heading">{{ currentNode?.name || '我的文档' }}</h2
|
||
><p class="doclib-path">{{ currentPath }}</p></div
|
||
>
|
||
<form class="doclib-search" @submit.prevent="search"
|
||
><el-input
|
||
v-model="keyword"
|
||
placeholder="搜索文档名称"
|
||
clearable
|
||
:prefix-icon="Search"
|
||
@clear="search"
|
||
/><el-button :loading="loading" @click="search"
|
||
><el-icon><Search /></el-icon>搜索</el-button
|
||
></form
|
||
>
|
||
</div>
|
||
<div class="list-toolbar"
|
||
><div class="list-toolbar-row doclib-toolbar">
|
||
<span class="doclib-selected"
|
||
>已选 <b>{{ selected.length }}</b> 项</span
|
||
>
|
||
<el-button
|
||
v-if="scope === 'mine'"
|
||
type="primary"
|
||
:disabled="!currentNode || treeLoading"
|
||
@click="openUpload"
|
||
><el-icon><Upload /></el-icon>上传文档</el-button
|
||
>
|
||
<el-button
|
||
v-if="scope === 'mine'"
|
||
:disabled="!currentNode || treeLoading"
|
||
@click="openFolder(false)"
|
||
><el-icon><FolderAdd /></el-icon>新建文件夹</el-button
|
||
>
|
||
<el-button
|
||
:disabled="!actions.share || loading || busy"
|
||
@click="openShare(selected)"
|
||
><el-icon><Share /></el-icon>设置分享</el-button
|
||
>
|
||
<el-button
|
||
:disabled="!actions.download || loading"
|
||
:loading="downloading"
|
||
@click="download(selected)"
|
||
><el-icon><Download /></el-icon>下载</el-button
|
||
>
|
||
<el-button
|
||
type="danger"
|
||
plain
|
||
:disabled="!actions.delete || loading || busy"
|
||
@click="removeFiles(selected)"
|
||
><el-icon><Delete /></el-icon>删除</el-button
|
||
>
|
||
<el-button v-if="manageableFolder" :disabled="busy" @click="openFolder(true)"
|
||
><el-icon><Edit /></el-icon>重命名文件夹</el-button
|
||
>
|
||
<el-button
|
||
v-if="manageableFolder"
|
||
:disabled="busy || !!currentNode?.count || !!currentNode?.children?.length"
|
||
@click="removeFolder"
|
||
><el-icon><Delete /></el-icon>删除空文件夹</el-button
|
||
>
|
||
<el-button :loading="loading || treeLoading" @click="refresh"
|
||
><el-icon><Refresh /></el-icon>刷新</el-button
|
||
>
|
||
</div></div
|
||
>
|
||
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
|
||
<div class="list-body list-wrap">
|
||
<ListTable
|
||
ref="tableRef"
|
||
table-key="doclib-files"
|
||
class="list-table"
|
||
height="100%"
|
||
:data="documents"
|
||
v-loading="loading"
|
||
stripe
|
||
row-key="id"
|
||
:empty-text="
|
||
query
|
||
? '没有匹配的文档'
|
||
: scope === 'shared'
|
||
? '同事分享给你的文档会出现在这里'
|
||
: '这里还没有文档'
|
||
"
|
||
@selection-change="selected = $event"
|
||
@row-dblclick="openDetail"
|
||
>
|
||
<el-table-column type="selection" width="55" align="center" />
|
||
<el-table-column label="文档名称" min-width="280"
|
||
><template #default="{ row }"
|
||
><div class="doclib-file-cell"
|
||
><div class="doclib-file-icon"
|
||
><el-icon :size="24"><Document /></el-icon></div
|
||
><div class="doclib-file-text"
|
||
><el-link type="primary" @click="openDetail(row)">{{ row.name }}</el-link
|
||
><p>{{ row.folderPath || '我的文档 / 根目录' }}</p></div
|
||
></div
|
||
></template
|
||
></el-table-column
|
||
>
|
||
<el-table-column label="大小" min-width="100"
|
||
><template #default="{ row }">{{
|
||
formatFileSize(row.size)
|
||
}}</template></el-table-column
|
||
>
|
||
<el-table-column prop="ownerName" label="上传人" min-width="110" />
|
||
<el-table-column label="分享人员" min-width="180"
|
||
><template #default="{ row }"
|
||
><div v-if="row.shares.length" class="doclib-shares"
|
||
><el-tag
|
||
v-for="person in row.shares.slice(0, 3)"
|
||
:key="person.id"
|
||
type="info"
|
||
size="small"
|
||
>{{ person.name }}</el-tag
|
||
><el-tooltip
|
||
v-if="row.shares.length > 3"
|
||
:content="row.shares.map((person: DoclibPerson) => person.name).join('、')"
|
||
><span class="doclib-muted">等 {{ row.shares.length }} 人</span></el-tooltip
|
||
></div
|
||
><el-tag v-else type="info" size="small">未分享</el-tag></template
|
||
></el-table-column
|
||
>
|
||
<el-table-column prop="createTime" label="上传时间" min-width="180" />
|
||
</ListTable>
|
||
</div>
|
||
<div class="doclib-pagination"
|
||
><span class="doclib-muted">按上传时间从新到旧排列</span
|
||
><el-pagination
|
||
v-model:current-page="page"
|
||
v-model:page-size="pageSize"
|
||
:total="total"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
@size-change="changePageSize"
|
||
@current-change="loadFiles"
|
||
/></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<p class="doclib-permission"
|
||
><el-icon><InfoFilled /></el-icon
|
||
>分享人员可查看、下载;只有上传人和超级管理员可以删除文档或设置分享。</p
|
||
>
|
||
<UploadDialog
|
||
v-model="uploadVisible"
|
||
:folder-id="uploadFolderId"
|
||
:destinations="ownFolderOptions"
|
||
:max-bytes="maxUploadBytes"
|
||
@changed="refresh"
|
||
@download="download([$event])"
|
||
@view="openDetail"
|
||
/>
|
||
<ShareDialog v-model="shareVisible" :documents="shareDocuments" @saved="shareSaved" />
|
||
<el-dialog
|
||
v-model="folderVisible"
|
||
:title="renameMode ? '重命名文件夹' : '新建文件夹'"
|
||
width="560px"
|
||
class="doclib-dialog dialog-form-layout"
|
||
align-center
|
||
destroy-on-close
|
||
:close-on-click-modal="!savingFolder"
|
||
:close-on-press-escape="!savingFolder"
|
||
:show-close="!savingFolder"
|
||
>
|
||
<el-form label-position="top" @submit.prevent="saveFolder"
|
||
><div class="form-grid-2"
|
||
><el-form-item label="文件夹名称" required class="form-col-span-2"
|
||
><el-input
|
||
v-model="folderName"
|
||
maxlength="50"
|
||
show-word-limit
|
||
placeholder="请输入文件夹名称"
|
||
:disabled="savingFolder" /></el-form-item
|
||
><el-form-item :label="renameMode ? '当前路径' : '上级文件夹'" class="form-col-span-2"
|
||
><el-input v-if="renameMode" :model-value="folderContextPath" readonly />
|
||
<el-select
|
||
v-else
|
||
v-model="folderContextId"
|
||
:disabled="savingFolder"
|
||
class="doclib-full-width"
|
||
aria-label="上级文件夹"
|
||
>
|
||
<el-option
|
||
v-for="option in ownFolderOptions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
:label="option.label"
|
||
><span :style="{ paddingLeft: `${option.depth * 14}px` }">{{
|
||
option.name
|
||
}}</span></el-option
|
||
>
|
||
</el-select></el-form-item
|
||
></div
|
||
></el-form
|
||
>
|
||
<template #footer
|
||
><el-button :disabled="savingFolder" @click="folderVisible = false"
|
||
><el-icon><Close /></el-icon>取消</el-button
|
||
><el-button type="primary" :loading="savingFolder" @click="saveFolder"
|
||
><el-icon><Check /></el-icon>保存</el-button
|
||
></template
|
||
>
|
||
</el-dialog>
|
||
<SystemViewDialog
|
||
v-model:visible="detailVisible"
|
||
:title="detail?.name || '文档详情'"
|
||
:icon="Document"
|
||
:loading="detailLoading"
|
||
:meta="detail ? `${detail.ownerName} · ${formatFileSize(detail.size)}` : ''"
|
||
:sections="detailSections"
|
||
>
|
||
<template v-if="detail" #hero-actions
|
||
><el-button type="primary" :loading="downloading" @click="download([detail])"
|
||
><el-icon><Download /></el-icon>下载文档</el-button
|
||
><el-button v-if="detail.canManage" :disabled="busy" @click="openShare(detail)"
|
||
><el-icon><Share /></el-icon>设置分享</el-button
|
||
><el-button
|
||
v-if="detail.canManage"
|
||
type="danger"
|
||
plain
|
||
:disabled="busy"
|
||
@click="removeFiles([detail])"
|
||
><el-icon><Delete /></el-icon>删除</el-button
|
||
></template
|
||
>
|
||
</SystemViewDialog>
|
||
</div>
|
||
</template>
|
||
<script setup lang="ts">
|
||
import {
|
||
FolderOpened,
|
||
Search,
|
||
Share,
|
||
Folder,
|
||
Lock,
|
||
Upload,
|
||
FolderAdd,
|
||
Download,
|
||
Delete,
|
||
Edit,
|
||
Refresh,
|
||
Document,
|
||
InfoFilled,
|
||
Close,
|
||
Check
|
||
} from '@element-plus/icons-vue'
|
||
import ListTable from '@/components/business/list-table/index.vue'
|
||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||
import type { ViewSection } from '@/components/business/system-view-dialog/types'
|
||
import {
|
||
fetchDoclibTree,
|
||
fetchDoclibFiles,
|
||
fetchDoclibFile,
|
||
createDoclibFolder,
|
||
renameDoclibFolder,
|
||
deleteDoclibFolder,
|
||
deleteDoclibFiles,
|
||
downloadDoclibFile,
|
||
type DoclibDocument,
|
||
type DoclibFolder,
|
||
type DoclibPerson
|
||
} from '@/api/doclib'
|
||
import {
|
||
actionsFor,
|
||
findFolder,
|
||
formatFileSize,
|
||
createLatestRequest,
|
||
folderOptions
|
||
} from './model'
|
||
import UploadDialog from './UploadDialog.vue'
|
||
import ShareDialog from './ShareDialog.vue'
|
||
defineOptions({ name: 'DoclibEnter' })
|
||
const nodes = ref<DoclibFolder[]>([])
|
||
const currentNode = ref<DoclibFolder | null>(null)
|
||
const ownFolderOptions = computed(() => folderOptions(nodes.value))
|
||
const treeKeyword = ref('')
|
||
const treeRef = ref<any>()
|
||
const tableRef = ref<any>()
|
||
const documents = ref<DoclibDocument[]>([])
|
||
const selected = ref<DoclibDocument[]>([])
|
||
const keyword = ref('')
|
||
const query = ref('')
|
||
const page = ref(1)
|
||
const pageSize = ref(20)
|
||
const total = ref(0)
|
||
const loading = ref(false)
|
||
const treeLoading = ref(false)
|
||
const busy = ref(false)
|
||
const loadError = ref('')
|
||
const maxUploadBytes = ref(50 * 1024 * 1024)
|
||
const filesRequest = createLatestRequest()
|
||
const treeRequest = createLatestRequest()
|
||
const detailRequest = createLatestRequest()
|
||
const scope = computed(() => currentNode.value?.scope || 'mine')
|
||
const currentPath = computed(() => currentNode.value?.fullPath || '我的文档 / 全部文件')
|
||
const actions = computed(() => actionsFor(selected.value))
|
||
const manageableFolder = computed(
|
||
() => !!currentNode.value?.folderId && currentNode.value.canManage && scope.value !== 'shared'
|
||
)
|
||
watch(treeKeyword, (value) => treeRef.value?.filter(value))
|
||
function filterNode(value: string, data: DoclibFolder) {
|
||
return !value || data.name.includes(value)
|
||
}
|
||
function clearSelection() {
|
||
selected.value = []
|
||
tableRef.value?.clearSelection()
|
||
}
|
||
function selectFolder(node: DoclibFolder) {
|
||
if (node.id === currentNode.value?.id) return
|
||
currentNode.value = node
|
||
keyword.value = ''
|
||
query.value = ''
|
||
page.value = 1
|
||
void loadFiles()
|
||
}
|
||
function search() {
|
||
query.value = keyword.value.trim()
|
||
page.value = 1
|
||
void loadFiles()
|
||
}
|
||
function changePageSize() {
|
||
page.value = 1
|
||
void loadFiles()
|
||
}
|
||
async function loadFiles() {
|
||
const token = filesRequest.start()
|
||
clearSelection()
|
||
loading.value = true
|
||
loadError.value = ''
|
||
try {
|
||
const result = await fetchDoclibFiles({
|
||
scope: scope.value,
|
||
folderId: currentNode.value?.folderId || '',
|
||
keyword: query.value,
|
||
page: page.value,
|
||
pageSize: pageSize.value
|
||
})
|
||
if (!filesRequest.isCurrent(token)) return
|
||
if (
|
||
page.value > 1 &&
|
||
!result.list.length &&
|
||
result.total < (page.value - 1) * pageSize.value + 1
|
||
) {
|
||
page.value = Math.max(1, Math.ceil(result.total / pageSize.value))
|
||
return await loadFiles()
|
||
}
|
||
documents.value = result.list
|
||
total.value = result.total
|
||
} catch {
|
||
if (filesRequest.isCurrent(token)) {
|
||
documents.value = []
|
||
total.value = 0
|
||
loadError.value = '文档加载失败,请点击刷新重试'
|
||
}
|
||
} finally {
|
||
if (filesRequest.isCurrent(token)) loading.value = false
|
||
}
|
||
}
|
||
async function refresh() {
|
||
const token = treeRequest.start()
|
||
treeLoading.value = true
|
||
try {
|
||
const result = await fetchDoclibTree()
|
||
if (!treeRequest.isCurrent(token)) return
|
||
nodes.value = result.nodes
|
||
maxUploadBytes.value = result.maxUploadBytes
|
||
currentNode.value =
|
||
findFolder(result.nodes, currentNode.value?.id || '') ||
|
||
result.nodes.find((node) => node.scope === 'mine') ||
|
||
result.nodes[0] ||
|
||
null
|
||
await nextTick()
|
||
treeRef.value?.setCurrentKey(currentNode.value?.id)
|
||
treeRef.value?.filter(treeKeyword.value)
|
||
await loadFiles()
|
||
} catch {
|
||
if (treeRequest.isCurrent(token)) loadError.value = '目录加载失败,请点击刷新重试'
|
||
} finally {
|
||
if (treeRequest.isCurrent(token)) treeLoading.value = false
|
||
}
|
||
}
|
||
const uploadVisible = ref(false)
|
||
const uploadFolderId = ref('')
|
||
function openUpload() {
|
||
if (scope.value !== 'mine') return
|
||
uploadFolderId.value = currentNode.value?.folderId || ''
|
||
uploadVisible.value = true
|
||
}
|
||
const folderVisible = ref(false)
|
||
const renameMode = ref(false)
|
||
const folderName = ref('')
|
||
const folderContextId = ref('')
|
||
const folderContextPath = ref('')
|
||
const savingFolder = ref(false)
|
||
function openFolder(rename: boolean) {
|
||
if (rename ? !manageableFolder.value : scope.value !== 'mine') return
|
||
renameMode.value = rename
|
||
folderName.value = rename ? currentNode.value?.name || '' : ''
|
||
folderContextId.value = currentNode.value?.folderId || ''
|
||
folderContextPath.value = folderContextId.value ? currentPath.value : '我的文档 / 根目录'
|
||
folderVisible.value = true
|
||
}
|
||
async function saveFolder() {
|
||
if (savingFolder.value) return
|
||
const name = folderName.value.trim()
|
||
if (!name) {
|
||
ElMessage.warning('请填写文件夹名称')
|
||
return
|
||
}
|
||
if (/[\\/]/.test(name)) {
|
||
ElMessage.warning('文件夹名称不能包含斜杠')
|
||
return
|
||
}
|
||
savingFolder.value = true
|
||
try {
|
||
if (renameMode.value) await renameDoclibFolder(folderContextId.value, name)
|
||
else await createDoclibFolder({ name, parentId: folderContextId.value })
|
||
folderVisible.value = false
|
||
ElMessage.success(renameMode.value ? '文件夹已重命名' : '文件夹已创建')
|
||
await refresh()
|
||
} catch {
|
||
/* 请求层统一展示错误 */
|
||
} finally {
|
||
savingFolder.value = false
|
||
}
|
||
}
|
||
async function removeFolder() {
|
||
const node = currentNode.value
|
||
if (!node || !manageableFolder.value || busy.value) return
|
||
busy.value = true
|
||
try {
|
||
await ElMessageBox.confirm(`确定删除空文件夹「${node.name}」吗?`, '删除空文件夹', {
|
||
type: 'warning',
|
||
confirmButtonText: '确认删除',
|
||
cancelButtonText: '取消'
|
||
})
|
||
await deleteDoclibFolder(node.folderId)
|
||
ElMessage.success('文件夹已删除')
|
||
await refresh()
|
||
} catch {
|
||
/* 取消确认或请求层已展示错误 */
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
const shareVisible = ref(false)
|
||
const shareDocuments = ref<DoclibDocument[]>([])
|
||
function openShare(rows: DoclibDocument | DoclibDocument[] | null | undefined) {
|
||
const list = (Array.isArray(rows) ? rows : rows ? [rows] : []).filter(
|
||
(row): row is DoclibDocument => !!row?.id && row.canManage
|
||
)
|
||
if (!list.length) return
|
||
shareDocuments.value = list
|
||
shareVisible.value = true
|
||
}
|
||
function shareSaved(rows: DoclibDocument[]) {
|
||
const updated = Array.isArray(rows) ? rows : []
|
||
const current = detail.value
|
||
if (current) {
|
||
const match = updated.find((row) => row.id === current.id)
|
||
if (match) detail.value = match
|
||
}
|
||
void refresh()
|
||
}
|
||
const detailVisible = ref(false)
|
||
const detailLoading = ref(false)
|
||
const detail = ref<DoclibDocument | null>(null)
|
||
const detailSections = computed<ViewSection[]>(() =>
|
||
detail.value
|
||
? [
|
||
{
|
||
title: '文档信息',
|
||
fields: [
|
||
{ label: '所在文件夹', value: detail.value.folderPath || '根目录', span: 'full' },
|
||
{ label: '上传人', value: detail.value.ownerName },
|
||
{ label: '文件大小', value: formatFileSize(detail.value.size) },
|
||
{ label: '上传时间', value: detail.value.createTime },
|
||
{ label: '更新时间', value: detail.value.updateTime },
|
||
{
|
||
label: '我的权限',
|
||
value: detail.value.canManage ? '可管理' : '可查看、下载',
|
||
tagType: detail.value.canManage ? 'success' : 'info'
|
||
},
|
||
{
|
||
label: '分享状态',
|
||
value: detail.value.shares.length ? '已分享' : '未分享',
|
||
tagType: detail.value.shares.length ? 'success' : 'info'
|
||
},
|
||
{
|
||
label: '分享人员',
|
||
value: detail.value.shares.map((person) => person.name).join('、') || '未分享',
|
||
span: 'full'
|
||
}
|
||
]
|
||
}
|
||
]
|
||
: []
|
||
)
|
||
watch(detailVisible, (value) => {
|
||
if (!value) detailRequest.invalidate()
|
||
})
|
||
async function openDetail(row: DoclibDocument) {
|
||
const token = detailRequest.start()
|
||
detail.value = null
|
||
detailVisible.value = true
|
||
detailLoading.value = true
|
||
try {
|
||
const result = await fetchDoclibFile(row.id)
|
||
if (detailRequest.isCurrent(token)) detail.value = result
|
||
} catch {
|
||
if (detailRequest.isCurrent(token)) detailVisible.value = false
|
||
} finally {
|
||
if (detailRequest.isCurrent(token)) detailLoading.value = false
|
||
}
|
||
}
|
||
async function removeFiles(rows: DoclibDocument[]) {
|
||
const targets = [...rows]
|
||
if (!actionsFor(targets).delete || busy.value) return
|
||
busy.value = true
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定删除这 ${targets.length} 份文档吗?删除后分享人员将无法继续查看或下载。`,
|
||
'删除文档',
|
||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
|
||
)
|
||
await deleteDoclibFiles(targets.map((row) => row.id))
|
||
if (targets.some((row) => row.id === detail.value?.id)) detailVisible.value = false
|
||
ElMessage.success('文档已删除')
|
||
await refresh()
|
||
} catch {
|
||
/* 取消确认或请求层已展示错误 */
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
const downloading = ref(false)
|
||
async function download(rows: DoclibDocument[]) {
|
||
if (downloading.value || !rows.length) return
|
||
downloading.value = true
|
||
try {
|
||
for (const row of [...rows]) {
|
||
const blob = await downloadDoclibFile(row.id)
|
||
const url = URL.createObjectURL(blob)
|
||
const link = window.document.createElement('a')
|
||
link.href = url
|
||
link.download = row.name
|
||
window.document.body.appendChild(link)
|
||
link.click()
|
||
link.remove()
|
||
setTimeout(() => URL.revokeObjectURL(url), 10000)
|
||
}
|
||
} catch {
|
||
/* 请求层统一展示错误 */
|
||
} finally {
|
||
downloading.value = false
|
||
}
|
||
}
|
||
onMounted(refresh)
|
||
onBeforeUnmount(() => {
|
||
filesRequest.invalidate()
|
||
treeRequest.invalidate()
|
||
detailRequest.invalidate()
|
||
})
|
||
</script>
|
||
<style lang="scss" src="./doclib.scss"></style>
|