Files
shanxia2.0/web-admin/src/views/doclib/enter/index.vue
T

611 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 文档库: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>