增加文档管理功能

This commit is contained in:
2026-09-10 00:44:15 +08:00
parent 63bf972c17
commit 8a0334a020
18 changed files with 5958 additions and 4 deletions
+188
View File
@@ -0,0 +1,188 @@
import request from "@/utils/request";
// OA 文档管理(文档库 / 文档图谱)
// 后端统一返回 { code, msg, data },响应拦截器只剥离外层,调用方需自行取 res.data
// ---------------- 分类 ----------------
// 分类树(每个节点带 doc_count)
export function getDocCategories() {
return request({
url: "/backend/oa/document/categories",
method: "get"
});
}
export function createDocCategory(data) {
return request({
url: "/backend/oa/document/categories",
method: "post",
data
});
}
export function updateDocCategory(id, data) {
return request({
url: `/backend/oa/document/categories/${id}`,
method: "post",
data
});
}
export function deleteDocCategory(id) {
return request({
url: `/backend/oa/document/categories/${id}`,
method: "delete"
});
}
// ---------------- 文档 ----------------
export function getDocList(params) {
return request({
url: "/backend/oa/document/list",
method: "get",
params
});
}
export function getDocStats() {
return request({
url: "/backend/oa/document/stats",
method: "get"
});
}
// 详情(后端会累加查看次数)
export function getDocDetail(id) {
return request({
url: `/backend/oa/document/detail/${id}`,
method: "get"
});
}
export function createDoc(data) {
return request({
url: "/backend/oa/document/create",
method: "post",
data
});
}
export function updateDoc(id, data) {
return request({
url: `/backend/oa/document/update/${id}`,
method: "post",
data
});
}
export function deleteDoc(id) {
return request({
url: `/backend/oa/document/delete/${id}`,
method: "delete"
});
}
export function batchDeleteDocs(ids) {
return request({
url: "/backend/oa/document/delete-batch",
method: "post",
data: { ids }
});
}
export function moveDocs(ids, categoryId) {
return request({
url: "/backend/oa/document/move",
method: "post",
data: { ids, category_id: categoryId }
});
}
// 收藏 / 取消收藏
export function toggleDocStar(id) {
return request({
url: `/backend/oa/document/star/${id}`,
method: "post"
});
}
// 记录下载次数并取回下载地址
export function recordDocDownload(id) {
return request({
url: `/backend/oa/document/download/${id}`,
method: "post"
});
}
// ---------------- 文档关联 ----------------
export function getDocLinks(params) {
return request({
url: "/backend/oa/document/links",
method: "get",
params
});
}
export function createDocLink(data) {
return request({
url: "/backend/oa/document/links",
method: "post",
data
});
}
export function deleteDocLink(id) {
return request({
url: `/backend/oa/document/links/${id}`,
method: "delete"
});
}
// ---------------- 文档私密共享 ----------------
// 租户用户列表(私密文档共享选人)
export function getDocMembers(params) {
return request({
url: "/backend/oa/document/members",
method: "get",
params
});
}
// 读取文档共享清单
export function getDocShares(id) {
return request({
url: `/backend/oa/document/shares/${id}`,
method: "get"
});
}
// 全量设置文档共享(shares: [{ share_type, target_id }])
export function setDocShares(id, shares) {
return request({
url: `/backend/oa/document/shares/${id}`,
method: "post",
data: { shares }
});
}
// 删除单条共享
export function removeDocShare(sid) {
return request({
url: `/backend/oa/document/share/${sid}`,
method: "delete"
});
}
// ---------------- 文档图谱 ----------------
export function getDocGraph(params) {
return request({
url: "/backend/oa/document/graph",
method: "get",
params
});
}
@@ -0,0 +1,666 @@
<template>
<div class="doc-graph-page">
<div class="page-header">
<div>
<h2>文档图谱</h2>
<p>以知识图谱呈现文档之间的引用、相关与归属关系,点击节点查看文档详情</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="loadGraph">刷新</el-button>
<el-button type="primary" :icon="Aim" @click="fitView">重新布局</el-button>
</div>
</div>
<div class="graph-layout">
<!-- 左侧:图谱 -->
<div class="graph-card">
<div class="filter-bar">
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索文档"
style="width: 200px"
@keyup.enter="loadGraph"
/>
<el-tree-select
v-model="filters.category_id"
:data="categories"
:props="{ label: 'name', children: 'children' }"
node-key="id"
check-strictly
clearable
placeholder="全部分类"
style="width: 180px"
/>
<el-select
v-model="filters.relation"
clearable
placeholder="全部关系"
style="width: 130px"
>
<el-option
v-for="r in DOC_RELATION_OPTIONS"
:key="r.value"
:label="r.label"
:value="r.value"
/>
</el-select>
<el-select v-model="filters.limit" style="width: 130px">
<el-option label="最多 100 个" :value="100" />
<el-option label="最多 300 个" :value="300" />
<el-option label="最多 600 个" :value="600" />
</el-select>
<el-checkbox v-model="filters.includeCategory">显示分类中心</el-checkbox>
<el-checkbox v-model="filters.onlyLinked">仅看有关系的文档</el-checkbox>
<el-button type="primary" :icon="Search" @click="loadGraph">生成图谱</el-button>
<el-button :icon="RefreshLeft" @click="resetFilters">重置</el-button>
</div>
<div v-loading="loading" class="chart-wrap">
<div ref="chartRef" class="chart" />
<el-empty
v-if="!loading && !nodes.length"
description="暂无可展示的文档,请先到文档库上传文档"
class="chart-empty"
/>
</div>
<div class="legend">
<span class="legend-item">
<i class="dot" style="background: #409eff" />文档
</span>
<span class="legend-item">
<i class="dot" style="background: #e6a23c" />分类
</span>
<span class="legend-item">
<i class="line solid" />关联关系
</span>
<span class="legend-item">
<i class="line dashed" />归属分类
</span>
<span class="legend-tip">滚轮缩放 · 拖拽平移 · 点击节点查看详情</span>
</div>
</div>
<!-- 右侧:统计 -->
<div class="side-card">
<div class="side-title">图谱概览</div>
<div class="side-stats">
<div class="side-stat">
<span class="label">文档节点</span>
<span class="value">{{ stats.doc_count || 0 }}</span>
</div>
<div class="side-stat">
<span class="label">分类节点</span>
<span class="value">{{ stats.category_count || 0 }}</span>
</div>
<div class="side-stat">
<span class="label">关联关系</span>
<span class="value primary">{{ stats.link_count || 0 }}</span>
</div>
<div class="side-stat">
<span class="label">孤立文档</span>
<span class="value warning">{{ stats.isolated_count || 0 }}</span>
</div>
<div class="side-stat">
<span class="label">平均关联度</span>
<span class="value">{{
Number(stats.avg_degree || 0).toFixed(2)
}}</span>
</div>
</div>
<el-divider content-position="left">关系分布</el-divider>
<div v-if="relationCounts.length" class="relation-list">
<div v-for="r in relationCounts" :key="r.relation" class="relation-item">
<span>{{ r.label }}</span>
<el-progress
:percentage="relationPercent(r.count)"
:stroke-width="10"
:show-text="false"
style="width: 120px"
/>
<span class="relation-count">{{ r.count }}</span>
</div>
</div>
<el-empty v-else description="暂无关联关系" :image-size="60" />
<el-divider content-position="left">核心文档 Top 8</el-divider>
<div v-if="topNodes.length" class="top-list">
<div
v-for="(n, i) in topNodes"
:key="n.id"
class="top-item"
@click="openNode(n)"
>
<span class="rank" :class="{ top: i < 3 }">{{ i + 1 }}</span>
<span class="top-name" :title="n.name">{{ n.name }}</span>
<span class="top-degree">{{ n.degree }} 关联</span>
</div>
</div>
<el-empty v-else description="暂无数据" :image-size="60" />
</div>
</div>
<DocDetailDrawer
v-model="detailVisible"
:doc-id="detailId"
:categories="categories"
@link-changed="loadGraph"
@star-changed="loadGraph"
/>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { Aim, Refresh, RefreshLeft, Search } from "@element-plus/icons-vue";
import * as echarts from "echarts";
import DocDetailDrawer from "../documentlibrary/components/docDetailDrawer.vue";
import { getDocCategories, getDocGraph } from "@/api/oaDocument";
import { DOC_RELATION_OPTIONS, docTypeColor, docTypeLabel } from "../utils";
const chartRef = ref(null);
let chart = null;
let resizeObserver = null;
let themeObserver = null;
const loading = ref(false);
const categories = ref([]);
const nodes = ref([]);
const links = ref([]);
const stats = ref({});
const relationCounts = ref([]);
const topNodes = ref([]);
const detailVisible = ref(false);
const detailId = ref(0);
const filters = reactive({
keyword: "",
category_id: null,
relation: "",
limit: 300,
includeCategory: true,
onlyLinked: false
});
function isDark() {
return document.documentElement.classList.contains("dark");
}
function buildOption() {
const dark = isDark();
const textColor = dark ? "#e5eaf3" : "#303133";
const subTextColor = dark ? "#a3a6ad" : "#909399";
const axisColor = dark ? "#3a3a3a" : "#e0e6ed";
const data = nodes.value.map(n => {
const isCategory = n.category === 1;
const size = isCategory
? 42
: Math.min(58, 22 + (n.degree || 0) * 5);
return {
id: n.id,
name: n.name,
category: n.category,
symbolSize: size,
value: n.value,
// 供 tooltip / 点击回查
docId: n.doc_id,
categoryId: n.category_id,
docType: n.doc_type,
status: n.status,
ext: n.ext,
tags: n.tags,
ownerName: n.owner_name,
viewCount: n.view_count,
degree: n.degree,
itemStyle: {
color: isCategory ? "#e6a23c" : docTypeColor(n.doc_type),
borderColor: dark ? "#1f1f1f" : "#fff",
borderWidth: 2,
shadowBlur: 10,
shadowColor: dark ? "rgba(0,0,0,0.5)" : "rgba(0,0,0,0.15)"
},
label: {
show: true,
position: "right",
fontSize: isCategory ? 13 : 12,
fontWeight: isCategory ? 600 : 400,
color: textColor
}
};
});
const edgeList = links.value.map(l => ({
source: l.source,
target: l.target,
value: l.label,
lineStyle: {
width: l.kind === "belong" ? 1 : 1.6,
type: l.kind === "belong" ? "dashed" : "solid",
opacity: l.kind === "belong" ? 0.45 : 0.8,
curveness: 0.12
}
}));
return {
backgroundColor: "transparent",
tooltip: {
trigger: "item",
backgroundColor: dark ? "#1f1f1f" : "#fff",
borderColor: axisColor,
textStyle: { color: textColor, fontSize: 12 },
formatter: params => {
if (params.dataType !== "node") {
return params.data?.value ? `关系:${params.data.value}` : "";
}
const d = params.data;
if (d.category === 1) {
return `<b>${d.name}</b><br/>分类中心 · ${d.degree || 0} 个关联`;
}
return [
`<b>${d.name}</b>`,
`类型:${docTypeLabel(d.docType)}`,
`标签:${d.tags || "-"}`,
`负责人:${d.ownerName || "-"}`,
`关联数:${d.degree || 0}`,
`查看:${d.viewCount || 0}`
].join("<br/>");
}
},
series: [
{
type: "graph",
layout: "force",
roam: true,
draggable: true,
data,
links: edgeList,
categories: [
{ name: "文档", itemStyle: { color: "#409eff" } },
{ name: "分类", itemStyle: { color: "#e6a23c" } }
],
force: {
repulsion: nodes.value.length > 200 ? 90 : 160,
edgeLength: [70, 150],
gravity: 0.08
},
emphasis: {
focus: "adjacency",
scale: 1.15,
lineStyle: { width: 3, opacity: 1 }
},
lineStyle: { color: "source", opacity: 0.8 },
label: { color: textColor },
selectedMode: "single"
}
],
textStyle: { color: subTextColor }
};
}
function renderChart() {
if (!chartRef.value) return;
if (!chart) {
chart = echarts.init(chartRef.value);
chart.on("click", handleChartClick);
}
chart.setOption(buildOption(), true);
}
function fitView() {
// 力导向图无内置"复位"动作,重新渲染即可回到初始布局与缩放
renderChart();
}
function handleChartClick(params) {
if (params.dataType !== "node") return;
openNode(params.data);
}
function openNode(node) {
if (Number(node.category) === 1) {
// 分类节点:按该分类过滤图谱
filters.category_id = node.categoryId ?? node.category_id ?? null;
loadGraph();
return;
}
const docId = node.docId ?? node.doc_id;
if (docId) {
detailId.value = docId;
detailVisible.value = true;
}
}
async function loadCategories() {
const res = await getDocCategories();
if (res?.code === 200) {
categories.value = res.data || [];
}
}
async function loadGraph() {
loading.value = true;
try {
const res = await getDocGraph({
keyword: filters.keyword || "",
category_id: filters.category_id || 0,
relation: filters.relation || "",
limit: filters.limit,
include_category: filters.includeCategory ? 1 : 0,
only_linked: filters.onlyLinked ? 1 : 0
});
if (res?.code === 200) {
const data = res.data || {};
nodes.value = data.nodes || [];
links.value = data.links || [];
stats.value = data.stats || {};
relationCounts.value = stats.value.relation_counts || [];
topNodes.value = stats.value.top_nodes || [];
await nextTick();
renderChart();
} else {
ElMessage.error(res?.msg || "加载图谱失败");
}
} finally {
loading.value = false;
}
}
function resetFilters() {
filters.keyword = "";
filters.category_id = null;
filters.relation = "";
filters.limit = 300;
filters.includeCategory = true;
filters.onlyLinked = false;
loadGraph();
}
const relationMax = computed(() =>
Math.max(1, ...relationCounts.value.map(r => r.count || 0))
);
function relationPercent(count) {
return Math.round(((count || 0) * 100) / relationMax.value);
}
function handleResize() {
chart?.resize();
}
onMounted(() => {
loadCategories();
loadGraph();
nextTick(() => {
if (!chartRef.value) return;
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(chartRef.value);
});
themeObserver = new MutationObserver(() => renderChart());
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"]
});
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
themeObserver?.disconnect();
chart?.dispose();
chart = null;
});
</script>
<style lang="less" scoped>
.doc-graph-page {
padding: 16px 20px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.page-header h2 {
margin: 0 0 4px;
font-size: 20px;
}
.page-header p {
margin: 0;
color: #909399;
font-size: 13px;
}
.header-actions {
display: flex;
gap: 10px;
}
.graph-layout {
display: flex;
gap: 14px;
align-items: flex-start;
}
.graph-card {
flex: 1;
min-width: 0;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 14px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.chart-wrap {
position: relative;
width: 100%;
height: 620px;
}
.chart {
width: 100%;
height: 100%;
}
.chart-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.legend {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-top: 8px;
font-size: 12px;
color: #606266;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 5px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.line {
width: 22px;
height: 0;
display: inline-block;
&.solid {
border-top: 2px solid #909399;
}
&.dashed {
border-top: 2px dashed #c0c4cc;
}
}
.legend-tip {
margin-left: auto;
color: #909399;
}
.side-card {
width: 300px;
flex: 0 0 300px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 14px;
}
.side-title {
font-weight: 600;
margin-bottom: 10px;
}
.side-stats {
display: flex;
flex-direction: column;
gap: 8px;
}
.side-stat {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
}
.side-stat .label {
color: #909399;
}
.side-stat .value {
font-weight: 600;
color: #303133;
&.primary {
color: #409eff;
}
&.warning {
color: #e6a23c;
}
}
.relation-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.relation-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.relation-count {
color: #909399;
}
.top-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.top-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 6px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
&:hover {
background: #f5f7fa;
}
}
.rank {
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
border-radius: 4px;
background: #f0f2f5;
color: #909399;
font-size: 12px;
&.top {
background: #409eff;
color: #fff;
}
}
.top-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-degree {
color: #909399;
font-size: 12px;
}
// ===== 暗色主题适配 =====
html.dark & {
.page-header p {
color: var(--el-text-color-secondary);
}
.graph-card,
.side-card {
background: var(--el-bg-color);
border-color: var(--el-border-color-lighter);
}
.legend,
.side-stat .label,
.relation-count,
.top-degree,
.legend-tip {
color: var(--el-text-color-secondary);
}
.side-stat .value {
color: var(--el-text-color-primary);
}
.top-item:hover {
background: var(--el-fill-color-light);
}
}
</style>
@@ -0,0 +1,563 @@
<template>
<el-drawer
v-model="visible"
:title="detail?.title || '文档详情'"
size="560px"
@closed="handleClosed"
>
<div v-loading="loading" class="doc-detail">
<template v-if="detail">
<div class="detail-tags">
<el-tag :color="docTypeColor(detail.doc_type)" effect="dark" size="small">
{{ docTypeLabel(detail.doc_type) }}
</el-tag>
<el-tag :type="docStatusTagType(detail.status)" size="small">
{{ docStatusLabel(detail.status) }}
</el-tag>
<el-tag v-if="detail.is_star === 1" type="warning" size="small" effect="plain">
已收藏
</el-tag>
<el-tag
v-if="detail.visibility === 1"
type="danger"
size="small"
effect="plain"
>
私密
</el-tag>
<el-tag type="info" size="small" effect="plain">
v{{ detail.version || 1 }}
</el-tag>
</div>
<el-descriptions :column="2" border size="small" class="detail-desc">
<el-descriptions-item label="所属分类">
{{ categoryName(detail.category_id) }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ detail.owner_name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="创建人">
{{ detail.creator_name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="文件大小">
{{ formatFileSize(detail.size) }}
</el-descriptions-item>
<el-descriptions-item label="查看 / 下载">
{{ detail.view_count || 0 }} / {{ detail.download_count || 0 }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ formatDateTime(detail.update_time) }}
</el-descriptions-item>
<el-descriptions-item label="原始文件名" :span="2">
{{ detail.file_name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="标签" :span="2">
<el-tag
v-for="t in parseTags(detail.tags)"
:key="t"
size="small"
effect="plain"
class="tag-item"
>{{ t }}</el-tag
>
<span v-if="!parseTags(detail.tags).length">-</span>
</el-descriptions-item>
<el-descriptions-item label="摘要" :span="2">
{{ detail.summary || "-" }}
</el-descriptions-item>
</el-descriptions>
<div class="detail-actions">
<el-button type="primary" :icon="View" @click="emit('preview', detail)"
>预览</el-button
>
<el-button :icon="Download" @click="emit('download', detail)"
>下载</el-button
>
<el-button :icon="Star" @click="handleStar">{{
detail.is_star === 1 ? "取消收藏" : "收藏"
}}</el-button>
<el-button :icon="Edit" @click="emit('edit', detail)">编辑</el-button>
</div>
<el-divider content-position="left">共享范围(私密文档)</el-divider>
<div v-if="detail.visibility === 1" class="share-panel">
<div class="share-list">
<el-tag
v-for="s in shares"
:key="s.id"
type="info"
effect="plain"
closable
@close="removeShare(s)"
>
{{ s.share_type === 1 ? "部门" : "用户" }}:{{
s.target_name || s.target_id
}}
</el-tag>
<span v-if="!shares.length" class="empty-tip">仅创建者本人可见</span>
</div>
<div class="share-add">
<el-select v-model="shareForm.type" style="width: 110px">
<el-option :value="0" label="用户" />
<el-option :value="1" label="部门" />
</el-select>
<el-select
v-if="shareForm.type === 0"
v-model="shareForm.target"
filterable
remote
reserve-keyword
:remote-method="searchMembers"
:loading="searchingMember"
placeholder="搜索用户"
style="flex: 1"
>
<el-option
v-for="m in memberOptions"
:key="m.uid"
:label="`${m.name || m.account}(${m.account})`"
:value="m.uid"
/>
</el-select>
<el-tree-select
v-else
v-model="shareForm.target"
:data="orgTree"
:props="{ label: 'label', value: 'value', children: 'children' }"
value-key="value"
check-strictly
clearable
placeholder="选择部门"
style="flex: 1"
/>
<el-button
type="primary"
:disabled="!shareForm.target"
@click="addShare"
>添加</el-button
>
</div>
</div>
<div v-else class="share-public-tip">
该文档为「租户公开」,本租户内所有成员均可访问。
</div>
<el-divider content-position="left">关联文档</el-divider>
<div class="link-add">
<el-select
v-model="linkForm.target_id"
filterable
remote
clearable
reserve-keyword
placeholder="搜索要关联的文档"
:remote-method="searchDocs"
:loading="searching"
style="flex: 1"
>
<el-option
v-for="d in docOptions"
:key="d.id"
:label="d.title"
:value="d.id"
:disabled="d.id === detail.id"
/>
</el-select>
<el-select v-model="linkForm.relation" style="width: 110px">
<el-option
v-for="r in DOC_RELATION_OPTIONS"
:key="r.value"
:label="r.label"
:value="r.value"
/>
</el-select>
<el-button type="primary" :icon="Connection" @click="handleAddLink"
>关联</el-button
>
</div>
<el-table :data="links" size="small" class="link-table">
<el-table-column label="关系" width="90">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.relation_cn }}</el-tag>
</template>
</el-table-column>
<el-table-column label="文档" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="link-title" @click="openPeer(row)">{{ peer(row).title }}</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="说明" min-width="100" show-overflow-tooltip />
<el-table-column label="操作" width="70" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="handleDeleteLink(row)"
>解除</el-button
>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无关联文档" :image-size="60" />
</template>
</el-table>
</template>
<el-empty v-else-if="!loading" description="未选择文档" />
</div>
</el-drawer>
</template>
<script setup>
import { ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Connection,
Download,
Edit,
Star,
View
} from "@element-plus/icons-vue";
import {
createDocLink,
deleteDocLink,
getDocDetail,
getDocLinks,
getDocList,
getDocMembers,
getDocShares,
removeDocShare,
setDocShares,
toggleDocStar
} from "@/api/oaDocument";
import { oaOrganizationApi } from "@/api/organization";
import {
DOC_RELATION_OPTIONS,
docStatusLabel,
docStatusTagType,
docTypeColor,
docTypeLabel,
findCategory,
formatDateTime,
formatFileSize,
parseTags
} from "../../utils";
const props = defineProps({
modelValue: { type: Boolean, default: false },
docId: { type: [Number, String], default: 0 },
categories: { type: Array, default: () => [] }
});
const emit = defineEmits([
"update:modelValue",
"preview",
"download",
"edit",
"star-changed",
"open-doc",
"link-changed"
]);
const visible = ref(props.modelValue);
const loading = ref(false);
const detail = ref(null);
const links = ref([]);
const searching = ref(false);
const docOptions = ref([]);
const linkForm = ref({ target_id: null, relation: "related" });
// 共享范围
const shares = ref([]);
const shareForm = ref({ type: 0, target: null });
const memberOptions = ref([]);
const searchingMember = ref(false);
const orgTree = ref([]);
// 组织机构树(部门选择用),平铺结构转树
const buildOrgTree = (list) => {
const nodes = (list || []).map((o) => ({
value: Number(o.id),
label: o.org_name || o.name || `部门${o.id}`,
parent_id: Number(o.parent_id || 0)
}));
const map = new Map();
nodes.forEach((n) => map.set(n.value, { ...n, children: [] }));
const tree = [];
map.forEach((node) => {
const parent = map.get(node.parent_id);
if (parent) parent.children.push(node);
else tree.push(node);
});
return tree;
};
const loadOrgTree = async () => {
try {
const res = await oaOrganizationApi.getOrganizationTree();
if (res?.code === 200) {
const list = res?.data?.list || res?.data || [];
orgTree.value = buildOrgTree(list);
}
} catch {
orgTree.value = [];
}
};
const loadShares = async (docId) => {
const res = await getDocShares(docId);
shares.value = res?.code === 200 ? res.data || [] : [];
};
async function searchMembers(keyword) {
searchingMember.value = true;
try {
const res = await getDocMembers({ keyword, limit: 50 });
memberOptions.value = res?.code === 200 ? res.data || [] : [];
} finally {
searchingMember.value = false;
}
}
async function addShare() {
if (!shareForm.value.target) return;
// 全量替换:保留现有共享 + 新增一条
const items = shares.value.map((s) => ({
share_type: s.share_type,
target_id: s.target_id
}));
items.push({ share_type: shareForm.value.type, target_id: shareForm.value.target });
const res = await setDocShares(detail.value.id, items);
if (res?.code === 200) {
shares.value = res.data || [];
shareForm.value.target = null;
ElMessage.success("已添加共享");
} else {
ElMessage.error(res?.msg || "操作失败");
}
}
async function removeShare(s) {
const res = await removeDocShare(s.id);
if (res?.code === 200) {
shares.value = shares.value.filter((x) => x.id !== s.id);
ElMessage.success("已移除共享");
} else {
ElMessage.error(res?.msg || "操作失败");
}
}
watch(
() => props.modelValue,
val => {
visible.value = val;
if (val && props.docId) {
loadDetail(props.docId);
}
}
);
watch(visible, val => emit("update:modelValue", val));
watch(
() => props.docId,
id => {
if (visible.value && id) {
loadDetail(id);
}
}
);
async function loadDetail(id) {
loading.value = true;
try {
const [detailRes, linkRes] = await Promise.all([
getDocDetail(id),
getDocLinks({ doc_id: id })
]);
if (detailRes?.code === 200) {
detail.value = detailRes.data;
} else {
ElMessage.error(detailRes?.msg || "加载文档失败");
}
links.value = linkRes?.code === 200 ? linkRes.data || [] : [];
loadShares(id);
} finally {
loading.value = false;
}
}
function categoryName(id) {
if (!id) return "未分类";
return findCategory(props.categories, id)?.name || "未分类";
}
function peer(row) {
if (!detail.value) return { id: 0, title: "-" };
if (row.source_id === detail.value.id) {
return { id: row.target_id, title: row.target_title || `文档#${row.target_id}` };
}
return { id: row.source_id, title: row.source_title || `文档#${row.source_id}` };
}
function openPeer(row) {
const p = peer(row);
if (p.id) emit("open-doc", p.id);
}
async function searchDocs(keyword) {
searching.value = true;
try {
const res = await getDocList({ keyword, pageSize: 20, page: 1 });
docOptions.value = res?.code === 200 ? res.data?.list || [] : [];
} finally {
searching.value = false;
}
}
async function handleAddLink() {
if (!linkForm.value.target_id) {
ElMessage.warning("请选择要关联的文档");
return;
}
const res = await createDocLink({
source_id: detail.value.id,
target_id: linkForm.value.target_id,
relation: linkForm.value.relation,
remark: ""
});
if (res?.code === 200) {
ElMessage.success("已建立关联");
linkForm.value.target_id = null;
await refreshLinks();
emit("link-changed");
} else {
ElMessage.error(res?.msg || "关联失败");
}
}
async function refreshLinks() {
if (!detail.value) return;
const res = await getDocLinks({ doc_id: detail.value.id });
links.value = res?.code === 200 ? res.data || [] : [];
}
async function handleDeleteLink(row) {
try {
await ElMessageBox.confirm("确定解除该关联关系吗?", "提示", {
type: "warning",
confirmButtonText: "解除",
cancelButtonText: "取消"
});
} catch {
return;
}
const res = await deleteDocLink(row.id);
if (res?.code === 200) {
ElMessage.success("已解除关联");
await refreshLinks();
emit("link-changed");
} else {
ElMessage.error(res?.msg || "操作失败");
}
}
async function handleStar() {
const res = await toggleDocStar(detail.value.id);
if (res?.code === 200) {
detail.value.is_star = res.data?.is_star;
ElMessage.success(res.data?.is_star === 1 ? "已收藏" : "已取消收藏");
emit("star-changed");
} else {
ElMessage.error(res?.msg || "操作失败");
}
}
function handleClosed() {
detail.value = null;
links.value = [];
docOptions.value = [];
linkForm.value = { target_id: null, relation: "related" };
}
defineExpose({ reload: () => detail.value && loadDetail(detail.value.id) });
</script>
<style lang="less" scoped>
.doc-detail {
padding: 0 4px;
}
.detail-tags {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.detail-desc {
margin-bottom: 14px;
}
.tag-item {
margin-right: 6px;
margin-bottom: 4px;
}
.detail-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.link-add {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.share-panel {
margin-bottom: 6px;
}
.share-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.share-list .empty-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.share-add {
display: flex;
gap: 8px;
}
.share-public-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
margin-bottom: 6px;
}
.link-table {
width: 100%;
}
.link-title {
color: var(--el-color-primary);
cursor: pointer;
}
:deep(.el-descriptions__label) {
width: 90px;
}
</style>
@@ -0,0 +1,162 @@
<template>
<el-dialog v-model="visible" title="编辑文档" width="560px" @closed="handleClosed">
<el-form :model="form" label-width="90px">
<el-form-item label="文档标题" required>
<el-input v-model="form.title" maxlength="120" show-word-limit />
</el-form-item>
<el-form-item label="所属分类">
<el-tree-select
v-model="form.category_id"
:data="categories"
:props="{ label: 'name', children: 'children' }"
node-key="id"
check-strictly
clearable
placeholder="未分类"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="form.tagList"
multiple
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option v-for="t in form.tagList" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio :value="0">草稿</el-radio>
<el-radio :value="1">已发布</el-radio>
<el-radio :value="2">已归档</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="可见性">
<el-radio-group v-model="form.visibility">
<el-radio :value="0">租户公开</el-radio>
<el-radio :value="1">私密</el-radio>
</el-radio-group>
<div class="el-upload__tip">
私密文档仅本人及被共享的用户/部门可见;设为私密后可在详情中设置共享范围。
</div>
</el-form-item>
<el-form-item label="版本号">
<el-input-number v-model="form.version" :min="1" :max="999" />
</el-form-item>
<el-form-item label="负责人">
<el-input v-model="form.owner_name" maxlength="50" />
</el-form-item>
<el-form-item label="摘要">
<el-input
v-model="form.summary"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit"
>保存</el-button
>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { updateDoc } from "@/api/oaDocument";
import { parseTags } from "../../utils";
const props = defineProps({
modelValue: { type: Boolean, default: false },
doc: { type: Object, default: null },
categories: { type: Array, default: () => [] }
});
const emit = defineEmits(["update:modelValue", "saved"]);
const visible = ref(false);
const submitting = ref(false);
const form = ref({
title: "",
category_id: 0,
tagList: [],
status: 0,
visibility: 0,
version: 1,
owner_name: "",
summary: ""
});
watch(
() => props.modelValue,
val => {
visible.value = val;
if (val && props.doc) {
form.value = {
title: props.doc.title || "",
category_id: props.doc.category_id || 0,
tagList: parseTags(props.doc.tags),
status: props.doc.status ?? 0,
visibility: props.doc.visibility ?? 0,
version: props.doc.version || 1,
owner_name: props.doc.owner_name || "",
summary: props.doc.summary || ""
};
}
}
);
watch(visible, val => emit("update:modelValue", val));
async function handleSubmit() {
if (!form.value.title?.trim()) {
ElMessage.warning("请输入文档标题");
return;
}
submitting.value = true;
try {
const res = await updateDoc(props.doc.id, {
title: form.value.title.trim(),
category_id: form.value.category_id || 0,
tags: form.value.tagList.join(","),
status: form.value.status,
visibility: form.value.visibility || 0,
version: form.value.version,
owner_name: form.value.owner_name,
summary: form.value.summary
});
if (res?.code === 200) {
ElMessage.success("保存成功");
visible.value = false;
emit("saved");
} else {
ElMessage.error(res?.msg || "保存失败");
}
} finally {
submitting.value = false;
}
}
function handleClosed() {
form.value = {
title: "",
category_id: 0,
tagList: [],
status: 0,
visibility: 0,
version: 1,
owner_name: "",
summary: ""
};
}
</script>
@@ -0,0 +1,285 @@
<template>
<el-dialog
v-model="visible"
title="上传文档"
width="620px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-form :model="form" label-width="90px">
<el-form-item label="文件" required>
<el-upload
ref="uploadRef"
drag
:auto-upload="false"
:limit="1"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleExceed"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.zip,.rar,.7z,.jpg,.jpeg,.png,.gif,.webp,.mp4,.mp3"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">
支持 PDF / Office / 图片 / 压缩包等,单个文件不超过 200MB
</div>
</template>
</el-upload>
<el-progress
v-if="uploading"
:percentage="uploadPercent"
:stroke-width="14"
striped
style="margin-top: 10px"
/>
</el-form-item>
<el-form-item label="文档标题">
<el-input
v-model="form.title"
maxlength="120"
show-word-limit
placeholder="留空则使用文件名"
/>
</el-form-item>
<el-form-item label="所属分类">
<el-tree-select
v-model="form.category_id"
:data="categoryTree"
:props="treeProps"
node-key="id"
check-strictly
clearable
placeholder="未分类"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="form.tagList"
multiple
filterable
allow-create
default-first-option
placeholder="输入后回车可新建标签"
style="width: 100%"
>
<el-option v-for="t in presetTags" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio :value="0">草稿</el-radio>
<el-radio :value="1">已发布</el-radio>
<el-radio :value="2">已归档</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="可见性">
<el-radio-group v-model="form.visibility">
<el-radio :value="0">租户公开</el-radio>
<el-radio :value="1">私密</el-radio>
</el-radio-group>
<div class="el-upload__tip">
私密文档仅本人及被共享的用户/部门可见;设为私密后可在详情中设置共享范围。
</div>
</el-form-item>
<el-form-item label="负责人">
<el-input
v-model="form.owner_name"
maxlength="50"
placeholder="选填,默认当前操作人"
/>
</el-form-item>
<el-form-item label="摘要">
<el-input
v-model="form.summary"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="选填,简要描述文档内容"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="uploading"
@click="handleSubmit"
>保存</el-button
>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
import { uploadFile } from "@/api/file";
import { createDoc } from "@/api/oaDocument";
const props = defineProps({
modelValue: { type: Boolean, default: false },
categories: { type: Array, default: () => [] },
defaultCategoryId: { type: Number, default: 0 },
currentUserName: { type: String, default: "" }
});
const emit = defineEmits(["update:modelValue", "saved"]);
const treeProps = { label: "name", children: "children" };
const presetTags = ["制度", "流程", "模板", "合同", "培训", "报表", "归档"];
const visible = ref(props.modelValue);
const uploadRef = ref(null);
const fileList = ref([]);
const rawFile = ref(null);
const uploading = ref(false);
const uploadPercent = ref(0);
const submitting = ref(false);
const form = ref({
title: "",
category_id: 0,
tagList: [],
status: 1,
visibility: 0,
owner_name: "",
summary: ""
});
const categoryTree = computed(() => props.categories || []);
watch(
() => props.modelValue,
val => {
visible.value = val;
if (val) {
form.value = {
title: "",
category_id: props.defaultCategoryId || 0,
tagList: [],
status: 1,
visibility: 0,
owner_name: props.currentUserName || "",
summary: ""
};
uploadPercent.value = 0;
}
}
);
watch(visible, val => emit("update:modelValue", val));
function handleFileChange(file) {
rawFile.value = file.raw || file;
fileList.value = [file];
if (!form.value.title) {
form.value.title = String(file.name || "").replace(/\.[^.]+$/, "");
}
}
function handleFileRemove() {
rawFile.value = null;
fileList.value = [];
}
function handleExceed() {
ElMessage.warning("单次只能上传 1 个文件");
}
function buildFormData() {
const fd = new FormData();
fd.append("file", rawFile.value);
fd.append("cate", "0");
fd.append("scope", "tenant");
return fd;
}
async function handleSubmit() {
if (!rawFile.value) {
ElMessage.warning("请选择要上传的文件");
return;
}
if (rawFile.value.size > 200 * 1024 * 1024) {
ElMessage.warning("单个文件不能超过 200MB");
return;
}
submitting.value = true;
uploading.value = true;
uploadPercent.value = 0;
try {
const upRes = await uploadFile(buildFormData(), {
onUploadProgress: e => {
if (!e?.total) return;
uploadPercent.value = Math.min(
99,
Math.round((e.loaded * 100) / e.total)
);
}
});
// 秒传命中时返回 code=201,同样携带 url/id/name
const upData = upRes?.data || {};
if (upRes?.code !== 200 && upRes?.code !== 201) {
ElMessage.error(upRes?.msg || "文件上传失败");
return;
}
uploadPercent.value = 100;
const name = upData.name || rawFile.value.name || "";
const ext = name.includes(".") ? name.split(".").pop() : "";
const res = await createDoc({
title: form.value.title?.trim() || name.replace(/\.[^.]+$/, ""),
category_id: form.value.category_id || 0,
file_id: upData.id || 0,
file_url: upData.url || "",
file_name: name,
ext,
size: rawFile.value.size || 0,
tags: form.value.tagList.join(","),
summary: form.value.summary || "",
status: form.value.status,
visibility: form.value.visibility || 0,
owner_name: form.value.owner_name || ""
});
if (res?.code === 200) {
ElMessage.success("上传成功");
visible.value = false;
emit("saved");
} else {
ElMessage.error(res?.msg || "保存失败");
}
} catch (err) {
ElMessage.error(err?.message || "上传失败");
} finally {
uploading.value = false;
submitting.value = false;
}
}
function handleClosed() {
rawFile.value = null;
fileList.value = [];
uploadPercent.value = 0;
uploadRef.value?.clearFiles?.();
}
</script>
<style lang="less" scoped>
:deep(.el-upload-dragger) {
padding: 18px 12px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
// OA 文档管理(文档库 / 文档图谱)公共工具
// doc_type: 0-其他 1-文档 2-表格 3-演示 4-PDF 5-图片 6-压缩包 7-视频 8-音频
// status : 0-草稿 1-已发布 2-已归档
// relation: reference-引用 related-相关 version-版本 belong-从属
export const DOC_TYPE_OPTIONS = [
{ value: 1, label: "文档", color: "#409eff" },
{ value: 2, label: "表格", color: "#67c23a" },
{ value: 3, label: "演示", color: "#e6a23c" },
{ value: 4, label: "PDF", color: "#f56c6c" },
{ value: 5, label: "图片", color: "#909399" },
{ value: 6, label: "压缩包", color: "#a06bd6" },
{ value: 7, label: "视频", color: "#00b8d9" },
{ value: 8, label: "音频", color: "#ff8c42" },
{ value: 0, label: "其他", color: "#8d8d8d" }
];
export const DOC_STATUS_OPTIONS = [
{ value: 0, label: "草稿", type: "warning" },
{ value: 1, label: "已发布", type: "success" },
{ value: 2, label: "已归档", type: "info" }
];
export const DOC_RELATION_OPTIONS = [
{ value: "reference", label: "引用" },
{ value: "related", label: "相关" },
{ value: "version", label: "版本" },
{ value: "belong", label: "从属" }
];
// 可见性:0-租户公开 1-私密
export const DOC_VISIBILITY_OPTIONS = [
{ value: 0, label: "租户公开" },
{ value: 1, label: "私密" }
];
export function docVisibilityLabel(visibility) {
return Number(visibility) === 1 ? "私密" : "公开";
}
const docTypeMap = new Map(DOC_TYPE_OPTIONS.map(i => [i.value, i]));
const docStatusMap = new Map(DOC_STATUS_OPTIONS.map(i => [i.value, i]));
const relationMap = new Map(DOC_RELATION_OPTIONS.map(i => [i.value, i.label]));
export function docTypeLabel(docType) {
return docTypeMap.get(Number(docType))?.label || "其他";
}
export function docTypeColor(docType) {
return docTypeMap.get(Number(docType))?.color || "#8d8d8d";
}
export function docStatusLabel(status) {
return docStatusMap.get(Number(status))?.label || "草稿";
}
export function docStatusTagType(status) {
return docStatusMap.get(Number(status))?.type || "warning";
}
export function relationLabel(relation) {
return relationMap.get(relation) || relation || "相关";
}
/**
* 拼接文件访问地址:云存储返回完整 http(s) 地址时直接使用,
* 本地存储返回相对路径时拼接接口基址。
*/
export function getFileUrl(url) {
if (!url) return "";
const normalized = String(url).trim().replace(/\\/g, "/");
if (/^(https?:\/\/|data:|blob:)/i.test(normalized)) {
return normalized;
}
const baseUrl = String(import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, "");
const path = normalized.startsWith("/") ? normalized : `/${normalized}`;
return `${baseUrl}${path}`;
}
/** 是否为可内联预览的图片 */
export function isImageDoc(row) {
return ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
String(row?.ext || "").toLowerCase()
);
}
/** 是否为可在浏览器直接打开的 PDF */
export function isPdfDoc(row) {
return String(row?.ext || "").toLowerCase() === "pdf";
}
export function formatFileSize(size) {
const bytes = Number(size || 0);
if (!bytes) return "-";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function formatDateTime(value) {
if (!value) return "-";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
const pad = n => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
/** 后端返回的 tags 为逗号分隔字符串,统一转为数组 */
export function parseTags(tags) {
if (Array.isArray(tags)) return tags.filter(Boolean);
return String(tags || "")
.split(/[,,;;]/)
.map(t => t.trim())
.filter(Boolean);
}
/**
* 扁平化分类树,用于 tree-select / 下拉选择。
* @param {Array} tree 后端返回的分类树
* @param {number} depth 起始层级
*/
export function flattenCategories(tree, depth = 0, out = []) {
(tree || []).forEach(node => {
out.push({ id: node.id, name: node.name, depth, doc_count: node.doc_count });
if (node.children?.length) {
flattenCategories(node.children, depth + 1, out);
}
});
return out;
}
/** 从分类树中查找节点 */
export function findCategory(tree, id) {
for (const node of tree || []) {
if (node.id === id) return node;
const hit = findCategory(node.children, id);
if (hit) return hit;
}
return null;
}