优化文档相关
This commit is contained in:
@@ -5,11 +5,12 @@ import request from "@/utils/request";
|
||||
|
||||
// ---------------- 分类 ----------------
|
||||
|
||||
// 分类树(每个节点带 doc_count)
|
||||
export function getDocCategories() {
|
||||
// 分类树(每个节点带 doc_count),全租户共用一套,不再区分个人/共享
|
||||
export function getDocCategories(params) {
|
||||
return request({
|
||||
url: "/backend/oa/document/categories",
|
||||
method: "get"
|
||||
method: "get",
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,10 +47,12 @@ export function getDocList(params) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getDocStats() {
|
||||
// 概览统计:可见性维度(不再区分个人/共享)
|
||||
export function getDocStats(params) {
|
||||
return request({
|
||||
url: "/backend/oa/document/stats",
|
||||
method: "get"
|
||||
method: "get",
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,6 +64,14 @@ export function getDocDetail(id) {
|
||||
});
|
||||
}
|
||||
|
||||
// 在线文档编辑源(不累加查看次数),用于打开在线编辑器回填内容
|
||||
export function getDocContent(id) {
|
||||
return request({
|
||||
url: `/backend/oa/document/content/${id}`,
|
||||
method: "get"
|
||||
});
|
||||
}
|
||||
|
||||
export function createDoc(data) {
|
||||
return request({
|
||||
url: "/backend/oa/document/create",
|
||||
|
||||
@@ -18,6 +18,11 @@ import { useAuthStore } from './stores/auth'
|
||||
// 导入全局组件
|
||||
import UmoEditor from '@/views/components/UmoEditor.vue';
|
||||
|
||||
// 兼容某些依赖(如 HTML→docx 转换库)对 Node 全局变量 global 的引用
|
||||
if (typeof window !== 'undefined' && !window.global) {
|
||||
window.global = window;
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
// 全局注册 UmoEditor 组件
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>文档图谱</h2>
|
||||
<p>以知识图谱呈现文档之间的引用、相关与归属关系,点击节点查看文档详情</p>
|
||||
<p>
|
||||
默认展示顶级往下 3 级分类脉络,点击带 ▸ 的分类节点可继续延展 3 级;滚轮缩放,按住空格 + 左键拖拽平移
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="RefreshLeft" @click="collapseAll">收起全部</el-button>
|
||||
<el-button :icon="Refresh" @click="loadGraph">刷新</el-button>
|
||||
<el-button type="primary" :icon="Aim" @click="fitView">重新布局</el-button>
|
||||
</div>
|
||||
@@ -25,12 +28,13 @@
|
||||
<el-tree-select
|
||||
v-model="filters.category_id"
|
||||
:data="categories"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
|
||||
node-key="id"
|
||||
check-strictly
|
||||
clearable
|
||||
default-expand-all
|
||||
placeholder="全部分类"
|
||||
style="width: 180px"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filters.relation"
|
||||
@@ -50,7 +54,12 @@
|
||||
<el-option label="最多 300 个" :value="300" />
|
||||
<el-option label="最多 600 个" :value="600" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.layout" style="width: 130px">
|
||||
<el-option label="力导向布局" value="force" />
|
||||
<el-option label="环形布局" value="circular" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="filters.includeCategory">显示分类中心</el-checkbox>
|
||||
<el-checkbox v-model="filters.showDocs">显示文档</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>
|
||||
@@ -59,8 +68,8 @@
|
||||
<div v-loading="loading" class="chart-wrap">
|
||||
<div ref="chartRef" class="chart" />
|
||||
<el-empty
|
||||
v-if="!loading && !nodes.length"
|
||||
description="暂无可展示的文档,请先到文档库上传文档"
|
||||
v-if="!loading && !displayNodes.length"
|
||||
description="暂无可展示的分类或文档,请先到文档库新建分类并上传文档"
|
||||
class="chart-empty"
|
||||
/>
|
||||
</div>
|
||||
@@ -70,7 +79,13 @@
|
||||
<i class="dot" style="background: #409eff" />文档
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<i class="dot" style="background: #e6a23c" />分类
|
||||
<i class="dot" style="background: #4e7cf6" />1级分类
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<i class="dot" style="background: #2bb673" />2级分类
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<i class="dot" style="background: #e6a23c" />3级分类
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<i class="line solid" />关联关系
|
||||
@@ -78,7 +93,12 @@
|
||||
<span class="legend-item">
|
||||
<i class="line dashed" />归属分类
|
||||
</span>
|
||||
<span class="legend-tip">滚轮缩放 · 拖拽平移 · 点击节点查看详情</span>
|
||||
<span class="legend-item">
|
||||
<i class="line dotted" />分类层级
|
||||
</span>
|
||||
<span class="legend-tip"
|
||||
>滚轮缩放 · 空格+左键拖拽平移 · 点击 ▸ 分类展开下级(更深层级循环使用这 3 色)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +173,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch
|
||||
} from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Aim, Refresh, RefreshLeft, Search } from "@element-plus/icons-vue";
|
||||
import * as echarts from "echarts";
|
||||
@@ -167,9 +195,11 @@ let resizeObserver = null;
|
||||
let themeObserver = null;
|
||||
|
||||
const loading = ref(false);
|
||||
const categories = ref([]);
|
||||
const nodes = ref([]);
|
||||
const links = ref([]);
|
||||
const categories = ref([]); // 分组后的分类(用于筛选下拉)
|
||||
const sharedTree = ref([]); // 共享分类树(用于分层展开)
|
||||
const personalTree = ref([]); // 私密分类树(用于分层展开)
|
||||
const nodes = ref([]); // 原始节点
|
||||
const links = ref([]); // 原始连线
|
||||
const stats = ref({});
|
||||
const relationCounts = ref([]);
|
||||
const topNodes = ref([]);
|
||||
@@ -177,15 +207,125 @@ const topNodes = ref([]);
|
||||
const detailVisible = ref(false);
|
||||
const detailId = ref(0);
|
||||
|
||||
// 分层展开:默认展示顶级往下 3 级,点击分支节点后在该分支继续延展 3 级
|
||||
const INITIAL_DEPTH = 3;
|
||||
const expandedIds = ref(new Set());
|
||||
// 按住空格 + 鼠标左键拖拽平移
|
||||
const spaceHeld = ref(false);
|
||||
|
||||
const filters = reactive({
|
||||
keyword: "",
|
||||
category_id: null,
|
||||
relation: "",
|
||||
limit: 300,
|
||||
layout: "force",
|
||||
includeCategory: true,
|
||||
onlyLinked: false
|
||||
onlyLinked: false,
|
||||
showDocs: true
|
||||
});
|
||||
|
||||
// ---------------- 分类层级索引 & 分层可见集合 ----------------
|
||||
const catIndex = computed(() => {
|
||||
const byId = new Map();
|
||||
const childrenOf = new Map();
|
||||
const parentOf = new Map();
|
||||
const depthOf = new Map(); // 分类层级:顶级=1
|
||||
const roots = [];
|
||||
const walk = (list, parentId, depth) => {
|
||||
(list || []).forEach(n => {
|
||||
byId.set(n.id, n);
|
||||
depthOf.set(n.id, depth);
|
||||
if (parentId) parentOf.set(n.id, parentId);
|
||||
else roots.push(n.id);
|
||||
const kids = n.children || [];
|
||||
if (kids.length) {
|
||||
childrenOf.set(
|
||||
n.id,
|
||||
kids.map(c => c.id)
|
||||
);
|
||||
walk(kids, n.id, depth + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(sharedTree.value, 0, 1);
|
||||
walk(personalTree.value, 0, 1);
|
||||
return { byId, childrenOf, parentOf, depthOf, roots };
|
||||
});
|
||||
|
||||
// 分类层级配色:只保留 3 种颜色(对应同时展示的 3 级),更深层级按 1/2/3 级循环
|
||||
const CATEGORY_LEVEL_COLORS = ["#4E7CF6", "#2BB673", "#E6A23C"];
|
||||
function categoryColor(depth) {
|
||||
const d = Math.max(1, Number(depth) || 1);
|
||||
return CATEGORY_LEVEL_COLORS[(d - 1) % CATEGORY_LEVEL_COLORS.length];
|
||||
}
|
||||
|
||||
const visibleCatIds = computed(() => {
|
||||
const { childrenOf, roots } = catIndex.value;
|
||||
const expanded = expandedIds.value;
|
||||
const visible = new Set();
|
||||
const walk = (id, budget) => {
|
||||
if (budget <= 0) return;
|
||||
visible.add(id);
|
||||
for (const kid of childrenOf.get(id) || []) {
|
||||
// 已展开的分支:从该节点重新给 INITIAL_DEPTH 级预算;否则继续消耗剩余预算
|
||||
walk(kid, expanded.has(id) ? INITIAL_DEPTH : budget - 1);
|
||||
}
|
||||
};
|
||||
roots.forEach(r => walk(r, INITIAL_DEPTH));
|
||||
return visible;
|
||||
});
|
||||
|
||||
// 每个可见分类隐藏的直接子级数量(用于 +N 提示可展开)
|
||||
const hiddenChildCounts = computed(() => {
|
||||
const { childrenOf } = catIndex.value;
|
||||
const visible = visibleCatIds.value;
|
||||
const map = new Map();
|
||||
visible.forEach(id => {
|
||||
const hidden = (childrenOf.get(id) || []).filter(k => !visible.has(k)).length;
|
||||
if (hidden > 0) map.set(id, hidden);
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
// 依据展开状态计算实际展示的节点与连线
|
||||
const displayData = computed(() => {
|
||||
const visible = visibleCatIds.value;
|
||||
|
||||
const docCat = new Map(); // 文档节点 → 所属分类 ID
|
||||
links.value.forEach(l => {
|
||||
if (
|
||||
l.kind === "belong" &&
|
||||
String(l.source).startsWith("d") &&
|
||||
String(l.target).startsWith("c")
|
||||
) {
|
||||
docCat.set(l.source, Number(String(l.target).slice(1)));
|
||||
}
|
||||
});
|
||||
|
||||
const catNodes = [];
|
||||
const docNodes = [];
|
||||
nodes.value.forEach(n => {
|
||||
if (Number(n.category) >= 1) {
|
||||
if (visible.has(Number(n.category_id))) catNodes.push(n);
|
||||
} else if (filters.showDocs) {
|
||||
const cid = docCat.get(n.id);
|
||||
if (cid !== undefined && visible.has(cid)) docNodes.push(n);
|
||||
}
|
||||
});
|
||||
|
||||
const shown = new Set([...catNodes, ...docNodes].map(n => n.id));
|
||||
const shownLinks = links.value.filter(
|
||||
l => shown.has(l.source) && shown.has(l.target)
|
||||
);
|
||||
return { catNodes, docNodes, shownLinks };
|
||||
});
|
||||
|
||||
const displayNodes = computed(() => [
|
||||
...displayData.value.catNodes,
|
||||
...displayData.value.docNodes
|
||||
]);
|
||||
const displayLinks = computed(() => displayData.value.shownLinks);
|
||||
|
||||
function isDark() {
|
||||
return document.documentElement.classList.contains("dark");
|
||||
}
|
||||
@@ -196,20 +336,52 @@ function buildOption() {
|
||||
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);
|
||||
const sourceNodes = displayNodes.value;
|
||||
const sourceLinks = displayLinks.value;
|
||||
const total = sourceNodes.length;
|
||||
const dense = total > 160; // 节点较多时缩小节点,减少重叠
|
||||
const childrenOf = catIndex.value.childrenOf;
|
||||
const data = sourceNodes.map(n => {
|
||||
const isCategory = Number(n.category) >= 1;
|
||||
const isPrivateCat = Number(n.category) === 2;
|
||||
const cid = Number(n.category_id);
|
||||
const hasChildren =
|
||||
isCategory && (childrenOf.get(cid) || []).length > 0;
|
||||
const isExpanded = isCategory && expandedIds.value.has(cid);
|
||||
const hidden = isCategory ? hiddenChildCounts.value.get(cid) || 0 : 0;
|
||||
|
||||
const base = dense ? 16 : 22;
|
||||
const cap = dense ? 40 : 58;
|
||||
let size = isCategory
|
||||
? dense
|
||||
? 34
|
||||
: 42
|
||||
: Math.min(cap, base + (n.degree || 0) * 5);
|
||||
if (isCategory && hasChildren) size += 6; // 有子级的分类节点更大,便于点击展开
|
||||
|
||||
let displayName = n.name;
|
||||
if (isCategory) {
|
||||
const mark = hasChildren ? (isExpanded ? " ▾" : " ▸") : "";
|
||||
const plus = hidden > 0 ? ` +${hidden}` : "";
|
||||
const priv = isPrivateCat ? "私·" : "";
|
||||
displayName = `${priv}${n.name}${mark}${plus}`;
|
||||
}
|
||||
|
||||
const depth = isCategory ? catIndex.value.depthOf.get(cid) || 1 : 0;
|
||||
const catColor = categoryColor(depth);
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
displayName,
|
||||
category: n.category,
|
||||
symbolSize: size,
|
||||
value: n.value,
|
||||
// 供 tooltip / 点击回查
|
||||
docId: n.doc_id,
|
||||
categoryId: n.category_id,
|
||||
hasChildren,
|
||||
depth,
|
||||
isPrivateCat,
|
||||
docType: n.doc_type,
|
||||
status: n.status,
|
||||
ext: n.ext,
|
||||
@@ -218,15 +390,16 @@ function buildOption() {
|
||||
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)"
|
||||
// 分类按层级配色(1/2/3 级各不相同),文档按类型配色;纯色无边框
|
||||
color: isCategory ? catColor : docTypeColor(n.doc_type),
|
||||
borderWidth: 0,
|
||||
shadowBlur: 8,
|
||||
shadowColor: dark ? "rgba(0,0,0,0.4)" : "rgba(0,0,0,0.12)"
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: "right",
|
||||
formatter: () => displayName,
|
||||
fontSize: isCategory ? 13 : 12,
|
||||
fontWeight: isCategory ? 600 : 400,
|
||||
color: textColor
|
||||
@@ -234,14 +407,15 @@ function buildOption() {
|
||||
};
|
||||
});
|
||||
|
||||
const edgeList = links.value.map(l => ({
|
||||
const edgeList = sourceLinks.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,
|
||||
width: l.kind === "doc" ? 1.6 : 1,
|
||||
type:
|
||||
l.kind === "belong" ? "dashed" : l.kind === "cate" ? "dotted" : "solid",
|
||||
opacity: l.kind === "doc" ? 0.8 : 0.45,
|
||||
curveness: 0.12
|
||||
}
|
||||
}));
|
||||
@@ -258,8 +432,14 @@ function buildOption() {
|
||||
return params.data?.value ? `关系:${params.data.value}` : "";
|
||||
}
|
||||
const d = params.data;
|
||||
if (d.category === 1) {
|
||||
return `<b>${d.name}</b><br/>分类中心 · ${d.degree || 0} 个关联`;
|
||||
if (Number(d.category) >= 1) {
|
||||
const kind = Number(d.category) === 2 ? "私密分类" : "共享分类";
|
||||
const tip = d.hasChildren
|
||||
? `<br/>点击${d.isExpanded ? "收起" : "展开下级分类"}`
|
||||
: "";
|
||||
return `<b>${d.name}</b><br/>${d.depth || 1} 级分类 · ${kind} · ${
|
||||
d.degree || 0
|
||||
} 个关联${tip}`;
|
||||
}
|
||||
return [
|
||||
`<b>${d.name}</b>`,
|
||||
@@ -274,20 +454,32 @@ function buildOption() {
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
layout: "force",
|
||||
layout: filters.layout,
|
||||
// roam: 滚轮缩放 + 拖拽平移;按住空格时禁用节点拖拽,让左键拖拽整体平移
|
||||
roam: true,
|
||||
draggable: true,
|
||||
scaleLimit: { min: 0.15, max: 6 },
|
||||
draggable: !spaceHeld.value,
|
||||
data,
|
||||
links: edgeList,
|
||||
categories: [
|
||||
{ name: "文档", itemStyle: { color: "#409eff" } },
|
||||
{ name: "分类", itemStyle: { color: "#e6a23c" } }
|
||||
{ name: "共享分类", itemStyle: { color: "#e6a23c" } },
|
||||
{ name: "私密分类", itemStyle: { color: "#b37feb" } }
|
||||
],
|
||||
force: {
|
||||
repulsion: nodes.value.length > 200 ? 90 : 160,
|
||||
edgeLength: [70, 150],
|
||||
gravity: 0.08
|
||||
},
|
||||
// 标签重叠时自动隐藏,避免文字糊在一起
|
||||
labelLayout: { hideOverlap: true },
|
||||
force:
|
||||
filters.layout === "force"
|
||||
? {
|
||||
// 斥力随节点数放大,节点越多铺得越开,避免挤成一团
|
||||
repulsion: Math.max(400, total * 14),
|
||||
edgeLength: dense ? [140, 280] : [100, 200],
|
||||
gravity: 0.06,
|
||||
layoutAnimation: true
|
||||
}
|
||||
: undefined,
|
||||
circular:
|
||||
filters.layout === "circular" ? { rotateLabel: true } : undefined,
|
||||
emphasis: {
|
||||
focus: "adjacency",
|
||||
scale: 1.15,
|
||||
@@ -302,32 +494,45 @@ function buildOption() {
|
||||
};
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
function renderChart(reset = true) {
|
||||
if (!chartRef.value) return;
|
||||
if (!chart) {
|
||||
chart = echarts.init(chartRef.value);
|
||||
chart.on("click", handleChartClick);
|
||||
}
|
||||
chart.setOption(buildOption(), true);
|
||||
// 展开/收起时使用合并更新(reset=false),保留当前缩放与平移
|
||||
chart.setOption(buildOption(), { notMerge: reset, lazyUpdate: false });
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
// 力导向图无内置"复位"动作,重新渲染即可回到初始布局与缩放
|
||||
renderChart();
|
||||
// 重新渲染回到初始布局与缩放
|
||||
renderChart(true);
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
expandedIds.value = new Set();
|
||||
renderChart(true);
|
||||
}
|
||||
|
||||
function handleChartClick(params) {
|
||||
if (params.dataType !== "node") return;
|
||||
openNode(params.data);
|
||||
const d = params.data;
|
||||
if (Number(d.category) >= 1) {
|
||||
// 分类节点:有子级则展开 / 收起,展开后在该分支继续延展 3 级
|
||||
const cid = Number(d.categoryId);
|
||||
const kids = catIndex.value.childrenOf.get(cid) || [];
|
||||
if (!kids.length) return;
|
||||
const next = new Set(expandedIds.value);
|
||||
if (next.has(cid)) next.delete(cid);
|
||||
else next.add(cid);
|
||||
expandedIds.value = next;
|
||||
renderChart(false);
|
||||
return;
|
||||
}
|
||||
openNode(d);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -335,13 +540,71 @@ function openNode(node) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
const res = await getDocCategories();
|
||||
if (res?.code === 200) {
|
||||
categories.value = res.data || [];
|
||||
// ---------------- 空格 + 鼠标左键拖拽平移 ----------------
|
||||
function isTypingTarget(el) {
|
||||
return (
|
||||
!!el &&
|
||||
(el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.isContentEditable === true)
|
||||
);
|
||||
}
|
||||
|
||||
function applyPanMode(on) {
|
||||
if (chartRef.value) chartRef.value.style.cursor = on ? "grab" : "";
|
||||
try {
|
||||
// 空格按下时禁用节点拖拽,使左键拖拽变为整体平移
|
||||
chart?.setOption({ series: [{ draggable: !on }] });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function onSpaceDown(e) {
|
||||
if (e.code !== "Space") return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
e.preventDefault();
|
||||
if (spaceHeld.value) return;
|
||||
spaceHeld.value = true;
|
||||
applyPanMode(true);
|
||||
}
|
||||
|
||||
function onSpaceUp(e) {
|
||||
if (e.code !== "Space") return;
|
||||
spaceHeld.value = false;
|
||||
applyPanMode(false);
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
// 图谱分类筛选需同时包含共享分类与当前用户的私密分类,按分组展示
|
||||
const [sharedRes, personalRes] = await Promise.all([
|
||||
getDocCategories({ scope: "shared" }),
|
||||
getDocCategories({ scope: "personal" })
|
||||
]);
|
||||
const shared = sharedRes?.code === 200 ? sharedRes.data || [] : [];
|
||||
const personal = personalRes?.code === 200 ? personalRes.data || [] : [];
|
||||
sharedTree.value = shared;
|
||||
personalTree.value = personal;
|
||||
const groups = [];
|
||||
if (shared.length) {
|
||||
groups.push({
|
||||
id: "grp-shared",
|
||||
name: "共享分类",
|
||||
disabled: true,
|
||||
children: shared
|
||||
});
|
||||
}
|
||||
if (personal.length) {
|
||||
groups.push({
|
||||
id: "grp-personal",
|
||||
name: "私密分类",
|
||||
disabled: true,
|
||||
children: personal
|
||||
});
|
||||
}
|
||||
categories.value = groups;
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -375,8 +638,11 @@ function resetFilters() {
|
||||
filters.category_id = null;
|
||||
filters.relation = "";
|
||||
filters.limit = 300;
|
||||
filters.layout = "force";
|
||||
filters.includeCategory = true;
|
||||
filters.onlyLinked = false;
|
||||
filters.showDocs = true;
|
||||
expandedIds.value = new Set();
|
||||
loadGraph();
|
||||
}
|
||||
|
||||
@@ -391,22 +657,34 @@ function handleResize() {
|
||||
chart?.resize();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories();
|
||||
loadGraph();
|
||||
// "显示文档"开关即时生效(保留当前缩放/平移)
|
||||
watch(
|
||||
() => filters.showDocs,
|
||||
() => renderChart(false)
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// 先加载分类树(分层展示依赖它),再加载图谱数据
|
||||
await loadCategories();
|
||||
await loadGraph();
|
||||
nextTick(() => {
|
||||
if (!chartRef.value) return;
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(chartRef.value);
|
||||
});
|
||||
themeObserver = new MutationObserver(() => renderChart());
|
||||
themeObserver = new MutationObserver(() => renderChart(false));
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"]
|
||||
});
|
||||
// 空格 + 左键拖拽平移
|
||||
window.addEventListener("keydown", onSpaceDown);
|
||||
window.addEventListener("keyup", onSpaceUp);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onSpaceDown);
|
||||
window.removeEventListener("keyup", onSpaceUp);
|
||||
resizeObserver?.disconnect();
|
||||
themeObserver?.disconnect();
|
||||
chart?.dispose();
|
||||
@@ -519,6 +797,10 @@ onBeforeUnmount(() => {
|
||||
&.dashed {
|
||||
border-top: 2px dashed #c0c4cc;
|
||||
}
|
||||
|
||||
&.dotted {
|
||||
border-top: 2px dotted #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-tip {
|
||||
|
||||
@@ -391,8 +391,8 @@ async function loadDetail(id) {
|
||||
}
|
||||
|
||||
function categoryName(id) {
|
||||
if (!id) return "未分类";
|
||||
return findCategory(props.categories, id)?.name || "未分类";
|
||||
if (!id) return "-";
|
||||
return findCategory(props.categories, id)?.name || "-";
|
||||
}
|
||||
|
||||
function peer(row) {
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑文档' : '新建文档'"
|
||||
class="doc-editor-dialog"
|
||||
:class="{ 'is-fullscreen': fullscreen }"
|
||||
width="78%"
|
||||
top="5vh"
|
||||
:fullscreen="fullscreen"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<template #header="{ titleId, titleClass }">
|
||||
<div class="doc-editor-header">
|
||||
<span :id="titleId" :class="titleClass">
|
||||
{{ isEdit ? "编辑文档" : "新建文档" }}
|
||||
</span>
|
||||
<el-button
|
||||
link
|
||||
:icon="FullScreen"
|
||||
class="doc-editor-fs"
|
||||
@click="fullscreen = !fullscreen"
|
||||
>
|
||||
{{ fullscreen ? "退出全屏" : "全屏" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="72px" class="doc-editor-form">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
maxlength="120"
|
||||
show-word-limit
|
||||
placeholder="请输入文档标题"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="分类" required>
|
||||
<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-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option
|
||||
v-for="s in DOC_STATUS_OPTIONS"
|
||||
:key="s.value"
|
||||
:label="s.label"
|
||||
:value="s.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<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-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="form.owner_name" placeholder="负责人" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="可见性">
|
||||
<el-radio-group v-model="form.visibility" :disabled="true">
|
||||
<el-radio :value="0">公开</el-radio>
|
||||
<el-radio :value="1">私密</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="摘要">
|
||||
<el-input
|
||||
v-model="form.summary"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="正文">
|
||||
<div
|
||||
v-loading="contentLoading"
|
||||
class="doc-editor-box"
|
||||
:style="editorBoxStyle"
|
||||
>
|
||||
<UmoEditor v-if="ready" v-model="form.content" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="doc-editor-footer">
|
||||
<span class="doc-editor-hint">
|
||||
{{ props.scope === "personal" ? "保存为私密文档(绑定个人)" : "保存为共享文档(租户内可见)" }}
|
||||
· 以 Word(docx)格式保存
|
||||
</span>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? "保存" : "创建" }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { FullScreen } from "@element-plus/icons-vue";
|
||||
import { uploadFile } from "@/api/file";
|
||||
import { createDoc, getDocContent, updateDoc } from "@/api/oaDocument";
|
||||
import { DOC_STATUS_OPTIONS, getFileUrl, parseTags } from "../../utils";
|
||||
|
||||
const DOCX_MIME =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
scope: { type: String, default: "shared" },
|
||||
categories: { type: Array, default: () => [] },
|
||||
defaultCategoryId: { type: Number, default: 0 },
|
||||
currentUserName: { type: String, default: "" },
|
||||
// 传入则为编辑已有在线文档,否则为新建
|
||||
doc: { type: Object, default: null }
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "saved"]);
|
||||
|
||||
const presetTags = ["制度", "流程", "模板", "合同", "培训", "报表", "归档"];
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
const fullscreen = ref(false);
|
||||
const ready = ref(false);
|
||||
const contentLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const isEdit = computed(() => !!props.doc?.id);
|
||||
const lockedVisibility = computed(() => (props.scope === "personal" ? 1 : 0));
|
||||
const editorBoxStyle = computed(() =>
|
||||
fullscreen.value ? { height: "calc(100vh - 210px)" } : {}
|
||||
);
|
||||
|
||||
const form = reactive({
|
||||
title: "",
|
||||
category_id: 0,
|
||||
tagList: [],
|
||||
status: 1,
|
||||
visibility: 0,
|
||||
owner_name: "",
|
||||
summary: "",
|
||||
content: ""
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
visible.value = val;
|
||||
if (val) open();
|
||||
}
|
||||
);
|
||||
watch(visible, val => emit("update:modelValue", val));
|
||||
|
||||
async function open() {
|
||||
fullscreen.value = false;
|
||||
ready.value = false;
|
||||
contentLoading.value = false;
|
||||
Object.assign(form, {
|
||||
title: props.doc?.title || "",
|
||||
category_id: props.doc?.category_id || props.defaultCategoryId || 0,
|
||||
tagList: parseTags(props.doc?.tags),
|
||||
status: props.doc?.status ?? 1,
|
||||
visibility: lockedVisibility.value,
|
||||
owner_name: props.doc?.owner_name || props.currentUserName || "",
|
||||
summary: props.doc?.summary || "",
|
||||
content: ""
|
||||
});
|
||||
|
||||
if (isEdit.value) {
|
||||
contentLoading.value = true;
|
||||
try {
|
||||
let html = "";
|
||||
const res = await getDocContent(props.doc.id);
|
||||
if (res?.code === 200) html = res.data?.content || "";
|
||||
// 兼容早期以 html 文件保存的在线文档
|
||||
if (
|
||||
!html &&
|
||||
props.doc?.file_url &&
|
||||
["html", "htm"].includes(String(props.doc.ext || "").toLowerCase())
|
||||
) {
|
||||
html = await fetchText(getFileUrl(props.doc.file_url));
|
||||
}
|
||||
form.content = absolutizeUrls(extractBody(html));
|
||||
} catch (e) {
|
||||
ElMessage.error("文档内容加载失败,可重新编辑后覆盖保存");
|
||||
} finally {
|
||||
contentLoading.value = false;
|
||||
}
|
||||
}
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const title = String(form.title || "").trim();
|
||||
if (!title) {
|
||||
ElMessage.warning("请输入文档标题");
|
||||
return;
|
||||
}
|
||||
if (!form.category_id) {
|
||||
ElMessage.warning("请选择所属分类");
|
||||
return;
|
||||
}
|
||||
if (!hasContent(form.content)) {
|
||||
ElMessage.warning("请输入文档正文");
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 图片地址转为绝对地址,便于导出 docx 时抓取
|
||||
const html = absolutizeUrls(form.content);
|
||||
|
||||
let docxBlob;
|
||||
try {
|
||||
const { default: HTMLtoDOCX } = await import("@turbodocx/html-to-docx");
|
||||
const out = await HTMLtoDOCX(html, null, {
|
||||
font: "宋体",
|
||||
fontSize: 24,
|
||||
title,
|
||||
creator: props.currentUserName || undefined,
|
||||
footer: false,
|
||||
pageNumber: false,
|
||||
table: { row: { cantSplit: true } },
|
||||
decodeUnicode: true
|
||||
});
|
||||
docxBlob = out instanceof Blob ? out : new Blob([out], { type: DOCX_MIME });
|
||||
} catch (e) {
|
||||
ElMessage.error("生成 Word 文档失败:" + (e?.message || ""));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = `${safeFileName(title)}.docx`;
|
||||
const file = new File([docxBlob], fileName, { type: DOCX_MIME });
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("cate", "0");
|
||||
const up = await uploadFile(fd);
|
||||
const upData = up?.data || {};
|
||||
if (up?.code !== 200 && up?.code !== 201) {
|
||||
ElMessage.error(up?.msg || "文档保存失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title,
|
||||
category_id: form.category_id || 0,
|
||||
tags: (form.tagList || []).join(","),
|
||||
summary: form.summary || "",
|
||||
status: form.status,
|
||||
visibility: form.visibility,
|
||||
owner_name: form.owner_name || "",
|
||||
file_id: upData.id || 0,
|
||||
file_url: upData.url || "",
|
||||
file_name: fileName,
|
||||
ext: "docx",
|
||||
size: docxBlob.size,
|
||||
content: html
|
||||
};
|
||||
|
||||
const res = isEdit.value
|
||||
? await updateDoc(props.doc.id, payload)
|
||||
: await createDoc(payload);
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success(isEdit.value ? "已保存" : "创建成功");
|
||||
visible.value = false;
|
||||
emit("saved");
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "保存失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const resp = await fetch(url, { mode: "cors" });
|
||||
if (!resp.ok) throw new Error(`请求失败(${resp.status})`);
|
||||
return await resp.text();
|
||||
}
|
||||
|
||||
/** 判断编辑器内容是否为空(去掉标签与空白后仍有字符) */
|
||||
function hasContent(html) {
|
||||
return (
|
||||
String(html || "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /gi, "")
|
||||
.replace(/\s/g, "").length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** 从完整 HTML 文档中取出 body 内容,便于回填编辑器 */
|
||||
function extractBody(html) {
|
||||
const s = String(html || "");
|
||||
if (!/<body[\s>]/i.test(s)) return s;
|
||||
try {
|
||||
return new DOMParser().parseFromString(s, "text/html").body.innerHTML;
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
/** 将相对图片地址补全为绝对地址(编辑器/导出 docx 都需要能直接访问) */
|
||||
function absolutizeUrls(html) {
|
||||
const s = String(html || "");
|
||||
if (!s) return s;
|
||||
const base = String(import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, "");
|
||||
if (!base) return s;
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(s, "text/html");
|
||||
doc.querySelectorAll("img").forEach(img => {
|
||||
const src = img.getAttribute("src") || "";
|
||||
if (src && !/^(https?:|data:|blob:)/i.test(src)) {
|
||||
img.setAttribute("src", base + (src.startsWith("/") ? src : "/" + src));
|
||||
}
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function safeFileName(name) {
|
||||
return String(name || "文档")
|
||||
.replace(/[\\/:*?"<>|]/g, "_")
|
||||
.slice(0, 80);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-editor-dialog :deep(.el-dialog__body) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.doc-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.doc-editor-fs {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.doc-editor-box {
|
||||
width: 100%;
|
||||
height: 460px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.doc-editor-box :deep(.umo-editor),
|
||||
.doc-editor-box :deep(umo-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.doc-editor-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.doc-editor-hint {
|
||||
margin-right: auto;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
class="doc-preview-dialog"
|
||||
width="82%"
|
||||
top="5vh"
|
||||
:fullscreen="fullscreen"
|
||||
destroy-on-close
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<template #header="{ titleId, titleClass }">
|
||||
<div class="pv-header">
|
||||
<span :id="titleId" :class="titleClass">{{ dialogTitle }}</span>
|
||||
<el-button
|
||||
link
|
||||
:icon="fullscreen ? Close : FullScreen"
|
||||
class="pv-fs"
|
||||
@click="fullscreen = !fullscreen"
|
||||
>
|
||||
{{ fullscreen ? "退出全屏" : "全屏" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="pv-toolbar">
|
||||
<div class="pv-title">
|
||||
<el-tag size="small" effect="plain">{{ kindLabel }}</el-tag>
|
||||
<span class="pv-name" :title="fileName">{{ fileName }}</span>
|
||||
<span v-if="fileSize" class="pv-size">{{ fileSize }}</span>
|
||||
</div>
|
||||
<div class="pv-ops">
|
||||
<el-button
|
||||
v-if="kind === 'image'"
|
||||
size="small"
|
||||
:icon="ZoomIn"
|
||||
@click="toggleImageFit"
|
||||
>{{ imageFit === "original" ? "适应宽度" : "原始大小" }}</el-button
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="RefreshRight"
|
||||
:disabled="loading"
|
||||
@click="reload"
|
||||
>重新加载</el-button
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:icon="Download"
|
||||
@click="emit('download', doc)"
|
||||
>下载</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载文档..."
|
||||
class="pv-stage"
|
||||
:style="stageStyle"
|
||||
>
|
||||
<template v-if="!loading">
|
||||
<!-- 加载失败:引导下载 -->
|
||||
<el-empty v-if="error" description="无法在线预览">
|
||||
<p class="pv-tip">{{ error }}</p>
|
||||
<el-button type="primary" :icon="Download" @click="emit('download', doc)"
|
||||
>下载后查看</el-button
|
||||
>
|
||||
</el-empty>
|
||||
|
||||
<!-- 图片:默认适应宽度,可切原始大小;超出容器时滚动查看 -->
|
||||
<img
|
||||
v-else-if="kind === 'image'"
|
||||
:src="fileUrl"
|
||||
class="pv-image"
|
||||
:style="imageStyle"
|
||||
/>
|
||||
|
||||
<!-- PDF:交给浏览器内置阅读器 -->
|
||||
<iframe
|
||||
v-else-if="kind === 'pdf'"
|
||||
:src="fileUrl"
|
||||
class="pv-frame"
|
||||
frameborder="0"
|
||||
:style="frameStyle"
|
||||
/>
|
||||
|
||||
<!-- 视频 -->
|
||||
<video
|
||||
v-else-if="kind === 'video'"
|
||||
:src="fileUrl"
|
||||
controls
|
||||
class="pv-media"
|
||||
:style="mediaStyle"
|
||||
/>
|
||||
|
||||
<!-- 音频 -->
|
||||
<audio
|
||||
v-else-if="kind === 'audio'"
|
||||
:src="fileUrl"
|
||||
controls
|
||||
class="pv-audio"
|
||||
/>
|
||||
|
||||
<!-- Word(docx) -->
|
||||
<div v-else-if="kind === 'docx'" ref="docxBox" class="pv-docx"></div>
|
||||
|
||||
<!-- Excel / CSV -->
|
||||
<div v-else-if="kind === 'sheet'" class="pv-sheet">
|
||||
<div v-for="s in sheets" :key="s.name" class="pv-sheet-item">
|
||||
<div class="pv-sheet-name">{{ s.name }}</div>
|
||||
<div class="pv-sheet-html" v-html="s.html"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 纯文本 -->
|
||||
<pre v-else-if="kind === 'text'" class="pv-text">{{ textContent }}</pre>
|
||||
|
||||
<!-- Markdown / HTML -->
|
||||
<div v-else-if="kind === 'html'" class="pv-html" v-html="safeHtml"></div>
|
||||
|
||||
<!-- 其他类型 -->
|
||||
<el-empty v-else :description="`暂不支持在线预览 .${ext || ''} 文件`">
|
||||
<p class="pv-tip">该格式无法在浏览器中安全渲染,请下载后使用本地软件打开。</p>
|
||||
<el-button type="primary" :icon="Download" @click="emit('download', doc)"
|
||||
>下载后查看</el-button
|
||||
>
|
||||
</el-empty>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import {
|
||||
Close,
|
||||
Download,
|
||||
FullScreen,
|
||||
RefreshRight,
|
||||
ZoomIn
|
||||
} from "@element-plus/icons-vue";
|
||||
import { formatFileSize, getFileUrl } from "../../utils";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
doc: { type: Object, default: null }
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "download"]);
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
const fullscreen = ref(false);
|
||||
const imageFit = ref("width"); // width=适应宽度;original=原始大小
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const textContent = ref("");
|
||||
const safeHtml = ref("");
|
||||
const sheets = ref([]);
|
||||
const docxBox = ref(null);
|
||||
|
||||
// 全屏时让内容区自适应可视区域
|
||||
const stageStyle = computed(() =>
|
||||
fullscreen.value ? { maxHeight: "calc(100vh - 200px)" } : {}
|
||||
);
|
||||
const frameStyle = computed(() =>
|
||||
fullscreen.value ? { height: "calc(100vh - 250px)" } : {}
|
||||
);
|
||||
const mediaStyle = computed(() =>
|
||||
fullscreen.value ? { maxHeight: "calc(100vh - 250px)" } : {}
|
||||
);
|
||||
// 图片:适应宽度时按宽度等比缩放;原始大小时保持原尺寸,超出由容器滚动查看
|
||||
const imageStyle = computed(() =>
|
||||
imageFit.value === "original"
|
||||
? { maxWidth: "none", width: "auto", height: "auto" }
|
||||
: { maxWidth: "100%", width: "auto", height: "auto" }
|
||||
);
|
||||
|
||||
function toggleImageFit() {
|
||||
imageFit.value = imageFit.value === "original" ? "width" : "original";
|
||||
}
|
||||
|
||||
const ext = computed(() => String(props.doc?.ext || "").toLowerCase());
|
||||
const fileUrl = computed(() => getFileUrl(props.doc?.file_url || ""));
|
||||
const fileName = computed(
|
||||
() => props.doc?.file_name || props.doc?.title || "文档"
|
||||
);
|
||||
const dialogTitle = computed(() => props.doc?.title || fileName.value);
|
||||
const fileSize = computed(() =>
|
||||
props.doc?.size ? formatFileSize(props.doc.size) : ""
|
||||
);
|
||||
|
||||
const IMAGE_EXT = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico"];
|
||||
const VIDEO_EXT = ["mp4", "webm", "ogv", "mov", "m4v", "avi", "mkv"];
|
||||
const AUDIO_EXT = ["mp3", "wav", "ogg", "m4a", "aac", "flac"];
|
||||
const SHEET_EXT = ["xls", "xlsx", "csv"];
|
||||
const TEXT_EXT = [
|
||||
"txt",
|
||||
"log",
|
||||
"json",
|
||||
"xml",
|
||||
"yml",
|
||||
"yaml",
|
||||
"ini",
|
||||
"conf",
|
||||
"js",
|
||||
"ts",
|
||||
"css",
|
||||
"sql",
|
||||
"sh"
|
||||
];
|
||||
const HTML_EXT = ["md", "markdown", "html", "htm"];
|
||||
|
||||
const kind = computed(() => {
|
||||
const e = ext.value;
|
||||
if (!e) return "unknown";
|
||||
if (IMAGE_EXT.includes(e)) return "image";
|
||||
if (e === "pdf") return "pdf";
|
||||
if (VIDEO_EXT.includes(e)) return "video";
|
||||
if (AUDIO_EXT.includes(e)) return "audio";
|
||||
if (e === "docx") return "docx";
|
||||
if (SHEET_EXT.includes(e)) return "sheet";
|
||||
if (e === "md" || e === "markdown" || e === "html" || e === "htm")
|
||||
return "html";
|
||||
if (TEXT_EXT.includes(e)) return "text";
|
||||
return "unknown";
|
||||
});
|
||||
|
||||
const kindLabel = computed(
|
||||
() =>
|
||||
({
|
||||
image: "图片",
|
||||
pdf: "PDF",
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
docx: "Word",
|
||||
sheet: "表格",
|
||||
html: "文本",
|
||||
text: "文本",
|
||||
unknown: "文件"
|
||||
})[kind.value] || "文件"
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
fullscreen.value = false;
|
||||
imageFit.value = "width";
|
||||
start();
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(visible, val => emit("update:modelValue", val));
|
||||
// 弹窗已打开时切换文档,重新加载预览内容
|
||||
watch(
|
||||
() => props.doc,
|
||||
() => {
|
||||
if (visible.value) start();
|
||||
}
|
||||
);
|
||||
|
||||
function reload() {
|
||||
start();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
error.value = "";
|
||||
textContent.value = "";
|
||||
safeHtml.value = "";
|
||||
sheets.value = [];
|
||||
if (docxBox.value) docxBox.value.innerHTML = "";
|
||||
}
|
||||
|
||||
async function start() {
|
||||
reset();
|
||||
if (!fileUrl.value) {
|
||||
error.value = "该文档没有可预览的附件";
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
// 图片 / PDF / 音视频由浏览器直接渲染,无需预取
|
||||
if (["image", "pdf", "video", "audio", "unknown"].includes(kind.value)) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
if (kind.value === "docx") {
|
||||
const buf = await fetchBuffer(fileUrl.value);
|
||||
loading.value = false;
|
||||
await nextTick();
|
||||
const { renderAsync } = await import("docx-preview");
|
||||
await renderAsync(buf, docxBox.value, null, {
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
useBase64URL: true
|
||||
});
|
||||
} else if (kind.value === "sheet") {
|
||||
const buf = await fetchBuffer(fileUrl.value);
|
||||
const XLSX = await import("xlsx");
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
sheets.value = (wb.SheetNames || []).map(name => ({
|
||||
name,
|
||||
html: tableOnly(XLSX.utils.sheet_to_html(wb.Sheets[name]))
|
||||
}));
|
||||
loading.value = false;
|
||||
} else if (kind.value === "html") {
|
||||
const txt = await fetchText(fileUrl.value);
|
||||
safeHtml.value =
|
||||
ext.value === "md" || ext.value === "markdown"
|
||||
? sanitize((await import("marked")).marked.parse(txt))
|
||||
: sanitize(txt);
|
||||
loading.value = false;
|
||||
} else if (kind.value === "text") {
|
||||
textContent.value = await fetchText(fileUrl.value);
|
||||
loading.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e?.message ? `加载失败:${e.message}` : "加载失败";
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBuffer(url) {
|
||||
const resp = await fetch(url, { mode: "cors" });
|
||||
if (!resp.ok) throw new Error(`请求失败(${resp.status})`);
|
||||
return await resp.arrayBuffer();
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const resp = await fetch(url, { mode: "cors" });
|
||||
if (!resp.ok) throw new Error(`请求失败(${resp.status})`);
|
||||
return await resp.text();
|
||||
}
|
||||
|
||||
/** SheetJS 导出的 HTML 可能含 <html>/<body> 外壳,这里只保留 <table> */
|
||||
function tableOnly(html) {
|
||||
const m = /<table[\s\S]*<\/table>/i.exec(String(html || ""));
|
||||
return m ? m[0] : html;
|
||||
}
|
||||
|
||||
/** 轻量 HTML 清洗:移除脚本/内联事件等危险内容,避免上传文档造成 XSS */
|
||||
function sanitize(html) {
|
||||
const doc = new DOMParser().parseFromString(String(html || ""), "text/html");
|
||||
doc
|
||||
.querySelectorAll("script,iframe,object,embed,link,style,meta,base")
|
||||
.forEach(n => n.remove());
|
||||
doc.querySelectorAll("*").forEach(el => {
|
||||
[...el.attributes].forEach(attr => {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = String(attr.value || "")
|
||||
.replace(/\s+/g, "")
|
||||
.toLowerCase();
|
||||
if (name.startsWith("on")) el.removeAttribute(attr.name);
|
||||
if (
|
||||
(name === "href" || name === "src" || name === "xlink:href") &&
|
||||
val.startsWith("javascript:")
|
||||
) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
reset();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-preview-dialog :deep(.el-dialog__body) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.pv-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pv-fs {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pv-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.pv-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pv-name {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
max-width: 46vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pv-size {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.pv-stage {
|
||||
min-height: 260px;
|
||||
max-height: 74vh;
|
||||
overflow: auto;
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pv-tip {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.pv-image {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
/* 实际尺寸由内联样式控制(适应宽度 / 原始大小),超出时由 .pv-stage 滚动 */
|
||||
}
|
||||
|
||||
.pv-frame {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pv-media {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.pv-audio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 24px auto;
|
||||
}
|
||||
|
||||
.pv-docx {
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.pv-docx :deep(.docx-wrapper) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pv-sheet {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pv-sheet-item + .pv-sheet-item {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pv-sheet-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
padding: 6px 8px;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.pv-sheet-html {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pv-sheet-html :deep(table) {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pv-sheet-html :deep(td),
|
||||
.pv-sheet-html :deep(th) {
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pv-text {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
font-family: Consolas, Monaco, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.pv-html {
|
||||
background: #fff;
|
||||
padding: 16px 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #303133;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pv-html :deep(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pv-html :deep(table) {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.pv-html :deep(td),
|
||||
.pv-html :deep(th) {
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -12,7 +12,7 @@
|
||||
node-key="id"
|
||||
check-strictly
|
||||
clearable
|
||||
placeholder="未分类"
|
||||
placeholder="请选择分类"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -36,12 +36,12 @@
|
||||
</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 v-model="form.visibility" :disabled="true">
|
||||
<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="版本号">
|
||||
@@ -79,7 +79,9 @@ import { parseTags } from "../../utils";
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
doc: { type: Object, default: null },
|
||||
categories: { type: Array, default: () => [] }
|
||||
categories: { type: Array, default: () => [] },
|
||||
// 当前文档空间:shared=共享文档 personal=私密文档(决定可见性,且分类树已隔离)
|
||||
scope: { type: String, default: "shared" }
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "saved"]);
|
||||
@@ -107,7 +109,7 @@ watch(
|
||||
category_id: props.doc.category_id || 0,
|
||||
tagList: parseTags(props.doc.tags),
|
||||
status: props.doc.status ?? 0,
|
||||
visibility: props.doc.visibility ?? 0,
|
||||
visibility: props.scope === "personal" ? 1 : 0,
|
||||
version: props.doc.version || 1,
|
||||
owner_name: props.doc.owner_name || "",
|
||||
summary: props.doc.summary || ""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="上传文档"
|
||||
width="620px"
|
||||
width="720px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
@@ -11,41 +11,86 @@
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
multiple
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:file-list="fileList"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
:on-exceed="handleExceed"
|
||||
:show-file-list="false"
|
||||
:on-change="handleChange"
|
||||
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>
|
||||
<div class="el-upload__text">
|
||||
将文件拖到此处,或<em>点击选择</em>(支持批量)
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
支持 PDF / Office / 图片 / 压缩包等,单个文件不超过 200MB
|
||||
支持 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 v-if="fileList.length" label="文件列表">
|
||||
<el-table
|
||||
:data="fileList"
|
||||
size="small"
|
||||
border
|
||||
max-height="240"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column label="文档标题" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.title"
|
||||
size="small"
|
||||
maxlength="120"
|
||||
placeholder="默认使用文件名"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文件名" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.name }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" width="100">
|
||||
<template #default="{ row }">{{
|
||||
formatFileSize(row.size || row.raw?.size)
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70">
|
||||
<template #default="{ $index }">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="uploading"
|
||||
@click="removeFile($index)"
|
||||
>移除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="upload-tip">
|
||||
共 {{ fileList.length }} 个文件
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="uploading"
|
||||
@click="clearFiles"
|
||||
>清空</el-button
|
||||
>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="所属分类">
|
||||
<el-form-item v-if="uploading || uploadPercent > 0" label="进度">
|
||||
<div style="width: 100%">
|
||||
<el-progress
|
||||
:percentage="uploadPercent"
|
||||
:stroke-width="14"
|
||||
striped
|
||||
/>
|
||||
<div class="upload-tip">{{ progressText }}</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="所属分类" required>
|
||||
<el-tree-select
|
||||
v-model="form.category_id"
|
||||
:data="categoryTree"
|
||||
@@ -53,9 +98,12 @@
|
||||
node-key="id"
|
||||
check-strictly
|
||||
clearable
|
||||
placeholder="未分类"
|
||||
placeholder="请选择分类(应用于全部文件)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div v-if="!categoryTree.length" class="upload-tip">
|
||||
暂无分类,请先在左侧「文档分类」中新建分类后再上传
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="标签">
|
||||
@@ -65,7 +113,7 @@
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入后回车可新建标签"
|
||||
placeholder="输入后回车可新建标签(应用于全部文件)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="t in presetTags" :key="t" :label="t" :value="t" />
|
||||
@@ -81,12 +129,18 @@
|
||||
</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 v-model="form.visibility" :disabled="true">
|
||||
<el-radio :value="0">团队公开(共享文档)</el-radio>
|
||||
<el-radio :value="1">私密(仅自己及被共享者可见)</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="el-upload__tip">
|
||||
私密文档仅本人及被共享的用户/部门可见;设为私密后可在详情中设置共享范围。
|
||||
当前所在空间已决定归属:
|
||||
<template v-if="props.scope === 'personal'"
|
||||
>私密文档绑定到你的个人空间,仅自己及被显式共享的用户/部门可见,可在详情中设置共享范围。</template
|
||||
>
|
||||
<template v-else
|
||||
>共享文档对租户内所有人可见,归属到下方所选的共享分类。</template
|
||||
>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
@@ -102,22 +156,22 @@
|
||||
<el-input
|
||||
v-model="form.summary"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="选填,简要描述文档内容"
|
||||
placeholder="选填,简要描述文档内容(应用于全部文件)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button :disabled="uploading" @click="visible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="uploading"
|
||||
:disabled="uploading || !fileList.length"
|
||||
@click="handleSubmit"
|
||||
>保存</el-button
|
||||
>保存{{ fileList.length ? `(${fileList.length})` : "" }}</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -129,29 +183,36 @@ import { ElMessage } from "element-plus";
|
||||
import { UploadFilled } from "@element-plus/icons-vue";
|
||||
import { uploadFile } from "@/api/file";
|
||||
import { createDoc } from "@/api/oaDocument";
|
||||
import { formatFileSize } from "../../utils";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
categories: { type: Array, default: () => [] },
|
||||
defaultCategoryId: { type: Number, default: 0 },
|
||||
currentUserName: { type: String, default: "" }
|
||||
currentUserName: { type: String, default: "" },
|
||||
// 当前文档空间:shared=共享文档(固定公开)personal=私密文档(固定私密,绑定用户级别)
|
||||
scope: { type: String, default: "shared" },
|
||||
// 默认可见性:0-租户公开(共享文档)1-私密(个人文档)
|
||||
defaultVisibility: { type: Number, default: 0 }
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "saved"]);
|
||||
|
||||
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
||||
const MAX_COUNT = 100;
|
||||
|
||||
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 progressText = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
const form = ref({
|
||||
title: "",
|
||||
category_id: 0,
|
||||
tagList: [],
|
||||
status: 1,
|
||||
@@ -161,119 +222,187 @@ const form = ref({
|
||||
});
|
||||
|
||||
const categoryTree = computed(() => props.categories || []);
|
||||
// 可见性随空间锁定:共享空间固定公开,私密空间固定私密(分类树已按空间隔离)
|
||||
const lockedVisibility = computed(() =>
|
||||
props.scope === "personal" ? 1 : 0
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
fileList.value = [];
|
||||
uploadPercent.value = 0;
|
||||
progressText.value = "";
|
||||
form.value = {
|
||||
title: "",
|
||||
category_id: props.defaultCategoryId || 0,
|
||||
tagList: [],
|
||||
status: 1,
|
||||
visibility: 0,
|
||||
visibility: lockedVisibility.value,
|
||||
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 handleChange(file) {
|
||||
if (!file) return;
|
||||
if (fileList.value.some(f => f.uid === file.uid)) return;
|
||||
fileList.value.push({
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
size: file.size || file.raw?.size || 0,
|
||||
raw: file.raw || file,
|
||||
title: String(file.name || "").replace(/\.[^.]+$/, "")
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileRemove() {
|
||||
rawFile.value = null;
|
||||
function removeFile(index) {
|
||||
fileList.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function clearFiles() {
|
||||
fileList.value = [];
|
||||
uploadRef.value?.clearFiles?.();
|
||||
}
|
||||
|
||||
function handleExceed() {
|
||||
ElMessage.warning("单次只能上传 1 个文件");
|
||||
}
|
||||
|
||||
function buildFormData() {
|
||||
function buildFormData(raw) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", rawFile.value);
|
||||
fd.append("file", raw);
|
||||
fd.append("cate", "0");
|
||||
fd.append("scope", "tenant");
|
||||
return fd;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!rawFile.value) {
|
||||
const files = fileList.value.slice();
|
||||
if (!files.length) {
|
||||
ElMessage.warning("请选择要上传的文件");
|
||||
return;
|
||||
}
|
||||
if (rawFile.value.size > 200 * 1024 * 1024) {
|
||||
ElMessage.warning("单个文件不能超过 200MB");
|
||||
if (files.length > MAX_COUNT) {
|
||||
ElMessage.warning(`单次最多上传 ${MAX_COUNT} 个文件`);
|
||||
return;
|
||||
}
|
||||
const oversize = files.filter(
|
||||
f => (f.size || f.raw?.size || 0) > MAX_SIZE
|
||||
);
|
||||
if (oversize.length) {
|
||||
ElMessage.warning(
|
||||
`以下文件超过 200MB:${oversize
|
||||
.map(f => f.name)
|
||||
.slice(0, 3)
|
||||
.join("、")}${oversize.length > 3 ? " 等" : ""}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 文档必须归属分类:未选择分类(或还没有分类)时不允许上传
|
||||
if (!categoryTree.value.length) {
|
||||
ElMessage.warning("暂无文档分类,请先在左侧「文档分类」中新建分类");
|
||||
return;
|
||||
}
|
||||
if (!form.value.category_id) {
|
||||
ElMessage.warning("请先选择所属分类");
|
||||
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("上传成功");
|
||||
const total = files.length;
|
||||
const failures = [];
|
||||
let okCount = 0;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < total; i++) {
|
||||
const f = files[i];
|
||||
const raw = f.raw || f;
|
||||
const base = (i / total) * 100;
|
||||
const span = 100 / total;
|
||||
progressText.value = `正在上传 ${i + 1}/${total}:${f.name}`;
|
||||
|
||||
try {
|
||||
const upRes = await uploadFile(buildFormData(raw), {
|
||||
onUploadProgress: e => {
|
||||
if (!e?.total) return;
|
||||
const p = Math.min(99, Math.round((e.loaded * 100) / e.total));
|
||||
uploadPercent.value = Math.min(
|
||||
100,
|
||||
Math.round(base + (span * p) / 100)
|
||||
);
|
||||
}
|
||||
});
|
||||
// 秒传命中时返回 code=201,同样携带 url/id/name
|
||||
const upData = upRes?.data || {};
|
||||
if (upRes?.code !== 200 && upRes?.code !== 201) {
|
||||
failures.push(`${f.name}:${upRes?.msg || "上传失败"}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = upData.name || raw.name || "";
|
||||
const ext = name.includes(".") ? name.split(".").pop() : "";
|
||||
const res = await createDoc({
|
||||
title:
|
||||
String(f.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: raw.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) {
|
||||
okCount++;
|
||||
} else {
|
||||
failures.push(`${f.name}:${res?.msg || "保存失败"}`);
|
||||
}
|
||||
} catch (err) {
|
||||
failures.push(`${f.name}:${err?.message || "上传失败"}`);
|
||||
}
|
||||
|
||||
uploadPercent.value = Math.round(((i + 1) / total) * 100);
|
||||
}
|
||||
|
||||
if (okCount > 0) {
|
||||
if (failures.length) {
|
||||
ElMessage.warning(
|
||||
`成功 ${okCount} 个,失败 ${failures.length} 个:${failures
|
||||
.slice(0, 3)
|
||||
.join(";")}${failures.length > 3 ? " 等" : ""}`
|
||||
);
|
||||
} else {
|
||||
ElMessage.success(`成功上传 ${okCount} 个文档`);
|
||||
}
|
||||
visible.value = false;
|
||||
emit("saved");
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "保存失败");
|
||||
ElMessage.error(
|
||||
`上传失败:${failures.slice(0, 3).join(";")}${
|
||||
failures.length > 3 ? " 等" : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || "上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
submitting.value = false;
|
||||
progressText.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
rawFile.value = null;
|
||||
fileList.value = [];
|
||||
uploadPercent.value = 0;
|
||||
progressText.value = "";
|
||||
uploadRef.value?.clearFiles?.();
|
||||
}
|
||||
</script>
|
||||
@@ -282,4 +411,14 @@ function handleClosed() {
|
||||
:deep(.el-upload-dragger) {
|
||||
padding: 18px 12px;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,16 +3,44 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>文档库</h2>
|
||||
<p>按分类集中管理租户文档,支持上传、检索、预览、关联与归档</p>
|
||||
<p>
|
||||
按「共享文档 / 私密文档」两个空间分别管理:共享文档租户内可见,私密文档绑定到个人,支持在线制作、上传、检索、预览、关联与归档
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Plus" @click="openCreateCategory()">新建分类</el-button>
|
||||
<el-button :icon="EditPen" @click="openDocEditor()">新建文档</el-button>
|
||||
<el-button type="primary" :icon="Upload" @click="uploadVisible = true"
|
||||
>上传文档</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空间切换:共享文档 / 私密文档(各自独立的分类树与文档) -->
|
||||
<div class="scope-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="scope-tab"
|
||||
:class="{ active: activeScope === 'shared' }"
|
||||
@click="switchScope('shared')"
|
||||
>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span>共享文档</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scope-tab"
|
||||
:class="{ active: activeScope === 'personal' }"
|
||||
@click="switchScope('personal')"
|
||||
>
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span>私密文档</span>
|
||||
</button>
|
||||
<span class="scope-hint" v-if="activeScope === 'personal'"
|
||||
>仅自己及被共享者可见</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 概览 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
@@ -40,7 +68,7 @@
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<!-- 左侧:分类树 -->
|
||||
<!-- 左侧:统一分类树 -->
|
||||
<div class="category-pane">
|
||||
<div class="pane-header">
|
||||
<span>文档分类</span>
|
||||
@@ -57,22 +85,17 @@
|
||||
<div class="quick-list">
|
||||
<div
|
||||
class="quick-item"
|
||||
:class="{ active: activeCategoryId === 0 && !uncategorized }"
|
||||
:class="{ active: activeCategoryId === 0 }"
|
||||
@click="selectQuick(0)"
|
||||
>
|
||||
<el-icon><Files /></el-icon>
|
||||
<span>全部文档</span>
|
||||
<span class="count">{{ stats.total || 0 }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="quick-item"
|
||||
:class="{ active: uncategorized }"
|
||||
@click="selectQuick(-1)"
|
||||
>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span>未分类</span>
|
||||
<span class="count">{{ uncategorizedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!categoryTree.length" class="tree-empty">
|
||||
暂无分类,点击右上角 + 新建
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="tree-scroll">
|
||||
@@ -90,6 +113,14 @@
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node">
|
||||
<span class="node-name" :title="data.name">{{ data.name }}</span>
|
||||
<el-tag
|
||||
v-if="isSystemCategory(data)"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
class="builtin-tag"
|
||||
>内置</el-tag
|
||||
>
|
||||
<span class="node-count">{{ data.doc_count || 0 }}</span>
|
||||
<span class="node-actions" @click.stop>
|
||||
<el-button
|
||||
@@ -107,6 +138,7 @@
|
||||
@click="openRenameCategory(data)"
|
||||
/>
|
||||
<el-button
|
||||
v-if="!isSystemCategory(data)"
|
||||
link
|
||||
size="small"
|
||||
type="danger"
|
||||
@@ -167,6 +199,9 @@
|
||||
<el-checkbox v-model="filters.star" :true-value="1" :false-value="0"
|
||||
>仅看收藏</el-checkbox
|
||||
>
|
||||
<el-checkbox v-model="mineFilter" :true-value="true" :false-value="false"
|
||||
>仅看我创建的</el-checkbox
|
||||
>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
@@ -225,12 +260,21 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="可见性" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.visibility === 1" type="danger" size="small" effect="plain">
|
||||
私密
|
||||
<el-tag
|
||||
:type="row.visibility === 1 ? 'danger' : 'success'"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ activeScope === 'personal' ? '私密' : (row.visibility === 1 ? '私密' : '公开') }}
|
||||
</el-tag>
|
||||
<el-tag v-else type="success" size="small" effect="plain">公开</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="creator_name"
|
||||
label="创建人"
|
||||
width="100"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="docStatusTagType(row.status)" size="small">{{
|
||||
@@ -266,10 +310,17 @@
|
||||
formatDateTime(row.update_time)
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="handlePreview(row)">预览</el-button>
|
||||
<el-button
|
||||
v-if="isOnlineDoc(row)"
|
||||
link
|
||||
type="primary"
|
||||
@click="openDocEditor(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button link type="primary" @click="handleDownload(row)">下载</el-button>
|
||||
<el-button link type="primary" @click="handleStar(row)">{{
|
||||
row.is_star === 1 ? "取消收藏" : "收藏"
|
||||
@@ -277,7 +328,9 @@
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无文档" /></template>
|
||||
<template #empty>
|
||||
<el-empty description="暂无文档" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
@@ -296,8 +349,10 @@
|
||||
<!-- 上传 -->
|
||||
<UploadDialog
|
||||
v-model="uploadVisible"
|
||||
:scope="activeScope"
|
||||
:categories="categoryTree"
|
||||
:default-category-id="activeCategoryId > 0 ? activeCategoryId : 0"
|
||||
:default-visibility="activeScope === 'personal' ? 1 : 0"
|
||||
:current-user-name="currentUserName"
|
||||
@saved="onDataChanged"
|
||||
/>
|
||||
@@ -307,6 +362,18 @@
|
||||
v-model="editVisible"
|
||||
:doc="editingDoc"
|
||||
:categories="categoryTree"
|
||||
:scope="activeScope"
|
||||
@saved="onDataChanged"
|
||||
/>
|
||||
|
||||
<!-- 在线制作 / 编辑文档 -->
|
||||
<DocEditorDialog
|
||||
v-model="editorVisible"
|
||||
:doc="editorDoc"
|
||||
:scope="activeScope"
|
||||
:categories="categoryTree"
|
||||
:default-category-id="activeCategoryId > 0 ? activeCategoryId : 0"
|
||||
:current-user-name="currentUserName"
|
||||
@saved="onDataChanged"
|
||||
/>
|
||||
|
||||
@@ -332,7 +399,7 @@
|
||||
node-key="id"
|
||||
check-strictly
|
||||
clearable
|
||||
placeholder="未分类"
|
||||
placeholder="请选择目标分类"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<template #footer>
|
||||
@@ -341,29 +408,17 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<el-dialog v-model="previewVisible" :title="previewRow?.title" width="70%">
|
||||
<div class="preview-body">
|
||||
<el-image
|
||||
v-if="previewRow && isImageDoc(previewRow)"
|
||||
:src="getFileUrl(previewRow.file_url)"
|
||||
fit="contain"
|
||||
:preview-src-list="[getFileUrl(previewRow.file_url)]"
|
||||
class="preview-image"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="previewRow"
|
||||
:src="getFileUrl(previewRow.file_url)"
|
||||
class="preview-frame"
|
||||
frameborder="0"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
<!-- 文档在线预览 -->
|
||||
<DocPreviewDialog
|
||||
v-model="previewVisible"
|
||||
:doc="previewRow"
|
||||
@download="handleDownload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
Delete,
|
||||
@@ -372,6 +427,7 @@ import {
|
||||
Film,
|
||||
Files,
|
||||
FolderOpened,
|
||||
Lock,
|
||||
Microphone,
|
||||
Picture,
|
||||
Plus,
|
||||
@@ -386,12 +442,15 @@ import { useAuthStore } from "@/stores/auth";
|
||||
import UploadDialog from "./components/uploadDialog.vue";
|
||||
import EditDialog from "./components/editDialog.vue";
|
||||
import DocDetailDrawer from "./components/docDetailDrawer.vue";
|
||||
import DocPreviewDialog from "./components/docPreviewDialog.vue";
|
||||
import DocEditorDialog from "./components/docEditorDialog.vue";
|
||||
import {
|
||||
batchDeleteDocs,
|
||||
createDocCategory,
|
||||
deleteDoc,
|
||||
deleteDocCategory,
|
||||
getDocCategories,
|
||||
getDocDetail,
|
||||
getDocList,
|
||||
getDocStats,
|
||||
moveDocs,
|
||||
@@ -410,7 +469,6 @@ import {
|
||||
formatDateTime,
|
||||
formatFileSize,
|
||||
getFileUrl,
|
||||
isImageDoc,
|
||||
parseTags
|
||||
} from "../utils";
|
||||
|
||||
@@ -422,8 +480,13 @@ const categoryKeyword = ref("");
|
||||
const treeRef = ref(null);
|
||||
const expandedKeys = ref([]);
|
||||
|
||||
// 当前所在文档空间:shared=共享文档,personal=私密文档(各自独立的分类树)
|
||||
const activeScope = ref("shared");
|
||||
|
||||
const activeCategoryId = ref(0);
|
||||
const uncategorized = ref(false);
|
||||
|
||||
// 我创建的(在当前空间内进一步筛选)
|
||||
const mineFilter = ref(false);
|
||||
|
||||
const filters = reactive({
|
||||
keyword: "",
|
||||
@@ -442,6 +505,8 @@ const stats = ref({});
|
||||
const uploadVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const editingDoc = ref(null);
|
||||
const editorVisible = ref(false);
|
||||
const editorDoc = ref(null);
|
||||
const detailVisible = ref(false);
|
||||
const detailId = ref(0);
|
||||
const moveVisible = ref(false);
|
||||
@@ -450,16 +515,19 @@ const moving = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const previewRow = ref(null);
|
||||
|
||||
const uncategorizedCount = computed(() => stats.value?.uncategorized || 0);
|
||||
const currentCategoryName = computed(() => {
|
||||
if (uncategorized.value) return "未分类";
|
||||
if (!activeCategoryId.value) return "全部文档";
|
||||
return findCategory(categoryTree.value, activeCategoryId.value)?.name || "全部文档";
|
||||
});
|
||||
|
||||
// 系统内置分类:由后端按租户自动补建(is_system=1),租户隔离且不允许删除。
|
||||
function isSystemCategory(data) {
|
||||
return Number(data?.is_system) === 1;
|
||||
}
|
||||
|
||||
function categoryName(id) {
|
||||
if (!id) return "未分类";
|
||||
return findCategory(categoryTree.value, id)?.name || "未分类";
|
||||
if (!id) return "-";
|
||||
return findCategory(categoryTree.value, id)?.name || "-";
|
||||
}
|
||||
|
||||
function typeIcon(docType) {
|
||||
@@ -479,7 +547,7 @@ function typeIcon(docType) {
|
||||
|
||||
// ---------------- 数据加载 ----------------
|
||||
async function loadCategories() {
|
||||
const res = await getDocCategories();
|
||||
const res = await getDocCategories({ scope: activeScope.value });
|
||||
if (res?.code === 200) {
|
||||
categoryTree.value = res.data || [];
|
||||
expandedKeys.value = (res.data || []).map(c => c.id);
|
||||
@@ -487,7 +555,7 @@ async function loadCategories() {
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const res = await getDocStats();
|
||||
const res = await getDocStats({ scope: activeScope.value });
|
||||
if (res?.code === 200) {
|
||||
stats.value = res.data || {};
|
||||
}
|
||||
@@ -497,6 +565,7 @@ async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getDocList({
|
||||
scope: activeScope.value,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: filters.keyword || "",
|
||||
@@ -504,8 +573,8 @@ async function loadList() {
|
||||
doc_type: filters.doc_type === "" ? -1 : filters.doc_type,
|
||||
tag: filters.tag || "",
|
||||
star: filters.star ? 1 : -1,
|
||||
category_id: uncategorized.value ? 0 : activeCategoryId.value,
|
||||
uncategorized: uncategorized.value ? 1 : 0
|
||||
category_id: activeCategoryId.value,
|
||||
mine: mineFilter.value ? 1 : 0
|
||||
});
|
||||
if (res?.code === 200) {
|
||||
list.value = res.data?.list || [];
|
||||
@@ -524,6 +593,16 @@ function loadData() {
|
||||
loadCategories();
|
||||
}
|
||||
|
||||
// 切换空间(共享 / 私密):重置选中并重新加载该空间的数据
|
||||
function switchScope(scope) {
|
||||
if (activeScope.value === scope) return;
|
||||
activeScope.value = scope;
|
||||
activeCategoryId.value = 0;
|
||||
mineFilter.value = false;
|
||||
pagination.page = 1;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function onDataChanged() {
|
||||
loadList();
|
||||
loadStats();
|
||||
@@ -538,16 +617,21 @@ function filterCategory(value, data) {
|
||||
|
||||
watch(categoryKeyword, val => treeRef.value?.filter(val));
|
||||
|
||||
// 快捷入口与分类树共用同一选中态,切换时同步树的高亮
|
||||
watch([activeCategoryId, categoryTree], () => {
|
||||
nextTick(() => {
|
||||
treeRef.value?.setCurrentKey(activeCategoryId.value > 0 ? activeCategoryId.value : null);
|
||||
});
|
||||
});
|
||||
|
||||
function handleNodeClick(data) {
|
||||
uncategorized.value = false;
|
||||
activeCategoryId.value = data.id;
|
||||
pagination.page = 1;
|
||||
loadList();
|
||||
}
|
||||
|
||||
function selectQuick(id) {
|
||||
uncategorized.value = id === -1;
|
||||
activeCategoryId.value = id === -1 ? 0 : 0;
|
||||
activeCategoryId.value = id > 0 ? id : 0;
|
||||
pagination.page = 1;
|
||||
loadList();
|
||||
}
|
||||
@@ -561,6 +645,7 @@ async function openCreateCategory(parentId = 0) {
|
||||
inputErrorMessage: "分类名称不能为空"
|
||||
});
|
||||
const res = await createDocCategory({
|
||||
scope: activeScope.value,
|
||||
parent_id: parentId || 0,
|
||||
name: String(value || "").trim(),
|
||||
sort: 0
|
||||
@@ -601,6 +686,10 @@ async function openRenameCategory(data) {
|
||||
}
|
||||
|
||||
async function handleDeleteCategory(data) {
|
||||
if (isSystemCategory(data)) {
|
||||
ElMessage.warning("系统内置分类不可删除");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除分类「${data.name}」吗?`,
|
||||
@@ -674,13 +763,33 @@ async function handleStar(row) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 在线制作的文档(可再次编辑):后端标记 is_online,兼容早期以 html 文件保存的文档 */
|
||||
function isOnlineDoc(row) {
|
||||
if (Number(row?.is_online) === 1) return true;
|
||||
return ["html", "htm"].includes(String(row?.ext || "").toLowerCase());
|
||||
}
|
||||
|
||||
/** 打开在线文档编辑器:传 row 为编辑已有文档,不传为新建 */
|
||||
function openDocEditor(row) {
|
||||
editorDoc.value = row || null;
|
||||
editorVisible.value = true;
|
||||
}
|
||||
|
||||
function handlePreview(row) {
|
||||
if (!row?.file_url) {
|
||||
ElMessage.warning("该文档没有附件,无法预览");
|
||||
return;
|
||||
}
|
||||
previewRow.value = row;
|
||||
previewVisible.value = true;
|
||||
if (!isImageDoc(row) && !String(row.ext).toLowerCase().includes("pdf")) {
|
||||
window.open(getFileUrl(row.file_url), "_blank");
|
||||
previewVisible.value = false;
|
||||
}
|
||||
// 预览即视为"查看",用详情接口累加查看次数(失败不影响预览)
|
||||
getDocDetail(row.id)
|
||||
.then(res => {
|
||||
if (res?.code === 200 && res.data) {
|
||||
row.view_count = res.data.view_count;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function handleDownload(row) {
|
||||
@@ -810,6 +919,49 @@ onMounted(() => {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.scope-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.scope-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #c6e2ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-hint {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
@@ -896,11 +1048,29 @@ onMounted(() => {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
// 系统内置分类标记(项目文档)
|
||||
.builtin-tag {
|
||||
flex: 0 0 auto;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.tree-scroll {
|
||||
max-height: 560px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tree-empty {
|
||||
margin-top: 6px;
|
||||
padding: 16px 8px;
|
||||
border: 1px dashed var(--el-border-color, #dcdfe6);
|
||||
border-radius: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -938,6 +1108,58 @@ onMounted(() => {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.visibility-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chips-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: #c6e2ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
font-size: 12px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chip.active .chip-count {
|
||||
background: rgba(64, 158, 255, 0.18);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -954,6 +1176,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1011,24 +1236,6 @@ onMounted(() => {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
width: 100%;
|
||||
height: 60vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
// ===== 暗色主题适配 =====
|
||||
html.dark & {
|
||||
.page-header p {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { UmoEditor } from '@umoteam/editor'
|
||||
import '@umoteam/editor/style'
|
||||
import { uploadFile } from '@/api/file'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
Reference in New Issue
Block a user