feat: 完善知识库、策略预览与OnlyOffice集成,增强后端启动依赖检查
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'}` }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
web/src/views/scripts/knowledgePreviewMode.js
Normal file
21
web/src/views/scripts/knowledgePreviewMode.js
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
39
web/tests/knowledge-preview-mode.test.mjs
Normal file
39
web/tests/knowledge-preview-mode.test.mjs
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
1970
web/vite.config.js
1970
web/vite.config.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user