feat: 完善知识库、策略预览与OnlyOffice集成,增强后端启动依赖检查

This commit is contained in:
caoxiaozhu
2026-05-09 05:59:46 +00:00
parent 1d3ac5c2e0
commit d9133193e8
31 changed files with 5534 additions and 5343 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,157 +1,157 @@
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(/\/$/, '')
}
function isLoopbackHost(hostname) {
const normalized = String(hostname || '').trim().toLowerCase()
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1'
}
function resolveBrowserReachableApiBaseUrl(value) {
const normalized = normalizeApiBaseUrl(value)
if (typeof window === 'undefined') {
return normalized
}
try {
const apiUrl = new URL(normalized)
const browserHost = window.location.hostname
if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) {
apiUrl.hostname = browserHost
return normalizeApiBaseUrl(apiUrl.toString())
}
} catch {
return normalized
}
return normalized
}
function readStoredApiBaseUrl() {
if (typeof window === 'undefined') {
return ''
}
return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '')
}
let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1')
if (typeof window !== 'undefined') {
window.localStorage.removeItem(API_BASE_STORAGE_KEY)
}
export function setRuntimeApiBaseUrl(value) {
runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value)
if (typeof window !== 'undefined') {
window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl)
}
}
export function getRuntimeApiBaseUrl() {
return runtimeApiBaseUrl
}
function buildUrl(path) {
if (!path.startsWith('/')) {
return `${runtimeApiBaseUrl}/${path}`
}
return `${runtimeApiBaseUrl}${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), {
...fetchOptions,
headers
})
} catch {
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
}
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 {
payload = null
}
if (!response.ok) {
throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
}
return payload
}
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(/\/$/, '')
}
function isLoopbackHost(hostname) {
const normalized = String(hostname || '').trim().toLowerCase()
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '0.0.0.0' || normalized === '::1'
}
function resolveBrowserReachableApiBaseUrl(value) {
const normalized = normalizeApiBaseUrl(value)
if (typeof window === 'undefined') {
return normalized
}
try {
const apiUrl = new URL(normalized)
const browserHost = window.location.hostname
if (isLoopbackHost(apiUrl.hostname) && browserHost && !isLoopbackHost(browserHost)) {
apiUrl.hostname = browserHost
return normalizeApiBaseUrl(apiUrl.toString())
}
} catch {
return normalized
}
return normalized
}
function readStoredApiBaseUrl() {
if (typeof window === 'undefined') {
return ''
}
return resolveBrowserReachableApiBaseUrl(window.localStorage.getItem(API_BASE_STORAGE_KEY) || '')
}
let runtimeApiBaseUrl = normalizeApiBaseUrl('/api/v1')
if (typeof window !== 'undefined') {
window.localStorage.removeItem(API_BASE_STORAGE_KEY)
}
export function setRuntimeApiBaseUrl(value) {
runtimeApiBaseUrl = resolveBrowserReachableApiBaseUrl(value)
if (typeof window !== 'undefined') {
window.localStorage.setItem(API_BASE_STORAGE_KEY, runtimeApiBaseUrl)
}
}
export function getRuntimeApiBaseUrl() {
return runtimeApiBaseUrl
}
function buildUrl(path) {
if (!path.startsWith('/')) {
return `${runtimeApiBaseUrl}/${path}`
}
return `${runtimeApiBaseUrl}${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), {
...fetchOptions,
headers
})
} catch {
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
}
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 {
payload = null
}
if (!response.ok) {
throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
}
return payload
}

View File

@@ -1,37 +1,37 @@
import { apiRequest } from './api.js'
export function fetchKnowledgeLibrary() {
return apiRequest('/knowledge/library')
}
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)}`,
{
method: 'POST',
body: file,
contentType: file.type || 'application/octet-stream'
}
)
}
export function deleteKnowledgeDocument(documentId) {
return apiRequest(`/knowledge/documents/${documentId}`, {
method: 'DELETE'
})
}
export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') {
return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, {
responseType: 'blob',
contentType: null
})
}
import { apiRequest } from './api.js'
export function fetchKnowledgeLibrary() {
return apiRequest('/knowledge/library')
}
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)}`,
{
method: 'POST',
body: file,
contentType: file.type || 'application/octet-stream'
}
)
}
export function deleteKnowledgeDocument(documentId) {
return apiRequest(`/knowledge/documents/${documentId}`, {
method: 'DELETE'
})
}
export function fetchKnowledgeDocumentBlob(documentId, disposition = 'inline') {
return apiRequest(`/knowledge/documents/${documentId}/content?disposition=${disposition}`, {
responseType: 'blob',
contentType: null
})
}

View File

@@ -1,43 +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
}
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

@@ -1,63 +1,63 @@
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'requests',
'approval',
'chat',
'policies',
'audit',
'employees',
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver'],
audit: ['auditor'],
employees: ['manager'],
settings: ['manager']
}
function normalizedRoleCodes(user) {
if (!user) {
return []
}
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
}
export function isManagerUser(user) {
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (isManagerUser(user)) {
return true
}
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
return true
}
const requiredRoles = VIEW_ROLE_RULES[viewId] || []
const roleCodes = normalizedRoleCodes(user)
return requiredRoles.some((roleCode) => roleCodes.includes(roleCode))
}
export function getAccessibleViewIds(user) {
return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId))
}
export function filterNavItemsByAccess(navItems, user) {
return navItems.filter((item) => canAccessAppView(user, item.id))
}
export function resolveDefaultAuthorizedRoute(user) {
const firstVisibleView = getAccessibleViewIds(user)[0]
return { name: `app-${firstVisibleView || 'workbench'}` }
}
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'requests',
'approval',
'chat',
'policies',
'audit',
'employees',
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver'],
audit: ['auditor'],
employees: ['manager'],
settings: ['manager']
}
function normalizedRoleCodes(user) {
if (!user) {
return []
}
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
}
export function isManagerUser(user) {
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (isManagerUser(user)) {
return true
}
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
return true
}
const requiredRoles = VIEW_ROLE_RULES[viewId] || []
const roleCodes = normalizedRoleCodes(user)
return requiredRoles.some((roleCode) => roleCodes.includes(roleCode))
}
export function getAccessibleViewIds(user) {
return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId))
}
export function filterNavItemsByAccess(navItems, user) {
return navItems.filter((item) => canAccessAppView(user, item.id))
}
export function resolveDefaultAuthorizedRoute(user) {
const firstVisibleView = getAccessibleViewIds(user)[0]
return { name: `app-${firstVisibleView || 'workbench'}` }
}

View File

@@ -1,200 +1,200 @@
<template>
<div class="app">
<SidebarRail
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="companyProfile.name"
:current-user="currentUser"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
@logout="handleLogout"
/>
<main
class="main"
:class="{
'chat-main': activeView === 'chat',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees',
'settings-main': activeView === 'settings'
}"
>
<TopBar
v-if="activeView !== 'settings'"
:current-view="topBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:employee-summary="employeeSummary"
:knowledge-summary="knowledgeSummary"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@open-chat="handleOpenChat"
@new-application="openTravelCreate"
/>
<FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'chat-workarea': activeView === 'chat',
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees',
'settings-workarea': activeView === 'settings'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="handleOpenChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
@open-assistant="openSmartEntry"
/>
<ChatView
v-else-if="activeView === 'chat'"
:documents="filteredDocuments"
:doc-search="docSearch"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="travelPrompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@select-case="handleOpenChat"
@approve-case="toast(`${activeCase?.id || '当前单据'} 已标记为通过`)"
@reject-case="toast(`${activeCase?.id || '当前单据'} 已标记为驳回`)"
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode && selectedTravelRequest"
:request="selectedTravelRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<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 />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
@close="closeSmartEntry"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import ChatView from './ChatView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import SettingsView from './SettingsView.vue'
import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess } from '../utils/accessControl.js'
const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
const {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleNavigate,
handleOpenChat,
handleReject,
handleUpload,
messageList,
messages,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
search,
selectedTravelRequest,
sendMessage,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
topBarView,
travelPrompts,
uploadedFiles
} = useAppShell()
const { companyProfile, currentUser, logout } = useSystemState()
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
function handleLogout() {
logout('manual')
}
</script>
<template>
<div class="app">
<SidebarRail
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="companyProfile.name"
:current-user="currentUser"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
@logout="handleLogout"
/>
<main
class="main"
:class="{
'chat-main': activeView === 'chat',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees',
'settings-main': activeView === 'settings'
}"
>
<TopBar
v-if="activeView !== 'settings'"
:current-view="topBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:employee-summary="employeeSummary"
:knowledge-summary="knowledgeSummary"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@open-chat="handleOpenChat"
@new-application="openTravelCreate"
/>
<FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'chat-workarea': activeView === 'chat',
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees',
'settings-workarea': activeView === 'settings'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="handleOpenChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
@open-assistant="openSmartEntry"
/>
<ChatView
v-else-if="activeView === 'chat'"
:documents="filteredDocuments"
:doc-search="docSearch"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="travelPrompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@select-case="handleOpenChat"
@approve-case="toast(`${activeCase?.id || '当前单据'} 已标记为通过`)"
@reject-case="toast(`${activeCase?.id || '当前单据'} 已标记为驳回`)"
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode && selectedTravelRequest"
:request="selectedTravelRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<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 />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
@close="closeSmartEntry"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import ChatView from './ChatView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import SettingsView from './SettingsView.vue'
import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess } from '../utils/accessControl.js'
const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
const {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleNavigate,
handleOpenChat,
handleReject,
handleUpload,
messageList,
messages,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
search,
selectedTravelRequest,
sendMessage,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
topBarView,
travelPrompts,
uploadedFiles
} = useAppShell()
const { companyProfile, currentUser, logout } = useSystemState()
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
function handleLogout() {
logout('manual')
}
</script>

View File

@@ -1,199 +1,199 @@
<template>
<section class="knowledge-page">
<div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }">
<section class="knowledge-main">
<article class="library-panel panel">
<header class="panel-title">
<div>
<h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后可在右侧展开预览</p>
</div>
<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">
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="folder in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
<i :class="folder.icon"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
<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"
: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>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<table>
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
<th>版本</th>
<th>状态</th>
<th>上传人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="doc in visibleDocuments"
:key="doc.id"
class="doc-row"
:class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)"
>
<td>
<span class="file-name">
<i :class="doc.icon"></i>
{{ doc.name }}
</span>
</td>
<td>
<span class="doc-tag">{{ doc.tag }}</span>
</td>
<td>{{ doc.time }}</td>
<td>{{ doc.version }}</td>
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
<td>{{ doc.owner }}</td>
<td>
<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>
</table>
</div>
<footer class="list-foot">
<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--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} /<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button
v-for="size in pageSizes"
:key="size"
type="button"
role="option"
:aria-selected="pageSize === size"
:class="{ active: pageSize === size }"
@click="changePageSize(size)"
>
{{ size }} /
</button>
</div>
</div>
</footer>
</section>
</div>
</article>
</section>
<Transition name="preview-panel">
<aside v-if="selectedDocument" class="preview-column">
<article class="preview-panel panel">
<header class="preview-head">
<div class="preview-copy">
<h2>{{ selectedDocument.name }}</h2>
<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" @click="handleDownload(selectedDocument)">
<i class="mdi mdi-download"></i>
<span>下载</span>
</button>
<button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="preview-viewer">
<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">
<template>
<section class="knowledge-page">
<div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }">
<section class="knowledge-main">
<article class="library-panel panel">
<header class="panel-title">
<div>
<h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后可在右侧展开预览</p>
</div>
<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">
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="folder in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
<i :class="folder.icon"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
<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"
: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>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<table>
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
<th>版本</th>
<th>状态</th>
<th>上传人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="doc in visibleDocuments"
:key="doc.id"
class="doc-row"
:class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)"
>
<td>
<span class="file-name">
<i :class="doc.icon"></i>
{{ doc.name }}
</span>
</td>
<td>
<span class="doc-tag">{{ doc.tag }}</span>
</td>
<td>{{ doc.time }}</td>
<td>{{ doc.version }}</td>
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
<td>{{ doc.owner }}</td>
<td>
<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>
</table>
</div>
<footer class="list-foot">
<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--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} /<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button
v-for="size in pageSizes"
:key="size"
type="button"
role="option"
:aria-selected="pageSize === size"
:class="{ active: pageSize === size }"
@click="changePageSize(size)"
>
{{ size }} /
</button>
</div>
</div>
</footer>
</section>
</div>
</article>
</section>
<Transition name="preview-panel">
<aside v-if="selectedDocument" class="preview-column">
<article class="preview-panel panel">
<header class="preview-head">
<div class="preview-copy">
<h2>{{ selectedDocument.name }}</h2>
<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" @click="handleDownload(selectedDocument)">
<i class="mdi mdi-download"></i>
<span>下载</span>
</button>
<button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="preview-viewer">
<div v-if="previewLoading" class="preview-status">正在加载预览...</div>
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
<div v-else-if="previewMode === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
<iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe>
</div>
<div v-else-if="selectedDocument.previewKind === 'image' && previewBlobUrl" class="preview-image-wrap">
<div v-else-if="previewMode === '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">
@@ -201,64 +201,64 @@
<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-else-if="previewMode === '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.id}-${index}`"
class="page-sheet"
:style="{ '--page-delay': `${index * 70}ms` }"
>
<section class="page-content">
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
<h3>{{ block.heading }}</h3>
<ul>
<li v-for="line in block.lines" :key="line">{{ line }}</li>
</ul>
</div>
</section>
</article>
<div v-if="!selectedDocument.previewPages.length" class="preview-status">
当前文件暂未生成结构化预览请下载后查看
</div>
</div>
</div>
</article>
</aside>
</Transition>
</div>
</section>
</template>
<script src="./scripts/PoliciesView.js"></script>
<style scoped src="../assets/styles/views/policies-view.css"></style>
: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.id}-${index}`"
class="page-sheet"
:style="{ '--page-delay': `${index * 70}ms` }"
>
<section class="page-content">
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
<h3>{{ block.heading }}</h3>
<ul>
<li v-for="line in block.lines" :key="line">{{ line }}</li>
</ul>
</div>
</section>
</article>
<div v-if="!selectedDocument.previewPages.length" class="preview-status">
当前文件暂未生成结构化预览请下载后查看
</div>
</div>
</div>
</article>
</aside>
</Transition>
</div>
</section>
</template>
<script src="./scripts/PoliciesView.js"></script>
<style scoped src="../assets/styles/views/policies-view.css"></style>

View File

@@ -1,177 +1,183 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, 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 { computed, nextTick, onBeforeUnmount, onMounted, ref, 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'
import { canUseOnlyOfficePreview, resolveKnowledgePreviewMode } from './knowledgePreviewMode.js'
function triggerFileDownload(blob, filename) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
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',
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 pageSizeOpen = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const loading = ref(false)
const uploadInput = ref(null)
const uploading = ref(false)
const deletingId = ref('')
const previewLoading = ref(false)
export default {
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 pageSizeOpen = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
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 onlyOfficeAvailable = ref(false)
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 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 previewMode = computed(() =>
resolveKnowledgePreviewMode(selectedDocument.value, {
onlyOfficeAvailable: onlyOfficeAvailable.value
})
})
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 shouldUseOnlyOffice = computed(() => previewMode.value === 'onlyoffice')
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
}
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 = ''
onlyOfficeAvailable.value = false
destroyOnlyOfficeEditor()
try {
const payload = await fetchKnowledgeOnlyOfficeConfig(documentId)
await loadOnlyOfficeApi(payload.documentServerUrl)
await nextTick()
if (!window.DocsAPI?.DocEditor) {
throw new Error('ONLYOFFICE 编辑器未正确加载。')
}
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)
onlyOfficeAvailable.value = true
return true
} catch (error) {
onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。'
return false
} 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
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 = ''
onlyOfficeAvailable.value = false
revokePreviewBlob()
destroyOnlyOfficeEditor()
@@ -180,182 +186,187 @@ export default {
selectedDocument.value = payload
currentPreviewPageIndex.value = 0
if (supportsOnlyOfficePreview(payload)) {
if (canUseOnlyOfficePreview(payload)) {
await mountOnlyOfficeEditor(documentId)
} else if (payload.previewKind === 'pdf' || payload.previewKind === 'image') {
}
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
pageSizeOpen.value = false
currentPage.value = 1
}
function closePreview() {
selectedDocument.value = null
previewError.value = ''
currentPreviewPageIndex.value = 0
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
pageSizeOpen.value = false
currentPage.value = 1
}
function closePreview() {
selectedDocument.value = null
previewError.value = ''
currentPreviewPageIndex.value = 0
revokePreviewBlob()
destroyOnlyOfficeEditor()
onlyOfficeError.value = ''
onlyOfficeAvailable.value = false
}
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()
})
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()
destroyOnlyOfficeEditor()
})
return {
activeFolder,
activePreviewPage,
changePageSize,
closePreview,
excelPreviewTable,
currentPage,
currentPreviewPageIndex,
deletingId,
documentSearch,
filteredFolders,
handleDelete,
handleDownload,
handleDrop,
handleFileInput,
isAdmin,
loading,
pageSize,
return {
activeFolder,
activePreviewPage,
changePageSize,
closePreview,
excelPreviewTable,
currentPage,
currentPreviewPageIndex,
deletingId,
documentSearch,
filteredFolders,
handleDelete,
handleDownload,
handleDrop,
handleFileInput,
isAdmin,
loading,
pageSize,
pageSizeOpen,
pageSizes,
onlyOfficeError,
onlyOfficeHostId,
onlyOfficeLoading,
previewMode,
previewMetaLine,
previewSecondaryMetaLine,
previewBlobUrl,
previewError,
previewLoading,
shouldUseOnlyOffice,
selectDocument,
selectPreviewPage,
selectedDocument,
totalCount,
totalPages,
triggerUpload,
uploadHint,
uploadInput,
uploading,
visibleDocuments
}
}
}
previewError,
previewLoading,
shouldUseOnlyOffice,
selectDocument,
selectPreviewPage,
selectedDocument,
totalCount,
totalPages,
triggerUpload,
uploadHint,
uploadInput,
uploading,
visibleDocuments
}
}
}

View File

@@ -0,0 +1,21 @@
const ONLYOFFICE_EXTENSIONS = new Set(['docx', 'xlsx', 'pptx'])
function supportsOnlyOfficePreview(document) {
return ONLYOFFICE_EXTENSIONS.has(String(document?.extension || '').toLowerCase())
}
export function resolveKnowledgePreviewMode(document, options = {}) {
if (!document) {
return 'none'
}
if (supportsOnlyOfficePreview(document) && options.onlyOfficeAvailable) {
return 'onlyoffice'
}
return document.previewKind || 'unsupported'
}
export function canUseOnlyOfficePreview(document) {
return supportsOnlyOfficePreview(document)
}

View File

@@ -1,65 +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
}
}
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

@@ -1,99 +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)
})
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,39 @@
import assert from 'node:assert/strict'
import { resolveKnowledgePreviewMode } from '../src/views/scripts/knowledgePreviewMode.js'
function testPrefersOnlyOfficeForSupportedOfficeFileWhenAvailable() {
const document = {
extension: 'xlsx',
previewKind: 'table'
}
assert.equal(resolveKnowledgePreviewMode(document, { onlyOfficeAvailable: true }), 'onlyoffice')
}
function testFallsBackToStructuredPreviewForOfficeFileWhenOnlyOfficeUnavailable() {
const document = {
extension: 'xlsx',
previewKind: 'table'
}
assert.equal(resolveKnowledgePreviewMode(document, { onlyOfficeAvailable: false }), 'table')
}
function testUsesPreviewKindForNonOnlyOfficeFile() {
const document = {
extension: 'pdf',
previewKind: 'pdf'
}
assert.equal(resolveKnowledgePreviewMode(document, { onlyOfficeAvailable: false }), 'pdf')
}
function run() {
testPrefersOnlyOfficeForSupportedOfficeFileWhenAvailable()
testFallsBackToStructuredPreviewForOfficeFileWhenOnlyOfficeUnavailable()
testUsesPreviewKindForNonOnlyOfficeFile()
console.log('knowledge preview mode tests passed')
}
run()

View File

@@ -1,13 +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()
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

@@ -1,69 +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()
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()

File diff suppressed because it is too large Load Diff