feat: 完善知识库、策略预览与OnlyOffice集成

## 配置与环境
- .env.example: 更新环境变量配置
- docker-compose.yml: 完善Docker编排配置
- docker/README.md: 更新Docker文档

## 后端知识库模块
- endpoints/knowledge.py: 增强知识库API端点
- schemas/knowledge.py: 扩展知识库数据模型
- services/knowledge.py: 完善知识库业务逻辑
- config.py: 优化配置管理
- storage/knowledge/.index.json: 更新知识库索引

## 前端功能
- api.js: 完善API服务层
- knowledge.js: 优化知识库服务
- onlyoffice.js: 新增OnlyOffice文档服务集成
- TopBar.vue: 优化顶部导航栏
- PoliciesView.vue: 完善策略视图
- AppShellRouteView.vue: 新增应用外壳路由视图
- views/scripts/PoliciesView.js: 优化策略脚本
- policiesPreviewFormatters.js: 新增策略预览格式化工具

## 样式
- policies-view.css: 完善策略页样式

## 测试
- api-request.test.mjs: API请求测试
- onlyoffice-service.test.mjs: OnlyOffice服务测试
- policies-preview-formatters.test.mjs: 策略预览格式化测试
This commit is contained in:
caoxiaozhu
2026-05-09 04:25:30 +00:00
parent 619281afc3
commit d9ffa9ce2c
21 changed files with 1469 additions and 508 deletions

View File

@@ -60,23 +60,36 @@
line-height: 1.5;
}
.preview-hint {
min-height: 28px;
.file-search {
width: min(320px, 100%);
min-height: 36px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: #f1f5f9;
gap: 8px;
padding: 0 12px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #64748b;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
transition: background 220ms ease, color 220ms ease;
transition: border-color 180ms ease, box-shadow 180ms ease;
}
.preview-hint.active {
background: #dcfce7;
color: #059669;
.file-search:focus-within {
border-color: #60a5fa;
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
}
.file-search input {
width: 100%;
min-width: 0;
border: 0;
color: #0f172a;
font-size: 13px;
background: transparent;
}
.file-search input:focus {
outline: none;
}
.library-body {
@@ -90,41 +103,12 @@
.folder-rail {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
border-right: 1px solid #edf2f7;
padding-right: 12px;
}
.folder-search {
height: 36px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 24px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #64748b;
}
.folder-search input {
min-width: 0;
border: 0;
color: #0f172a;
font-size: 13px;
}
.folder-search input:focus {
outline: none;
}
.folder-search button {
border: 0;
background: transparent;
color: #64748b;
}
.folder-tree {
min-height: 0;
display: grid;
@@ -180,6 +164,12 @@
font-weight: 850;
}
.new-folder-btn.fixed {
border-color: rgba(148, 163, 184, 0.3);
background: #f8fafc;
color: #64748b;
}
.document-area {
min-width: 0;
min-height: 0;
@@ -188,6 +178,10 @@
gap: 12px;
}
.upload-input {
display: none;
}
.upload-zone {
min-height: 112px;
display: grid;
@@ -199,6 +193,23 @@
background: #f8fbff;
color: #334155;
text-align: center;
cursor: pointer;
transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease;
}
.upload-zone:hover {
border-color: #60a5fa;
background: #f3f8ff;
}
.upload-zone.disabled {
cursor: default;
border-color: #cbd5e1;
background: #f8fafc;
}
.upload-zone.busy {
opacity: 0.72;
}
.upload-zone i {
@@ -308,11 +319,36 @@ th {
}
.more-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #2563eb;
}
.more-btn.danger {
color: #dc2626;
}
.more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row-actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.empty-row {
color: #64748b;
text-align: center;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
@@ -444,8 +480,8 @@ th {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
padding: 18px;
grid-template-rows: auto minmax(0, 1fr);
padding: 20px 22px;
overflow: hidden;
}
@@ -453,19 +489,13 @@ th {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
gap: 18px;
padding-bottom: 16px;
border-bottom: 1px solid #edf2f7;
}
.preview-kicker {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: #ecfdf5;
color: #059669;
font-size: 11px;
font-weight: 800;
.preview-copy {
min-width: 0;
}
.preview-actions {
@@ -500,164 +530,281 @@ th {
place-items: center;
}
.preview-meta {
.preview-summary-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #edf2f7;
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.preview-meta span {
display: inline-flex;
.preview-secondary-line {
display: flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #f8fafc;
color: #475569;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #1e293b;
color: #e2e8f0;
font-size: 12px;
font-weight: 700;
line-height: 1.5;
}
.preview-viewer {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
margin-top: 14px;
margin-top: 18px;
}
.viewer-toolbar {
.preview-status {
display: grid;
place-items: center;
min-height: 180px;
padding: 24px;
border: 1px dashed #cbd5e1;
border-radius: 14px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.preview-status.error {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.preview-embed-wrap,
.preview-image-wrap {
min-height: 0;
overflow: hidden;
border: 1px solid #edf2f7;
border-radius: 12px;
background: #fff;
}
.preview-embed {
width: 100%;
height: 100%;
min-height: 560px;
border: 0;
}
.preview-image-wrap {
display: grid;
place-items: center;
padding: 20px;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.onlyoffice-preview-wrap {
min-height: 0;
overflow: hidden;
border: 1px solid #dbe4ee;
border-radius: 12px;
background: #fff;
}
.onlyoffice-preview-host {
width: 100%;
min-height: 720px;
}
.excel-preview-wrap {
min-height: 0;
overflow: hidden;
border: 1px solid #dbe4ee;
border-radius: 12px;
background: #fff;
}
.excel-sheet-tabs {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
padding: 0 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
overflow-x: auto;
}
.viewer-filetype {
display: inline-flex;
align-items: center;
gap: 8px;
.excel-sheet-tab {
min-height: 34px;
padding: 0 12px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #475569;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.excel-sheet-tab.active {
border-color: #93c5fd;
background: #dbeafe;
color: #1d4ed8;
}
.excel-preview-scroll {
min-height: 0;
overflow: auto;
}
.excel-preview-table {
width: 100%;
min-width: 640px;
border-collapse: separate;
border-spacing: 0;
}
.excel-preview-table th,
.excel-preview-table td {
padding: 10px 12px;
border-right: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
text-align: left;
vertical-align: top;
white-space: nowrap;
font-size: 13px;
line-height: 1.5;
}
.excel-preview-table th {
position: sticky;
top: 0;
z-index: 1;
background: #e8f0fe;
color: #0f172a;
font-weight: 800;
}
.viewer-toolbar-actions {
display: inline-flex;
gap: 8px;
.excel-preview-table td {
color: #334155;
background: #fff;
}
.viewer-toolbar-actions button {
width: 32px;
height: 32px;
display: grid;
place-items: center;
.excel-preview-table tbody tr:nth-child(even) td {
background: #f8fafc;
}
.excel-preview-table tr > *:last-child {
border-right: 0;
}
.excel-preview-table tbody tr:last-child td {
border-bottom: 0;
}
.page-stage {
min-height: 0;
overflow: auto;
display: grid;
gap: 16px;
padding-right: 4px;
gap: 20px;
padding-right: 6px;
}
.page-sheet {
display: grid;
gap: 16px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
gap: 18px;
padding: 0 0 20px;
border-bottom: 1px solid #edf2f7;
background: transparent;
box-shadow: none;
animation: previewSheetIn 360ms var(--ease) both;
animation-delay: var(--page-delay, 0ms);
}
.page-sheet:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.page-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
justify-content: flex-start;
gap: 12px;
}
.page-title strong {
color: #0f172a;
font-size: 15px;
font-size: 16px;
font-weight: 850;
}
.page-title span,
.page-title b {
.page-title span {
display: block;
margin-top: 4px;
margin-top: 6px;
color: #64748b;
font-size: 12px;
font-weight: 700;
font-size: 13px;
font-weight: 600;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
gap: 12px;
}
.summary-item {
display: grid;
gap: 6px;
padding: 12px;
border-radius: 10px;
background: #f8fafc;
gap: 4px;
padding: 0 0 10px;
border-bottom: 1px solid #f1f5f9;
background: transparent;
}
.summary-item span {
color: #64748b;
font-size: 11px;
font-weight: 700;
font-size: 12px;
font-weight: 600;
}
.summary-item strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
font-weight: 800;
}
.page-content {
display: grid;
gap: 12px;
gap: 16px;
}
.content-block {
padding: 14px;
border-radius: 12px;
background: #ffffff;
border: 1px solid #edf2f7;
padding: 0;
border-radius: 0;
background: transparent;
border: 0;
}
.content-block h3 {
margin: 0 0 8px;
margin: 0 0 10px;
color: #0f172a;
font-size: 13px;
font-size: 14px;
font-weight: 850;
}
.content-block ul {
display: grid;
gap: 8px;
gap: 10px;
margin: 0;
padding-left: 18px;
padding-left: 20px;
color: #475569;
font-size: 12px;
line-height: 1.6;
font-size: 13px;
line-height: 1.75;
}
.preview-panel-enter-active,

View File

@@ -113,7 +113,7 @@
<div v-for="kpi in knowledgeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}</span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
<span v-if="kpi.meta" class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
@@ -144,6 +144,10 @@ const props = defineProps({
type: Object,
default: () => null
},
knowledgeSummary: {
type: Object,
default: () => null
},
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
@@ -187,12 +191,20 @@ const approvalKpis = [
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: '#10b981' }
]
const knowledgeKpis = [
{ label: '文档总数', value: '1,248', meta: '较上周 +68', trend: 'up', icon: 'mdi mdi-file-document-outline', color: '#10b981' },
{ label: '文件夹总数', value: '36', meta: '较上周 +2', trend: 'up', icon: 'mdi mdi-folder-outline', color: '#3b82f6' },
{ label: '问答总量', value: '8,562', meta: '较上周 +321', trend: 'up', icon: 'mdi mdi-comment-text-multiple-outline', color: '#8b5cf6' },
{ label: '知识命中率', value: '87.3%', meta: '较上周 +1.2%', trend: 'up', icon: 'mdi mdi-bullseye-arrow', color: '#f59e0b' }
]
const knowledgeKpis = computed(() => {
const summary = props.knowledgeSummary ?? {}
const totalDocuments = Number(summary.totalDocuments ?? 0)
return [
{
label: '文档总数',
value: String(totalDocuments),
meta: '',
trend: 'up',
color: '#10b981'
}
]
})
const employeeKpis = computed(() => {
const summary = props.employeeSummary ?? {}

View File

@@ -1,4 +1,37 @@
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
function readCurrentUserHeaders() {
if (typeof window === 'undefined') {
return {}
}
const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY)
if (!raw) {
return {}
}
try {
const payload = JSON.parse(raw)
const username = String(payload?.username || '').trim()
const name = String(payload?.name || username).trim()
const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : []
const isAdmin = Boolean(payload?.isAdmin)
if (!username && !name) {
return {}
}
return {
'x-auth-username': username,
'x-auth-name': name,
'x-auth-role-codes': roleCodes.join(','),
'x-auth-is-admin': String(isAdmin)
}
} catch {
return {}
}
}
function normalizeApiBaseUrl(value) {
return String(value || '/api/v1').replace(/\/$/, '')
@@ -66,22 +99,50 @@ function buildUrl(path) {
}
export async function apiRequest(path, options = {}) {
const {
contentType = 'application/json',
responseType = 'json',
headers: customHeaders,
...fetchOptions
} = options
const headers = {
...readCurrentUserHeaders(),
...(customHeaders || {})
}
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
headers['Content-Type'] = contentType
}
let response
try {
response = await fetch(buildUrl(path), {
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
...fetchOptions,
headers
})
} catch {
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
}
let payload = null
if (responseType === 'blob') {
if (!response.ok) {
let payload = null
try {
payload = await response.json()
} catch {
payload = null
}
throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
}
return response.blob()
}
let payload = null
try {
payload = await response.json()
} catch {

View File

@@ -8,6 +8,10 @@ export function fetchKnowledgeDocument(documentId) {
return apiRequest(`/knowledge/documents/${documentId}`)
}
export function fetchKnowledgeOnlyOfficeConfig(documentId) {
return apiRequest(`/knowledge/documents/${documentId}/onlyoffice-config`)
}
export function uploadKnowledgeDocument({ folder, file }) {
return apiRequest(
`/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`,

View File

@@ -0,0 +1,43 @@
const scriptPromises = new Map()
function normalizeBaseUrl(value) {
return String(value || '').replace(/\/$/, '')
}
export function buildOnlyOfficeScriptUrl(documentServerUrl) {
return `${normalizeBaseUrl(documentServerUrl)}/web-apps/apps/api/documents/api.js`
}
export function loadOnlyOfficeApi(documentServerUrl) {
const scriptUrl = buildOnlyOfficeScriptUrl(documentServerUrl)
if (typeof window === 'undefined') {
return Promise.reject(new Error('ONLYOFFICE 只能在浏览器环境中加载。'))
}
if (window.DocsAPI?.DocEditor) {
return Promise.resolve(window.DocsAPI)
}
if (scriptPromises.has(scriptUrl)) {
return scriptPromises.get(scriptUrl)
}
const promise = new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${scriptUrl}"]`)
if (existing) {
existing.addEventListener('load', () => resolve(window.DocsAPI), { once: true })
existing.addEventListener('error', () => reject(new Error('ONLYOFFICE 脚本加载失败。')), { once: true })
return
}
const script = document.createElement('script')
script.src = scriptUrl
script.async = true
script.onload = () => resolve(window.DocsAPI)
script.onerror = () => reject(new Error('ONLYOFFICE 脚本加载失败。'))
document.head.appendChild(script)
})
scriptPromises.set(scriptUrl, promise)
return promise
}

View File

@@ -32,6 +32,7 @@
:ranges="ranges"
:active-range="activeRange"
:employee-summary="employeeSummary"
:knowledge-summary="knowledgeSummary"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@@ -110,7 +111,7 @@
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<PoliciesView v-else-if="activeView === 'policies'" />
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
<SettingsView v-else />
@@ -151,6 +152,7 @@ import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess } from '../utils/accessControl.js'
const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
const {
activeCase,

View File

@@ -8,19 +8,14 @@
<h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后可在右侧展开预览</p>
</div>
<span class="preview-hint" :class="{ active: selectedDocument }">
{{ selectedDocument ? '预览已展开' : '点击文件可预览' }}
</span>
<label class="file-search">
<i class="mdi mdi-magnify"></i>
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
</label>
</header>
<div class="library-body">
<aside class="folder-rail">
<label class="folder-search">
<i class="mdi mdi-magnify"></i>
<input v-model="folderSearch" type="search" placeholder="搜索文件夹" />
<button type="button" aria-label="新增文件夹"><i class="mdi mdi-plus"></i></button>
</label>
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="folder in filteredFolders"
@@ -35,17 +30,30 @@
</button>
</nav>
<button class="new-folder-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新建文件夹</span>
<button class="new-folder-btn fixed" type="button" disabled>
<i class="mdi mdi-lock-outline"></i>
<span>固定文件夹</span>
</button>
</aside>
<section class="document-area">
<div class="upload-zone">
<div
class="upload-zone"
:class="{ disabled: !isAdmin, busy: uploading }"
@click="triggerUpload"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="uploadInput"
class="upload-input"
type="file"
multiple
@change="handleFileInput"
/>
<i class="mdi mdi-cloud-upload"></i>
<strong>拖拽文档到此处或点击上传</strong>
<span>支持 PDF / Word / Excel / PPT 文档单个文件不超过 100MB</span>
<strong>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
@@ -64,10 +72,10 @@
<tbody>
<tr
v-for="doc in visibleDocuments"
:key="doc.name"
:key="doc.id"
class="doc-row"
:class="{ selected: selectedDocument?.name === doc.name }"
@click="selectedDocument = doc"
:class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)"
>
<td>
<span class="file-name">
@@ -83,9 +91,26 @@
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
<td>{{ doc.owner }}</td>
<td>
<button class="more-btn" type="button" aria-label="更多操作" @click.stop>
<i class="mdi mdi-dots-horizontal"></i>
</button>
<div class="row-actions" @click.stop>
<button class="more-btn" type="button" aria-label="下载文件" @click="handleDownload(doc)">
<i class="mdi mdi-download"></i>
</button>
<button
v-if="isAdmin"
class="more-btn danger"
type="button"
:disabled="deletingId === doc.id"
aria-label="删除文件"
@click="handleDelete(doc)"
>
<i class="mdi mdi-delete-outline"></i>
</button>
</div>
</td>
</tr>
<tr v-if="!visibleDocuments.length">
<td colspan="7" class="empty-row">
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
</td>
</tr>
</tbody>
@@ -93,17 +118,6 @@
</div>
<footer class="list-foot">
<template v-if="false">
<span> {{ filteredDocuments.length }} </span>
<button type="button">10/ <i class="mdi mdi-chevron-down"></i></button>
<div class="pager" aria-label="分页">
<button type="button" aria-label="上一页"><i class="mdi mdi-chevron-left"></i></button>
<button class="active" type="button" aria-current="page">1</button>
<button type="button">2</button>
<button type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
</div>
<label>前往 <input value="1" aria-label="页码" /> </label>
</template>
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
@@ -152,67 +166,78 @@
<aside v-if="selectedDocument" class="preview-column">
<article class="preview-panel panel">
<header class="preview-head">
<div>
<span class="preview-kicker">文件预览</span>
<div class="preview-copy">
<h2>{{ selectedDocument.name }}</h2>
<p>{{ selectedDocument.summary }}</p>
<p class="preview-summary-line">
<span v-for="part in previewMetaLine" :key="part">{{ part }}</span>
</p>
<div v-if="previewSecondaryMetaLine.length" class="preview-secondary-line">
<span v-for="part in previewSecondaryMetaLine" :key="part">{{ part }}</span>
</div>
</div>
<div class="preview-actions">
<button type="button" class="mini-action">
<button type="button" class="mini-action" @click="handleDownload(selectedDocument)">
<i class="mdi mdi-download"></i>
<span>下载</span>
</button>
<button type="button" class="icon-action" aria-label="关闭预览" @click="selectedDocument = null">
<button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="preview-meta">
<span><i class="mdi mdi-tag-outline"></i>{{ selectedDocument.tag }}</span>
<span><i class="mdi mdi-history"></i>{{ selectedDocument.time }}</span>
<span><i class="mdi mdi-account-circle-outline"></i>{{ selectedDocument.owner }}</span>
<span><i class="mdi mdi-source-branch"></i>{{ selectedDocument.version }}</span>
</div>
<div class="preview-viewer">
<div class="viewer-toolbar">
<div class="viewer-filetype" :class="selectedDocument.fileType">
<i :class="selectedDocument.icon"></i>
<span>{{ selectedDocument.fileTypeLabel }}</span>
</div>
<div class="viewer-toolbar-actions">
<button type="button"><i class="mdi mdi-magnify-minus-outline"></i></button>
<button type="button"><i class="mdi mdi-magnify-plus-outline"></i></button>
<button type="button"><i class="mdi mdi-fit-to-page-outline"></i></button>
</div>
<div v-if="previewLoading" class="preview-status">正在加载预览...</div>
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
<div v-else-if="selectedDocument.previewKind === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
<iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe>
</div>
<div class="page-stage">
<div v-else-if="selectedDocument.previewKind === 'image' && previewBlobUrl" class="preview-image-wrap">
<img :src="previewBlobUrl" :alt="selectedDocument.name" class="preview-image" />
</div>
<div v-else-if="shouldUseOnlyOffice" class="onlyoffice-preview-wrap">
<div v-if="onlyOfficeLoading" class="preview-status">正在加载 ONLYOFFICE 预览...</div>
<div v-else-if="onlyOfficeError" class="preview-status error">{{ onlyOfficeError }}</div>
<div v-else :id="onlyOfficeHostId" class="onlyoffice-preview-host"></div>
</div>
<div v-else-if="selectedDocument.previewKind === 'table'" class="excel-preview-wrap">
<div v-if="selectedDocument.previewPages.length > 1" class="excel-sheet-tabs" role="tablist" aria-label="Excel 工作表页签">
<button
v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.id}-sheet-${index}`"
type="button"
class="excel-sheet-tab"
:class="{ active: currentPreviewPageIndex === index }"
:aria-selected="currentPreviewPageIndex === index"
@click="selectPreviewPage(index)"
>
{{ page.title }}
</button>
</div>
<div v-if="excelPreviewTable.headers.length" class="excel-preview-scroll">
<table class="excel-preview-table">
<thead>
<tr>
<th v-for="header in excelPreviewTable.headers" :key="header">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in excelPreviewTable.rows" :key="`row-${rowIndex}`">
<td v-for="(cell, cellIndex) in row" :key="`cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="preview-status">当前表格暂未提取到可展示内容</div>
</div>
<div v-else class="page-stage">
<article
v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.name}-${index}`"
:key="`${selectedDocument.id}-${index}`"
class="page-sheet"
:style="{ '--page-delay': `${index * 70}ms` }"
>
<header class="page-title">
<div>
<strong>{{ page.title }}</strong>
<span>{{ page.subtitle }}</span>
</div>
<b> {{ index + 1 }} </b>
</header>
<section class="page-summary">
<div class="summary-grid">
<div v-for="item in page.stats" :key="item.label" class="summary-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</section>
<section class="page-content">
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
<h3>{{ block.heading }}</h3>
@@ -222,6 +247,9 @@
</div>
</section>
</article>
<div v-if="!selectedDocument.previewPages.length" class="preview-status">
当前文件暂未生成结构化预览请下载后查看
</div>
</div>
</div>
</article>

View File

@@ -1,249 +1,281 @@
import { computed, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
deleteKnowledgeDocument,
fetchKnowledgeDocument,
fetchKnowledgeDocumentBlob,
fetchKnowledgeLibrary,
fetchKnowledgeOnlyOfficeConfig,
uploadKnowledgeDocument
} from '../../services/knowledge.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { isManagerUser } from '../../utils/accessControl.js'
import {
buildExcelPreviewTable,
buildPreviewMetaLine,
buildPreviewSecondaryMetaLine
} from './policiesPreviewFormatters.js'
function triggerFileDownload(blob, filename) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
const ONLYOFFICE_EXTENSIONS = new Set(['docx', 'xlsx', 'pptx'])
function supportsOnlyOfficePreview(document) {
return ONLYOFFICE_EXTENSIONS.has(String(document?.extension || '').toLowerCase())
}
export default {
name: 'PoliciesView' ,
setup(props, { emit }) {
const folderSearch = ref('')
name: 'PoliciesView',
emits: ['summary-change'],
setup(_, { emit }) {
const { currentUser } = useSystemState()
const { toast } = useToast()
const documentSearch = ref('')
const activeFolder = ref('差旅规范')
const folders = ref([])
const documents = ref([])
const selectedDocument = ref(null)
const folders = [
{ name: '财务知识库', count: 36, icon: 'mdi mdi-folder' },
{ name: '制度政策', count: 8, icon: 'mdi mdi-folder' },
{ name: '报销制度', count: 12, icon: 'mdi mdi-folder-open' },
{ name: '差旅规范', count: 18, icon: 'mdi mdi-folder' },
{ name: '发票管理', count: 14, icon: 'mdi mdi-folder' },
{ name: '税务合规', count: 16, icon: 'mdi mdi-folder' },
{ name: '预算管理', count: 9, icon: 'mdi mdi-folder' },
{ name: '财务共享', count: 7, icon: 'mdi mdi-folder' },
{ name: '培训资料', count: 6, icon: 'mdi mdi-folder' },
{ name: '常见问答', count: 11, icon: 'mdi mdi-folder' }
]
const documents = [
{
name: '差旅报销管理办法2024版',
folder: '差旅规范',
tag: '差旅 / 制度',
time: '2024-05-12 14:35',
version: 'v3.2',
state: '已生效',
stateTone: 'success',
owner: '张明',
icon: 'mdi mdi-file-document-outline-pdf pdf',
fileType: 'pdf',
fileTypeLabel: 'PDF 预览',
summary: '面向员工与财务共享团队的差旅费用标准、审批边界和附件要求。',
previewPages: [
{
title: '差旅报销管理办法2024版',
subtitle: '住宿、交通、审批与附件要求',
stats: [
{ label: '适用范围', value: '全员' },
{ label: '生效日期', value: '2024-05-12' },
{ label: '更新重点', value: '住宿标准' }
],
blocks: [
{
heading: '一、适用范围',
lines: ['适用于国内差旅申请、预订、报销与借款冲销。', '共享中心审核以出差申请、票据与预算中心为准。']
},
{
heading: '二、住宿标准',
lines: ['一线城市单晚标准 650 元,超标需附业务说明。', '连续住宿超过 3 晚需补充行程与客户拜访记录。']
}
]
},
{
title: '审批与附件要求',
subtitle: '流程节点与必要凭证',
stats: [
{ label: '附件校验', value: '7 项' },
{ label: '审批节点', value: '4 级' },
{ label: '自动拦截', value: '超标 / 重复' }
],
blocks: [
{
heading: '三、审批规则',
lines: ['直属主管审批通过后进入财务复核。', '超预算或超标申请需追加部门负责人审批。']
},
{
heading: '四、附件清单',
lines: ['机票行程单、酒店发票、住宿水单、出租车发票。', '如存在改签、退票或异常情况,需补充说明材料。']
}
]
}
]
},
{
name: '发票查验规范及操作指引',
folder: '发票管理',
tag: '发票 / 操作',
time: '2024-05-10 10:22',
version: 'v1.5',
state: '已生效',
stateTone: 'success',
owner: '李娜',
icon: 'mdi mdi-file-document-outline-word word',
fileType: 'word',
fileTypeLabel: 'Word 预览',
summary: '说明发票验真路径、异常票据处理方式以及入账留痕要求。',
previewPages: [
{
title: '发票查验规范及操作指引',
subtitle: '验真流程与异常识别',
stats: [
{ label: '查验入口', value: '3 个' },
{ label: '异常类型', value: '6 类' },
{ label: '责任角色', value: '财务专员' }
],
blocks: [
{
heading: '一、查验入口',
lines: ['优先通过税务查验接口进行自动验真。', '无法自动识别时转人工核验并保留截图。']
},
{
heading: '二、异常票据',
lines: ['票面抬头不一致、号码重复、跨月补录需重点标注。', '出现红冲票据时需关联原单据并补充说明。']
}
]
}
]
},
{
name: '费用报销标准细则2024',
folder: '报销制度',
tag: '报销 / 标准',
time: '2024-05-08 09:16',
version: 'v2.1',
state: '已生效',
stateTone: 'success',
owner: '王磊',
icon: 'mdi mdi-file-document-outline-pdf pdf',
fileType: 'pdf',
fileTypeLabel: 'PDF 预览',
summary: '定义招待、差旅、办公采购与培训等费用类型的标准与限制。',
previewPages: [
{
title: '费用报销标准细则2024',
subtitle: '费用口径与报销边界',
stats: [
{ label: '费用大类', value: '8 类' },
{ label: '更新日期', value: '2024-05-08' },
{ label: '重点事项', value: '招待 / 交通' }
],
blocks: [
{
heading: '一、业务招待',
lines: ['需填写客户单位、参与人数及招待事由。', '单次超过 2000 元需上传审批邮件或会议纪要。']
},
{
heading: '二、交通与差旅',
lines: ['市内交通按真实票据报销,超标部分需说明。', '夜间出行或跨城交通需关联出差申请。']
}
]
}
]
},
{
name: '差旅费用标准对照表(国内)',
folder: '差旅规范',
tag: '差旅 / 标准',
time: '2024-05-05 08:20',
version: 'v1.3',
state: '审批中',
stateTone: 'warning',
owner: '陈杰',
icon: 'mdi mdi-file-document-outline-excel excel',
fileType: 'excel',
fileTypeLabel: 'Excel 预览',
summary: '各城市住宿、餐补与交通等级对照表,供申请与审核环节快速查询。',
previewPages: [
{
title: '差旅费用标准对照表(国内)',
subtitle: '城市维度对照',
stats: [
{ label: '覆盖城市', value: '48 个' },
{ label: '住宿档位', value: '4 级' },
{ label: '餐补标准', value: '日维度' }
],
blocks: [
{
heading: '一、住宿标准',
lines: ['北京 / 上海 / 深圳650 元 / 晚。', '新一线城市500 元 / 晚,其余城市按 380 元 / 晚执行。']
},
{
heading: '二、交通等级',
lines: ['总监及以上可乘坐高铁商务座或机票公务舱。', '其他员工默认经济舱、高铁二等座。']
}
]
}
]
},
{
name: '借款管理办法及流程',
folder: '财务共享',
tag: '借款 / 流程',
time: '2024-05-03 11:05',
version: 'v1.0',
state: '已生效',
stateTone: 'success',
owner: '刘洋',
icon: 'mdi mdi-file-document-outline-pdf pdf',
fileType: 'pdf',
fileTypeLabel: 'PDF 预览',
summary: '覆盖差旅借款、项目借款和借款冲销的全流程要求。',
previewPages: [
{
title: '借款管理办法及流程',
subtitle: '借款申请与冲销闭环',
stats: [
{ label: '适用场景', value: '差旅 / 项目' },
{ label: '冲销时限', value: '30 天' },
{ label: '审批路径', value: '3 级' }
],
blocks: [
{
heading: '一、借款申请',
lines: ['借款申请需绑定预算中心与费用类型。', '超过 5000 元需部门负责人额外审批。']
},
{
heading: '二、冲销要求',
lines: ['借款发生后 30 日内完成报销与冲销。', '逾期未冲销将纳入月度风险提醒。']
}
]
}
]
}
]
const filteredFolders = computed(() => {
const key = folderSearch.value.trim()
if (!key) return folders
return folders.filter((folder) => folder.name.includes(key))
})
const filteredDocuments = computed(() =>
documents.filter((doc) => {
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
return inFolder
})
)
const pageSizeOpen = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false)
const loading = ref(false)
const uploadInput = ref(null)
const uploading = ref(false)
const deletingId = ref('')
const previewLoading = ref(false)
const previewBlobUrl = ref('')
const previewError = ref('')
const onlyOfficeLoading = ref(false)
const onlyOfficeError = ref('')
const onlyOfficeEditor = ref(null)
const onlyOfficeHostId = ref('knowledge-onlyoffice-preview')
const currentPreviewPageIndex = ref(0)
const isAdmin = computed(() => isManagerUser(currentUser.value))
const uploadHint = computed(() =>
isAdmin.value
? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本'
: '当前账号只有查阅权限,上传、删除和修改仅管理员可用'
)
const filteredFolders = computed(() => folders.value)
const filteredDocuments = computed(() => {
const key = documentSearch.value.trim()
return documents.value.filter((doc) => {
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
const matchesSearch = key ? doc.name.includes(key) : true
return inFolder && matchesSearch
})
})
const totalCount = computed(() => filteredDocuments.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleDocuments = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredDocuments.value.slice(start, start + pageSize.value)
})
const activePreviewPage = computed(() => {
const pages = selectedDocument.value?.previewPages || []
return pages[currentPreviewPageIndex.value] || pages[0] || null
})
const previewMetaLine = computed(() => buildPreviewMetaLine(selectedDocument.value))
const previewSecondaryMetaLine = computed(() =>
buildPreviewSecondaryMetaLine(selectedDocument.value, activePreviewPage.value)
)
const shouldUseOnlyOffice = computed(() => supportsOnlyOfficePreview(selectedDocument.value))
const excelPreviewTable = computed(() =>
selectedDocument.value?.previewKind === 'table'
? buildExcelPreviewTable(activePreviewPage.value)
: { headers: [], rows: [] }
)
function revokePreviewBlob() {
if (previewBlobUrl.value) {
URL.revokeObjectURL(previewBlobUrl.value)
previewBlobUrl.value = ''
}
}
function destroyOnlyOfficeEditor() {
if (onlyOfficeEditor.value?.destroyEditor) {
onlyOfficeEditor.value.destroyEditor()
}
onlyOfficeEditor.value = null
}
async function mountOnlyOfficeEditor(documentId) {
onlyOfficeLoading.value = true
onlyOfficeError.value = ''
destroyOnlyOfficeEditor()
try {
const payload = await fetchKnowledgeOnlyOfficeConfig(documentId)
await loadOnlyOfficeApi(payload.documentServerUrl)
await nextTick()
if (!window.DocsAPI?.DocEditor) {
throw new Error('ONLYOFFICE 编辑器未正确加载。')
}
onlyOfficeHostId.value = `knowledge-onlyoffice-preview-${documentId}`
await nextTick()
onlyOfficeEditor.value = new window.DocsAPI.DocEditor(onlyOfficeHostId.value, payload.config)
} catch (error) {
onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。'
} finally {
onlyOfficeLoading.value = false
}
}
async function loadLibrary(options = {}) {
loading.value = true
try {
const payload = await fetchKnowledgeLibrary()
folders.value = payload.folders || []
documents.value = payload.documents || []
emit('summary-change', { totalDocuments: documents.value.length })
const activeExists = folders.value.some((folder) => folder.name === activeFolder.value)
if (!activeExists) {
activeFolder.value = folders.value[0]?.name || ''
}
if (options.preserveSelection && selectedDocument.value?.id) {
const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id)
if (!exists) {
selectedDocument.value = null
revokePreviewBlob()
}
}
} catch (error) {
emit('summary-change', { totalDocuments: 0 })
toast(error.message || '知识库加载失败。')
} finally {
loading.value = false
}
}
async function selectDocument(documentId) {
previewLoading.value = true
previewError.value = ''
onlyOfficeError.value = ''
revokePreviewBlob()
destroyOnlyOfficeEditor()
try {
const payload = await fetchKnowledgeDocument(documentId)
selectedDocument.value = payload
currentPreviewPageIndex.value = 0
if (supportsOnlyOfficePreview(payload)) {
await mountOnlyOfficeEditor(documentId)
} else if (payload.previewKind === 'pdf' || payload.previewKind === 'image') {
const blob = await fetchKnowledgeDocumentBlob(documentId, 'inline')
previewBlobUrl.value = URL.createObjectURL(blob)
}
} catch (error) {
previewError.value = error.message || '预览加载失败。'
toast(previewError.value)
} finally {
previewLoading.value = false
}
}
async function handleDownload(document) {
try {
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
triggerFileDownload(blob, document.name)
} catch (error) {
toast(error.message || '下载失败。')
}
}
function triggerUpload() {
if (!isAdmin.value || uploading.value) {
return
}
uploadInput.value?.click()
}
async function uploadFiles(fileList) {
const files = Array.from(fileList || []).filter(Boolean)
if (!files.length || !activeFolder.value || !isAdmin.value) {
return
}
uploading.value = true
try {
let latestDocumentId = ''
for (const file of files) {
const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file })
latestDocumentId = payload.id
}
await loadLibrary({ preserveSelection: true })
toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。')
if (latestDocumentId) {
await selectDocument(latestDocumentId)
}
} catch (error) {
toast(error.message || '上传失败。')
} finally {
uploading.value = false
if (uploadInput.value) {
uploadInput.value.value = ''
}
}
}
async function handleFileInput(event) {
await uploadFiles(event.target.files)
}
async function handleDrop(event) {
if (!isAdmin.value) {
return
}
await uploadFiles(event.dataTransfer?.files)
}
async function handleDelete(document) {
if (!isAdmin.value || deletingId.value) {
return
}
const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`)
if (!confirmed) {
return
}
deletingId.value = document.id
try {
await deleteKnowledgeDocument(document.id)
if (selectedDocument.value?.id === document.id) {
selectedDocument.value = null
revokePreviewBlob()
}
await loadLibrary()
toast('知识库文件已删除。')
} catch (error) {
toast(error.message || '删除失败。')
} finally {
deletingId.value = ''
}
}
function changePageSize(size) {
pageSize.value = size
@@ -251,28 +283,79 @@ export default {
currentPage.value = 1
}
function closePreview() {
selectedDocument.value = null
previewError.value = ''
currentPreviewPageIndex.value = 0
revokePreviewBlob()
destroyOnlyOfficeEditor()
onlyOfficeError.value = ''
}
function selectPreviewPage(index) {
currentPreviewPageIndex.value = index
}
watch(filteredDocuments, () => {
currentPage.value = 1
pageSizeOpen.value = false
if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) {
closePreview()
}
})
watch(activeFolder, () => {
closePreview()
})
onMounted(() => {
loadLibrary()
})
onBeforeUnmount(() => {
revokePreviewBlob()
})
return {
folderSearch,
activeFolder,
selectedDocument,
folders,
documents,
filteredFolders,
filteredDocuments,
activePreviewPage,
changePageSize,
closePreview,
excelPreviewTable,
currentPage,
currentPreviewPageIndex,
deletingId,
documentSearch,
filteredFolders,
handleDelete,
handleDownload,
handleDrop,
handleFileInput,
isAdmin,
loading,
pageSize,
pageSizes,
pageSizeOpen,
pageSizes,
onlyOfficeError,
onlyOfficeHostId,
onlyOfficeLoading,
previewMetaLine,
previewSecondaryMetaLine,
previewBlobUrl,
previewError,
previewLoading,
shouldUseOnlyOffice,
selectDocument,
selectPreviewPage,
selectedDocument,
totalCount,
totalPages,
visibleDocuments,
changePageSize
triggerUpload,
uploadHint,
uploadInput,
uploading,
visibleDocuments
}
}
}

View File

@@ -0,0 +1,65 @@
function splitPreviewRow(line) {
return String(line || '')
.split('|')
.map((cell) => cell.trim())
}
export function buildPreviewMetaLine(document) {
if (!document) {
return []
}
return [document.summary, document.time].filter(Boolean)
}
export function buildPreviewSecondaryMetaLine(document, page = null) {
if (!document) {
return []
}
const activePage = page || (Array.isArray(document.previewPages) ? document.previewPages[0] : null)
if (!activePage) {
return []
}
const parts = []
if (activePage.subtitle) {
parts.push(activePage.subtitle)
}
if (document.previewKind === 'table') {
for (const item of activePage.stats || []) {
if (!item?.label || !item?.value || item.label === '文件大小') {
continue
}
parts.push(`${item.label} ${item.value}`)
}
}
return parts
}
export function buildExcelPreviewTable(page) {
const rawRows = (page?.blocks || [])
.flatMap((block) => block.lines || [])
.map(splitPreviewRow)
.filter((row) => row.length > 0 && row.some((cell) => cell !== ''))
if (!rawRows.length) {
return { headers: [], rows: [] }
}
const columnCount = rawRows.reduce((max, row) => Math.max(max, row.length), 0)
const normalizedRows = rawRows.map((row) =>
Array.from({ length: columnCount }, (_, index) => row[index] ?? '')
)
const [headerRow, ...bodyRows] = normalizedRows
const headers = headerRow.map((cell, index) => cell || `${index + 1}`)
return {
headers,
rows: bodyRows
}
}

View File

@@ -0,0 +1,99 @@
import assert from 'node:assert/strict'
import { apiRequest } from '../src/services/api.js'
async function testUsesCustomContentTypeHeader() {
let capturedOptions = null
global.fetch = async (_url, options) => {
capturedOptions = options
return {
ok: true,
async json() {
return { ok: true }
}
}
}
await apiRequest('/knowledge/documents', {
method: 'POST',
body: 'payload',
contentType: 'application/octet-stream'
})
assert.equal(capturedOptions.headers['Content-Type'], 'application/octet-stream')
}
async function testSupportsBlobResponses() {
const blob = new Blob(['preview'])
global.fetch = async () => ({
ok: true,
async blob() {
return blob
},
async json() {
throw new Error('json parser should not be used for blob responses')
}
})
const payload = await apiRequest('/knowledge/documents/demo/content', {
responseType: 'blob',
contentType: null
})
assert.equal(payload, blob)
}
async function testInjectsAuthenticatedUserHeaders() {
const sessionStorage = new Map([
[
'x-financial-auth-user',
JSON.stringify({
username: 'admin',
name: '系统管理员',
roleCodes: ['manager'],
isAdmin: true
})
]
])
global.window = {
sessionStorage: {
getItem(key) {
return sessionStorage.get(key) ?? null
}
}
}
let capturedOptions = null
global.fetch = async (_url, options) => {
capturedOptions = options
return {
ok: true,
async json() {
return { ok: true }
}
}
}
await apiRequest('/knowledge/library')
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
assert.equal(capturedOptions.headers['x-auth-name'], '系统管理员')
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
}
async function run() {
await testUsesCustomContentTypeHeader()
await testSupportsBlobResponses()
await testInjectsAuthenticatedUserHeaders()
console.log('api-request tests passed')
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -0,0 +1,13 @@
import assert from 'node:assert/strict'
import { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js'
function run() {
assert.equal(
buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'),
'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js'
)
console.log('onlyoffice service tests passed')
}
run()

View File

@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import {
buildExcelPreviewTable,
buildPreviewMetaLine,
buildPreviewSecondaryMetaLine
} from '../src/views/scripts/policiesPreviewFormatters.js'
function testBuildPreviewMetaLineUsesRealDocumentFields() {
const document = {
summary: '财务知识库 · XLSX · 10.9 KB',
time: '2026-05-09 12:30'
}
assert.deepEqual(buildPreviewMetaLine(document), ['财务知识库 · XLSX · 10.9 KB', '2026-05-09 12:30'])
}
function testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() {
const document = {
previewKind: 'table',
previewPages: [
{
subtitle: '表格内容预览',
stats: [
{ label: '工作表数量', value: '4' },
{ label: '预览行数', value: '7' },
{ label: '文件大小', value: '10.9 KB' }
]
},
{
subtitle: '第二页签预览',
stats: [
{ label: '工作表数量', value: '4' },
{ label: '预览行数', value: '3' }
]
}
]
}
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[0]), ['表格内容预览', '工作表数量 4', '预览行数 7'])
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[1]), ['第二页签预览', '工作表数量 4', '预览行数 3'])
}
function testBuildExcelPreviewTableParsesHeaderAndRows() {
const page = {
blocks: [
{ heading: '第 1 行', lines: ['日期 | 部门 | 金额 | 备注'] },
{ heading: '第 2 行', lines: ['2026-05-01 | 财务部 | 300 | 差旅'] },
{ heading: '第 3 行', lines: ['2026-05-02 | 行政部 | 120 | '] }
]
}
assert.deepEqual(buildExcelPreviewTable(page), {
headers: ['日期', '部门', '金额', '备注'],
rows: [
['2026-05-01', '财务部', '300', '差旅'],
['2026-05-02', '行政部', '120', '']
]
})
}
function run() {
testBuildPreviewMetaLineUsesRealDocumentFields()
testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats()
testBuildExcelPreviewTableParsesHeaderAndRows()
console.log('policies preview formatter tests passed')
}
run()