feat(documents): implement document library management

This commit is contained in:
yelan
2026-08-27 16:47:31 +08:00
parent 24d8a9ece1
commit e7aa4843b7
7 changed files with 1378 additions and 17 deletions
@@ -1,12 +1,293 @@
<script setup lang="ts">
import PagePlaceholder from '@/components/PagePlaceholder.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
import DocumentLibraryHeader from './components/DocumentLibraryHeader.vue'
import DocumentPreviewDialog from './components/DocumentPreviewDialog.vue'
import DocumentTemplateCard from './components/DocumentTemplateCard.vue'
import { documentLibrary, documentStatusOptions } from './mock'
import type {
DepartmentTab,
DocumentDepartment,
DocumentFilterForm,
DocumentTemplate,
StatusTab,
} from './types'
const loading = ref(true)
const library = ref<DocumentDepartment[]>([])
const selectedTemplate = ref<DocumentTemplate | null>(null)
const filters = reactive<DocumentFilterForm>({
keyword: '',
department: null,
status: 'enabled',
})
const allTemplates = computed<DocumentTemplate[]>(() =>
library.value.flatMap((department) =>
department.categories.flatMap((category) =>
category.documents.map((template) => ({
...template,
departmentKey: department.key,
department: department.name,
category: category.name,
})),
),
),
)
const departmentTabs = computed<DepartmentTab[]>(() => [
{ key: null, label: '全部', count: allTemplates.value.length },
...library.value.map((department) => ({
key: department.key,
label: department.name,
count: allTemplates.value.filter((template) => template.departmentKey === department.key)
.length,
})),
])
const statusTabs = computed<StatusTab[]>(() =>
documentStatusOptions.map((option) => ({
...option,
count: allTemplates.value.filter((template) => template.status === option.key).length,
})),
)
const filteredTemplates = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return allTemplates.value.filter((template) => {
const matchesDepartment = !filters.department || template.departmentKey === filters.department
const matchesStatus = template.status === filters.status
const matchesKeyword =
!keyword ||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
return matchesDepartment && matchesStatus && matchesKeyword
})
})
async function loadLibrary() {
loading.value = true
await Promise.resolve()
library.value = documentLibrary
loading.value = false
}
function handlePreview(template: DocumentTemplate) {
selectedTemplate.value = template
}
function closePreview() {
selectedTemplate.value = null
}
function showAddMessage() {
ElMessage.info('原型演示:新增文档模板(类 Word 在线编辑器)')
}
function showExportMessage() {
ElMessage.info('原型演示:导出模板包(ZIP)')
}
function showEditMessage(template: DocumentTemplate) {
ElMessage.info('原型演示:编辑模板《' + template.name + '》')
}
function showInitiateMessage(template: DocumentTemplate) {
ElMessage.info('已选择《' + template.name + '》,签署工作台暂未实现')
}
function clearFilters() {
filters.keyword = ''
filters.department = null
}
function useTemplate(template: DocumentTemplate) {
closePreview()
ElMessage.info('原型演示:从模板《' + template.name + '》发起签署')
}
onMounted(loadLibrary)
</script>
<template>
<PagePlaceholder
group="管理"
title="文档库管理"
icon="▤"
description="文档库管理页面占位,待根据 Web 端需求实现。"
/>
<section class="documents-page">
<DocumentLibraryHeader
v-model="filters.keyword"
:total="filteredTemplates.length"
@add="showAddMessage"
@export="showExportMessage"
/>
<DocumentFilterTabs
v-model:department="filters.department"
v-model:status="filters.status"
:departments="departmentTabs"
:statuses="statusTabs"
/>
<div v-if="loading" class="template-grid" aria-label="正在加载文档模板">
<div v-for="index in 8" :key="index" class="template-skeleton">
<span class="skeleton-title" />
<span class="skeleton-line wide" />
<span class="skeleton-line" />
<span class="skeleton-line short" />
<span class="skeleton-footer" />
</div>
</div>
<div v-else-if="filteredTemplates.length" class="template-grid">
<DocumentTemplateCard
v-for="template in filteredTemplates"
:key="template.id"
:template="template"
@preview="handlePreview"
@edit="showEditMessage"
@initiate="showInitiateMessage"
/>
</div>
<div v-else class="empty-state">
<div class="empty-icon" aria-hidden="true">⌕</div>
<p>没有符合条件的模板</p>
<button
v-if="filters.keyword || filters.department"
type="button"
class="reset-button"
@click="clearFilters"
>
清除筛选
</button>
</div>
<DocumentPreviewDialog
:visible="Boolean(selectedTemplate)"
:template="selectedTemplate"
@close="closePreview"
@use="useTemplate"
/>
</section>
</template>
<style scoped>
.documents-page {
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
.template-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.template-skeleton {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 206px;
padding: 16px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.template-skeleton span {
display: block;
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
background-size: 400% 100%;
border-radius: 6px;
animation: skeleton-shimmer 1.4s ease infinite;
}
.skeleton-title {
width: 70%;
height: 20px;
}
.skeleton-line {
width: 90%;
height: 12px;
}
.skeleton-line.wide {
width: 100%;
}
.skeleton-line.short {
width: 62%;
}
.skeleton-footer {
width: 100%;
height: 26px;
margin-top: auto;
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
.empty-state {
padding: 56px 24px;
color: var(--mut);
text-align: center;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.empty-icon {
margin-bottom: 8px;
color: var(--brand);
font-size: 38px;
opacity: 0.55;
}
.empty-state p {
margin: 0;
font-size: 13px;
}
.reset-button {
padding: 6px 12px;
margin-top: 14px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 6px;
}
.reset-button:hover {
background: var(--brand-l);
}
@media (max-width: 1400px) {
.template-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.template-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 680px) {
.template-grid {
grid-template-columns: 1fr;
}
}
</style>