feat: 完善知识库、策略预览与OnlyOffice集成
## 配置与环境 - .env.example: 更新环境变量配置 - docker-compose.yml: 完善Docker编排配置 - docker/README.md: 更新Docker文档 ## 后端知识库模块 - endpoints/knowledge.py: 增强知识库API端点 - schemas/knowledge.py: 扩展知识库数据模型 - services/knowledge.py: 完善知识库业务逻辑 - config.py: 优化配置管理 - storage/knowledge/.index.json: 更新知识库索引 ## 前端功能 - api.js: 完善API服务层 - knowledge.js: 优化知识库服务 - onlyoffice.js: 新增OnlyOffice文档服务集成 - TopBar.vue: 优化顶部导航栏 - PoliciesView.vue: 完善策略视图 - AppShellRouteView.vue: 新增应用外壳路由视图 - views/scripts/PoliciesView.js: 优化策略脚本 - policiesPreviewFormatters.js: 新增策略预览格式化工具 ## 样式 - policies-view.css: 完善策略页样式 ## 测试 - api-request.test.mjs: API请求测试 - onlyoffice-service.test.mjs: OnlyOffice服务测试 - policies-preview-formatters.test.mjs: 策略预览格式化测试
This commit is contained in:
@@ -26,6 +26,10 @@ SERVER_STARTUP_TIMEOUT=300
|
||||
SERVER_BLOCKING_STARTUP_TIMEOUT=12
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
|
||||
ONLYOFFICE_ENABLED=false
|
||||
ONLYOFFICE_PUBLIC_URL=http://127.0.0.1:8082
|
||||
ONLYOFFICE_BACKEND_URL=http://127.0.0.1:8000
|
||||
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
|
||||
|
||||
POSTGRES_HOST=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
@@ -4,12 +4,18 @@ services:
|
||||
container_name: x-financial-main
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
onlyoffice:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
WEB_HOST: 0.0.0.0
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
||||
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-true}"
|
||||
ONLYOFFICE_PUBLIC_URL: "${ONLYOFFICE_PUBLIC_URL:-http://127.0.0.1:${ONLYOFFICE_PORT:-8082}}"
|
||||
ONLYOFFICE_BACKEND_URL: "${ONLYOFFICE_BACKEND_URL:-http://main:${SERVER_PORT:-8000}}"
|
||||
ONLYOFFICE_JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
||||
ports:
|
||||
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
|
||||
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
|
||||
@@ -33,6 +39,22 @@ services:
|
||||
retries: 10
|
||||
start_period: 180s
|
||||
|
||||
onlyoffice:
|
||||
image: onlyoffice/documentserver:latest
|
||||
container_name: x-financial-onlyoffice
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
JWT_ENABLED: "true"
|
||||
JWT_SECRET: "${ONLYOFFICE_JWT_SECRET:-x-financial-onlyoffice-dev-secret}"
|
||||
ports:
|
||||
- "${ONLYOFFICE_PORT:-8082}:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck >/dev/null || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: x-financial-postgres
|
||||
|
||||
@@ -20,6 +20,7 @@ http://<your-linux-host>:5173
|
||||
## Container Layout
|
||||
|
||||
- `main`: web + FastAPI main container
|
||||
- `onlyoffice`: ONLYOFFICE Document Server
|
||||
- `postgres`: PostgreSQL database container
|
||||
|
||||
The project root is mounted directly into the main container:
|
||||
@@ -51,6 +52,7 @@ The PostgreSQL data directory is stored in the named volume `postgres_data`.
|
||||
- `POSTGRES_PORT=5432`
|
||||
- `DATABASE_URL=...@postgres:...`
|
||||
- PostgreSQL is also published to the host by default as `127.0.0.1:55432`.
|
||||
- ONLYOFFICE is published to the host by default as `127.0.0.1:8082`.
|
||||
- First boot with `SETUP_COMPLETED=false` starts the setup UI only.
|
||||
- After you complete setup in the browser, the Vite setup bridge will start FastAPI in the
|
||||
same container using the saved runtime configuration.
|
||||
@@ -59,5 +61,7 @@ The PostgreSQL data directory is stored in the named volume `postgres_data`.
|
||||
- If you access the system from another machine, make sure `CORS_ORIGINS` in `.env` includes
|
||||
the frontend address you actually use.
|
||||
- For Navicat or any host-side client, use `127.0.0.1:55432`.
|
||||
- If you need to access ONLYOFFICE from another machine, override `ONLYOFFICE_PUBLIC_URL`
|
||||
so the browser can reach the document server address you actually expose.
|
||||
- For the setup page, using `127.0.0.1` is acceptable in this Docker layout; the internal
|
||||
test bridge will resolve that back to the Docker PostgreSQL service.
|
||||
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"sqlalchemy>=2.0.36,<3.0.0",
|
||||
"alembic>=1.14.0,<2.0.0",
|
||||
"psycopg[binary]>=3.2.0,<4.0.0",
|
||||
"PyJWT>=2.9.0,<3.0.0",
|
||||
"pydantic-settings>=2.6.0,<3.0.0",
|
||||
"python-dotenv>=1.0.1,<2.0.0",
|
||||
"email-validator>=2.2.0,<3.0.0",
|
||||
|
||||
@@ -10,6 +10,8 @@ from app.schemas.knowledge import (
|
||||
KnowledgeActionResponse,
|
||||
KnowledgeDocumentDetailRead,
|
||||
KnowledgeLibraryRead,
|
||||
KnowledgeOnlyOfficeCallbackRead,
|
||||
KnowledgeOnlyOfficeConfigRead,
|
||||
)
|
||||
from app.services.knowledge import KnowledgeService
|
||||
|
||||
@@ -34,6 +36,19 @@ def get_knowledge_document(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead)
|
||||
def get_knowledge_document_onlyoffice_config(
|
||||
document_id: str,
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> KnowledgeOnlyOfficeConfigRead:
|
||||
try:
|
||||
return KnowledgeService().build_onlyoffice_config(document_id, current_user)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_knowledge_document(
|
||||
request: Request,
|
||||
@@ -74,3 +89,36 @@ def get_knowledge_document_content(
|
||||
|
||||
_ = disposition
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/onlyoffice/content")
|
||||
def get_knowledge_document_onlyoffice_content(
|
||||
document_id: str,
|
||||
access_token: Annotated[str, Query(min_length=1)],
|
||||
) -> FileResponse:
|
||||
try:
|
||||
service = KnowledgeService()
|
||||
service.validate_onlyoffice_access_token(document_id, access_token)
|
||||
file_path, media_type, filename = service.get_document_content(document_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.post("/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead)
|
||||
async def handle_knowledge_document_onlyoffice_callback(
|
||||
document_id: str,
|
||||
request: Request,
|
||||
) -> KnowledgeOnlyOfficeCallbackRead:
|
||||
payload = await request.json()
|
||||
try:
|
||||
KnowledgeService().handle_onlyoffice_callback(document_id, payload)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
return KnowledgeOnlyOfficeCallbackRead()
|
||||
|
||||
@@ -47,6 +47,10 @@ class Settings(BaseSettings):
|
||||
vite_api_base_url: str = Field(
|
||||
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
|
||||
)
|
||||
onlyoffice_enabled: bool = Field(default=False, alias="ONLYOFFICE_ENABLED")
|
||||
onlyoffice_public_url: str = Field(default="", alias="ONLYOFFICE_PUBLIC_URL")
|
||||
onlyoffice_backend_url: str = Field(default="", alias="ONLYOFFICE_BACKEND_URL")
|
||||
onlyoffice_jwt_secret: str = Field(default="", alias="ONLYOFFICE_JWT_SECRET")
|
||||
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
log_dir: str = Field(default="logs", alias="LOG_DIR")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -51,6 +53,15 @@ class KnowledgeDocumentDetailRead(KnowledgeDocumentRead):
|
||||
previewPages: list[KnowledgePreviewPageRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeOnlyOfficeConfigRead(BaseModel):
|
||||
documentServerUrl: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KnowledgeOnlyOfficeCallbackRead(BaseModel):
|
||||
error: int = 0
|
||||
|
||||
|
||||
class KnowledgeLibraryRead(BaseModel):
|
||||
folders: list[KnowledgeFolderRead] = Field(default_factory=list)
|
||||
documents: list[KnowledgeDocumentRead] = Field(default_factory=list)
|
||||
|
||||
@@ -4,13 +4,17 @@ import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.request import Request, urlopen
|
||||
from uuid import uuid4
|
||||
from xml.etree import ElementTree
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
import jwt
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
@@ -19,6 +23,7 @@ from app.schemas.knowledge import (
|
||||
KnowledgeDocumentRead,
|
||||
KnowledgeFolderRead,
|
||||
KnowledgeLibraryRead,
|
||||
KnowledgeOnlyOfficeConfigRead,
|
||||
KnowledgePreviewBlockRead,
|
||||
KnowledgePreviewPageRead,
|
||||
KnowledgePreviewStatRead,
|
||||
@@ -58,6 +63,14 @@ IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"}
|
||||
ARCHIVE_EXTENSIONS = {"zip", "rar", "7z"}
|
||||
STRUCTURED_PREVIEW_EXTENSIONS = {"docx", "xlsx", "pptx"} | TEXT_EXTENSIONS
|
||||
INLINE_PREVIEW_EXTENSIONS = {"pdf"} | IMAGE_EXTENSIONS
|
||||
ONLYOFFICE_EDITABLE_EXTENSIONS = {"docx", "xlsx", "pptx"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OnlyOfficeCallbackPayload:
|
||||
status: int
|
||||
download_url: str
|
||||
users: list[str]
|
||||
|
||||
|
||||
def prepare_knowledge_library() -> None:
|
||||
@@ -219,6 +232,114 @@ class KnowledgeService:
|
||||
|
||||
return file_path, entry["mime_type"], entry["original_name"]
|
||||
|
||||
def build_onlyoffice_config(
|
||||
self,
|
||||
document_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> KnowledgeOnlyOfficeConfigRead:
|
||||
self.ensure_library_ready()
|
||||
settings = get_settings()
|
||||
if not settings.onlyoffice_enabled:
|
||||
raise ValueError("ONLYOFFICE 预览未启用。")
|
||||
if not settings.onlyoffice_public_url or not settings.onlyoffice_backend_url:
|
||||
raise ValueError("ONLYOFFICE 地址配置不完整。")
|
||||
if not settings.onlyoffice_jwt_secret:
|
||||
raise ValueError("ONLYOFFICE JWT 密钥未配置。")
|
||||
|
||||
index = self._load_index()
|
||||
entry = self._require_entry(index, document_id)
|
||||
extension = self._extract_extension(entry["original_name"])
|
||||
if extension not in ONLYOFFICE_EDITABLE_EXTENSIONS:
|
||||
raise ValueError("当前文件格式不支持 ONLYOFFICE 预览。")
|
||||
|
||||
document_type = self._resolve_onlyoffice_document_type(extension)
|
||||
backend_base_url = settings.onlyoffice_backend_url.rstrip("/")
|
||||
public_url = settings.onlyoffice_public_url.rstrip("/")
|
||||
access_token = self._build_onlyoffice_access_token(document_id)
|
||||
document_url = (
|
||||
f"{backend_base_url}{settings.api_v1_prefix}/knowledge/documents/{document_id}/onlyoffice/content"
|
||||
f"?access_token={access_token}"
|
||||
)
|
||||
callback_url = (
|
||||
f"{backend_base_url}{settings.api_v1_prefix}/knowledge/documents/{document_id}/onlyoffice/callback"
|
||||
)
|
||||
can_edit = current_user.is_admin or "manager" in current_user.role_codes
|
||||
document_key = self._build_onlyoffice_document_key(entry)
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"documentType": document_type,
|
||||
"document": {
|
||||
"fileType": extension,
|
||||
"key": document_key,
|
||||
"title": entry["original_name"],
|
||||
"url": document_url,
|
||||
"permissions": {
|
||||
"download": True,
|
||||
"edit": can_edit,
|
||||
"print": True,
|
||||
"copy": True,
|
||||
},
|
||||
},
|
||||
"editorConfig": {
|
||||
"mode": "edit" if can_edit else "view",
|
||||
"lang": "zh-CN",
|
||||
"callbackUrl": callback_url,
|
||||
"user": {
|
||||
"id": current_user.username,
|
||||
"name": current_user.name,
|
||||
},
|
||||
"customization": {
|
||||
"compactHeader": True,
|
||||
"compactToolbar": True,
|
||||
"toolbarNoTabs": False,
|
||||
"autosave": can_edit,
|
||||
"forcesave": can_edit,
|
||||
},
|
||||
},
|
||||
"width": "100%",
|
||||
"height": "100%",
|
||||
}
|
||||
config["token"] = jwt.encode(config, settings.onlyoffice_jwt_secret, algorithm="HS256")
|
||||
|
||||
return KnowledgeOnlyOfficeConfigRead(
|
||||
documentServerUrl=public_url,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def validate_onlyoffice_access_token(self, document_id: str, access_token: str) -> None:
|
||||
settings = get_settings()
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
access_token,
|
||||
settings.onlyoffice_jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise ValueError("ONLYOFFICE 文件访问令牌无效。") from exc
|
||||
|
||||
if payload.get("scope") != "onlyoffice-content" or payload.get("document_id") != document_id:
|
||||
raise ValueError("ONLYOFFICE 文件访问令牌无效。")
|
||||
|
||||
def handle_onlyoffice_callback(self, document_id: str, payload: dict[str, Any]) -> None:
|
||||
self.ensure_library_ready()
|
||||
callback = self._parse_onlyoffice_callback(payload)
|
||||
if callback.status not in {2, 6} or not callback.download_url:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"ONLYOFFICE callback received id=%s status=%s users=%s",
|
||||
document_id,
|
||||
callback.status,
|
||||
",".join(callback.users) if callback.users else "-",
|
||||
)
|
||||
|
||||
request = Request(callback.download_url, headers={"User-Agent": "x-financial-onlyoffice"})
|
||||
with urlopen(request, timeout=30) as response: # noqa: S310
|
||||
content = response.read()
|
||||
|
||||
actor_name = callback.users[0] if callback.users else "ONLYOFFICE"
|
||||
self._replace_document_content(document_id, content, actor_name=actor_name)
|
||||
|
||||
def _load_documents(self) -> list[KnowledgeDocumentRead]:
|
||||
self.ensure_library_ready()
|
||||
index = self._load_index()
|
||||
@@ -275,7 +396,7 @@ class KnowledgeService:
|
||||
return "text", [self._build_text_preview_page(entry, text)]
|
||||
|
||||
if extension == "xlsx":
|
||||
return "table", [self._build_xlsx_preview_page(entry, file_path)]
|
||||
return "table", self._build_xlsx_preview_pages(entry, file_path)
|
||||
|
||||
if extension == "pptx":
|
||||
return "slides", self._build_pptx_preview_pages(entry, file_path)
|
||||
@@ -328,31 +449,39 @@ class KnowledgeService:
|
||||
blocks=blocks,
|
||||
)
|
||||
|
||||
def _build_xlsx_preview_page(
|
||||
def _build_xlsx_preview_pages(
|
||||
self, entry: dict[str, Any], file_path: Path
|
||||
) -> KnowledgePreviewPageRead:
|
||||
rows, sheet_count = self._extract_xlsx_rows(file_path)
|
||||
if not rows:
|
||||
rows = [["未提取到表格内容。"]]
|
||||
) -> list[KnowledgePreviewPageRead]:
|
||||
sheets = self._extract_xlsx_sheets(file_path)
|
||||
if not sheets:
|
||||
sheets = [("Sheet 1", [["未提取到表格内容。"]])]
|
||||
|
||||
preview_pages: list[KnowledgePreviewPageRead] = []
|
||||
sheet_count = len(sheets)
|
||||
for sheet_name, rows in sheets[:8]:
|
||||
visible_rows = rows[:12] if rows else [["未提取到表格内容。"]]
|
||||
blocks = [
|
||||
KnowledgePreviewBlockRead(
|
||||
heading=f"第 {index + 1} 行",
|
||||
lines=[" | ".join(cell for cell in row if cell) or "(空行)"],
|
||||
lines=[" | ".join((cell or "") for cell in row)],
|
||||
)
|
||||
for index, row in enumerate(rows[:12])
|
||||
for index, row in enumerate(visible_rows)
|
||||
]
|
||||
|
||||
return KnowledgePreviewPageRead(
|
||||
title=entry["original_name"],
|
||||
preview_pages.append(
|
||||
KnowledgePreviewPageRead(
|
||||
title=sheet_name,
|
||||
subtitle="表格内容预览",
|
||||
stats=[
|
||||
KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)),
|
||||
KnowledgePreviewStatRead(label="预览行数", value=str(min(len(rows), 12))),
|
||||
KnowledgePreviewStatRead(label="预览行数", value=str(len(visible_rows))),
|
||||
KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])),
|
||||
],
|
||||
blocks=blocks,
|
||||
)
|
||||
)
|
||||
|
||||
return preview_pages
|
||||
|
||||
def _build_pptx_preview_pages(
|
||||
self, entry: dict[str, Any], file_path: Path
|
||||
@@ -464,6 +593,29 @@ class KnowledgeService:
|
||||
def _resolve_document_path(self, entry: dict[str, Any]) -> Path:
|
||||
return self.library_root / entry["folder"] / entry["stored_name"]
|
||||
|
||||
def _replace_document_content(self, document_id: str, content: bytes, actor_name: str) -> KnowledgeDocumentDetailRead:
|
||||
index = self._load_index()
|
||||
entry = self._require_entry(index, document_id)
|
||||
current_user = CurrentUserContext(
|
||||
username="onlyoffice",
|
||||
name=actor_name or "ONLYOFFICE",
|
||||
role_codes=["manager"],
|
||||
is_admin=True,
|
||||
)
|
||||
return self.upload_document(
|
||||
folder=entry["folder"],
|
||||
filename=entry["original_name"],
|
||||
content=content,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_onlyoffice_callback(payload: dict[str, Any]) -> OnlyOfficeCallbackPayload:
|
||||
status = int(payload.get("status") or 0)
|
||||
download_url = str(payload.get("url") or "").strip()
|
||||
users = [str(item).strip() for item in payload.get("users") or [] if str(item).strip()]
|
||||
return OnlyOfficeCallbackPayload(status=status, download_url=download_url, users=users)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_filename(filename: str) -> str:
|
||||
normalized = Path(str(filename or "").strip()).name.strip()
|
||||
@@ -484,6 +636,30 @@ class KnowledgeService:
|
||||
suffix = Path(filename).suffix.lower().lstrip(".")
|
||||
return suffix
|
||||
|
||||
@staticmethod
|
||||
def _build_onlyoffice_document_key(entry: dict[str, Any]) -> str:
|
||||
version = int(entry.get("version_number", 1))
|
||||
checksum = str(entry.get("sha256") or "")[:12]
|
||||
return f"{entry['id']}-v{version}-{checksum or 'nochecksum'}"
|
||||
|
||||
def _build_onlyoffice_access_token(self, document_id: str) -> str:
|
||||
settings = get_settings()
|
||||
payload = {
|
||||
"scope": "onlyoffice-content",
|
||||
"document_id": document_id,
|
||||
}
|
||||
return jwt.encode(payload, settings.onlyoffice_jwt_secret, algorithm="HS256")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_onlyoffice_document_type(extension: str) -> str:
|
||||
if extension in WORD_EXTENSIONS:
|
||||
return "word"
|
||||
if extension in EXCEL_EXTENSIONS:
|
||||
return "cell"
|
||||
if extension in PPT_EXTENSIONS:
|
||||
return "slide"
|
||||
raise ValueError("当前文件格式不支持 ONLYOFFICE 预览。")
|
||||
|
||||
@staticmethod
|
||||
def _parse_stored_name(stored_name: str) -> tuple[str, str]:
|
||||
if "__" not in stored_name:
|
||||
@@ -568,7 +744,7 @@ class KnowledgeService:
|
||||
return "\n".join(texts)
|
||||
|
||||
@staticmethod
|
||||
def _extract_xlsx_rows(file_path: Path) -> tuple[list[list[str]], int]:
|
||||
def _extract_xlsx_sheets(file_path: Path) -> list[tuple[str, list[list[str]]]]:
|
||||
try:
|
||||
with ZipFile(file_path) as archive:
|
||||
shared_strings: list[str] = []
|
||||
@@ -580,17 +756,58 @@ class KnowledgeService:
|
||||
if node.tag.endswith("}si")
|
||||
]
|
||||
|
||||
sheet_names = sorted(
|
||||
sheet_files = sorted(
|
||||
name
|
||||
for name in archive.namelist()
|
||||
if re.fullmatch(r"xl/worksheets/sheet\d+\.xml", name)
|
||||
)
|
||||
if not sheet_names:
|
||||
return [], 0
|
||||
if not sheet_files:
|
||||
return []
|
||||
|
||||
first_sheet = ElementTree.fromstring(archive.read(sheet_names[0]))
|
||||
relationship_targets: dict[str, str] = {}
|
||||
if "xl/_rels/workbook.xml.rels" in archive.namelist():
|
||||
rel_root = ElementTree.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
|
||||
for node in rel_root.iter():
|
||||
if not node.tag.endswith("Relationship"):
|
||||
continue
|
||||
rel_id = node.attrib.get("Id")
|
||||
target = node.attrib.get("Target")
|
||||
if not rel_id or not target:
|
||||
continue
|
||||
normalized = target.lstrip("/")
|
||||
if not normalized.startswith("xl/"):
|
||||
normalized = f"xl/{normalized.lstrip('./')}"
|
||||
relationship_targets[rel_id] = normalized
|
||||
|
||||
ordered_sheets: list[tuple[str, str]] = []
|
||||
if "xl/workbook.xml" in archive.namelist():
|
||||
workbook_root = ElementTree.fromstring(archive.read("xl/workbook.xml"))
|
||||
for index, node in enumerate(workbook_root.iter()):
|
||||
if not node.tag.endswith("sheet"):
|
||||
continue
|
||||
sheet_name = node.attrib.get("name") or f"Sheet {index + 1}"
|
||||
relationship_id = next(
|
||||
(value for key, value in node.attrib.items() if key.endswith("}id")),
|
||||
None,
|
||||
)
|
||||
target = relationship_targets.get(relationship_id or "")
|
||||
if target:
|
||||
ordered_sheets.append((sheet_name, target))
|
||||
|
||||
if not ordered_sheets:
|
||||
ordered_sheets = [
|
||||
(f"Sheet {index + 1}", sheet_file)
|
||||
for index, sheet_file in enumerate(sheet_files)
|
||||
]
|
||||
|
||||
preview_sheets: list[tuple[str, list[list[str]]]] = []
|
||||
for sheet_name, target in ordered_sheets:
|
||||
if target not in archive.namelist():
|
||||
continue
|
||||
|
||||
sheet_root = ElementTree.fromstring(archive.read(target))
|
||||
rows: list[list[str]] = []
|
||||
for row in first_sheet.iter():
|
||||
for row in sheet_root.iter():
|
||||
if not row.tag.endswith("}row"):
|
||||
continue
|
||||
row_values: list[str] = []
|
||||
@@ -599,9 +816,16 @@ class KnowledgeService:
|
||||
continue
|
||||
cell_type = cell.attrib.get("t")
|
||||
value_node = next((item for item in cell if item.tag.endswith("}v")), None)
|
||||
|
||||
if cell_type == "inlineStr":
|
||||
text_node = next((item for item in cell.iter() if item.tag.endswith("}t")), None)
|
||||
row_values.append((text_node.text or "").strip() if text_node is not None else "")
|
||||
continue
|
||||
|
||||
if value_node is None or value_node.text is None:
|
||||
row_values.append("")
|
||||
continue
|
||||
|
||||
raw_value = value_node.text.strip()
|
||||
if cell_type == "s" and raw_value.isdigit():
|
||||
index = int(raw_value)
|
||||
@@ -611,9 +835,11 @@ class KnowledgeService:
|
||||
if row_values:
|
||||
rows.append(row_values)
|
||||
|
||||
return rows, len(sheet_names)
|
||||
preview_sheets.append((sheet_name, rows))
|
||||
|
||||
return preview_sheets
|
||||
except (BadZipFile, ElementTree.ParseError, KeyError, ValueError):
|
||||
return [], 0
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _extract_pptx_slides(file_path: Path) -> list[list[str]]:
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
{
|
||||
"version": 1,
|
||||
"documents": []
|
||||
"documents": [
|
||||
{
|
||||
"id": "fde293670eac4ae2b90a80eeb9f27b5b",
|
||||
"folder": "财务知识库",
|
||||
"original_name": "差旅费季度报销258878.xlsx",
|
||||
"stored_name": "fde293670eac4ae2b90a80eeb9f27b5b__差旅费季度报销258878.xlsx",
|
||||
"mime_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"extension": "xlsx",
|
||||
"size_bytes": 11123,
|
||||
"sha256": "ea02e59d3a22a4a02284172acce3fd4c6367a26f1a4fd196dc4f65afed1bd4c5",
|
||||
"created_at": "2026-05-09T03:33:44.101489+00:00",
|
||||
"updated_at": "2026-05-09T03:33:44.101489+00:00",
|
||||
"uploaded_by": "admin",
|
||||
"version_number": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -60,23 +60,36 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
min-height: 28px;
|
||||
.file-search {
|
||||
width: min(320px, 100%);
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
transition: background 220ms ease, color 220ms ease;
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.preview-hint.active {
|
||||
background: #dcfce7;
|
||||
color: #059669;
|
||||
.file-search:focus-within {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
|
||||
}
|
||||
|
||||
.file-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.file-search input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.library-body {
|
||||
@@ -90,41 +103,12 @@
|
||||
.folder-rail {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
border-right: 1px solid #edf2f7;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.folder-search {
|
||||
height: 36px;
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) 24px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.folder-search input {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.folder-search input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.folder-search button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.folder-tree {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
@@ -180,6 +164,12 @@
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.new-folder-btn.fixed {
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.document-area {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -188,6 +178,10 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
min-height: 112px;
|
||||
display: grid;
|
||||
@@ -199,6 +193,23 @@
|
||||
background: #f8fbff;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #60a5fa;
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.upload-zone.disabled {
|
||||
cursor: default;
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.upload-zone.busy {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.upload-zone i {
|
||||
@@ -308,11 +319,36 @@ th {
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.more-btn.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.more-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
@@ -444,8 +480,8 @@ th {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
padding: 18px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
padding: 20px 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -453,19 +489,13 @@ th {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
gap: 18px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.preview-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
.preview-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
@@ -500,164 +530,281 @@ th {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
.preview-summary-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #edf2f7;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.preview-meta span {
|
||||
display: inline-flex;
|
||||
.preview-secondary-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-viewer {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.viewer-toolbar {
|
||||
.preview-status {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 180px;
|
||||
padding: 24px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-status.error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.preview-embed-wrap,
|
||||
.preview-image-wrap {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-embed {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 560px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.preview-image-wrap {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.onlyoffice-preview-wrap {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.onlyoffice-preview-host {
|
||||
width: 100%;
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.excel-preview-wrap {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.excel-sheet-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.viewer-filetype {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.excel-sheet-tab {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.excel-sheet-tab.active {
|
||||
border-color: #93c5fd;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.excel-preview-scroll {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.excel-preview-table {
|
||||
width: 100%;
|
||||
min-width: 640px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.excel-preview-table th,
|
||||
.excel-preview-table td {
|
||||
padding: 10px 12px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.excel-preview-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #e8f0fe;
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.viewer-toolbar-actions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
.excel-preview-table td {
|
||||
color: #334155;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.viewer-toolbar-actions button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
.excel-preview-table tbody tr:nth-child(even) td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.excel-preview-table tr > *:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.excel-preview-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.page-stage {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding-right: 4px;
|
||||
gap: 20px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.page-sheet {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
|
||||
gap: 18px;
|
||||
padding: 0 0 20px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
animation: previewSheetIn 360ms var(--ease) both;
|
||||
animation-delay: var(--page-delay, 0ms);
|
||||
}
|
||||
|
||||
.page-sheet:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.page-title span,
|
||||
.page-title b {
|
||||
.page-title span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
gap: 4px;
|
||||
padding: 0 0 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.summary-item span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-item strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf2f7;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.content-block h3 {
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 10px;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.content-block ul {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
padding-left: 20px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.preview-panel-enter-active,
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<div v-for="kpi in knowledgeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
||||
<span class="chip-value">{{ kpi.value }}</span>
|
||||
<span class="chip-label">{{ kpi.label }}</span>
|
||||
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
||||
<span v-if="kpi.meta" class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -144,6 +144,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
knowledgeSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
customRange: {
|
||||
type: Object,
|
||||
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
|
||||
@@ -187,12 +191,20 @@ const approvalKpis = [
|
||||
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: '#10b981' }
|
||||
]
|
||||
|
||||
const knowledgeKpis = [
|
||||
{ label: '文档总数', value: '1,248', meta: '较上周 +68', trend: 'up', icon: 'mdi mdi-file-document-outline', color: '#10b981' },
|
||||
{ label: '文件夹总数', value: '36', meta: '较上周 +2', trend: 'up', icon: 'mdi mdi-folder-outline', color: '#3b82f6' },
|
||||
{ label: '问答总量', value: '8,562', meta: '较上周 +321', trend: 'up', icon: 'mdi mdi-comment-text-multiple-outline', color: '#8b5cf6' },
|
||||
{ label: '知识命中率', value: '87.3%', meta: '较上周 +1.2%', trend: 'up', icon: 'mdi mdi-bullseye-arrow', color: '#f59e0b' }
|
||||
]
|
||||
const knowledgeKpis = computed(() => {
|
||||
const summary = props.knowledgeSummary ?? {}
|
||||
const totalDocuments = Number(summary.totalDocuments ?? 0)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '文档总数',
|
||||
value: String(totalDocuments),
|
||||
meta: '',
|
||||
trend: 'up',
|
||||
color: '#10b981'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const employeeKpis = computed(() => {
|
||||
const summary = props.employeeSummary ?? {}
|
||||
|
||||
@@ -1,4 +1,37 @@
|
||||
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
|
||||
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
|
||||
|
||||
function readCurrentUserHeaders() {
|
||||
if (typeof window === 'undefined') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw)
|
||||
const username = String(payload?.username || '').trim()
|
||||
const name = String(payload?.name || username).trim()
|
||||
const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
||||
const isAdmin = Boolean(payload?.isAdmin)
|
||||
|
||||
if (!username && !name) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'x-auth-username': username,
|
||||
'x-auth-name': name,
|
||||
'x-auth-role-codes': roleCodes.join(','),
|
||||
'x-auth-is-admin': String(isAdmin)
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeApiBaseUrl(value) {
|
||||
return String(value || '/api/v1').replace(/\/$/, '')
|
||||
@@ -66,20 +99,35 @@ function buildUrl(path) {
|
||||
}
|
||||
|
||||
export async function apiRequest(path, options = {}) {
|
||||
const {
|
||||
contentType = 'application/json',
|
||||
responseType = 'json',
|
||||
headers: customHeaders,
|
||||
...fetchOptions
|
||||
} = options
|
||||
|
||||
const headers = {
|
||||
...readCurrentUserHeaders(),
|
||||
...(customHeaders || {})
|
||||
}
|
||||
|
||||
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
|
||||
headers['Content-Type'] = contentType
|
||||
}
|
||||
|
||||
let response
|
||||
|
||||
try {
|
||||
response = await fetch(buildUrl(path), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
...options
|
||||
...fetchOptions,
|
||||
headers
|
||||
})
|
||||
} catch {
|
||||
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
|
||||
}
|
||||
|
||||
if (responseType === 'blob') {
|
||||
if (!response.ok) {
|
||||
let payload = null
|
||||
|
||||
try {
|
||||
@@ -88,6 +136,19 @@ export async function apiRequest(path, options = {}) {
|
||||
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 || '接口请求失败,请稍后重试。')
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ export function fetchKnowledgeDocument(documentId) {
|
||||
return apiRequest(`/knowledge/documents/${documentId}`)
|
||||
}
|
||||
|
||||
export function fetchKnowledgeOnlyOfficeConfig(documentId) {
|
||||
return apiRequest(`/knowledge/documents/${documentId}/onlyoffice-config`)
|
||||
}
|
||||
|
||||
export function uploadKnowledgeDocument({ folder, file }) {
|
||||
return apiRequest(
|
||||
`/knowledge/documents?folder=${encodeURIComponent(folder)}&filename=${encodeURIComponent(file.name)}`,
|
||||
|
||||
43
web/src/services/onlyoffice.js
Normal file
43
web/src/services/onlyoffice.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const scriptPromises = new Map()
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function buildOnlyOfficeScriptUrl(documentServerUrl) {
|
||||
return `${normalizeBaseUrl(documentServerUrl)}/web-apps/apps/api/documents/api.js`
|
||||
}
|
||||
|
||||
export function loadOnlyOfficeApi(documentServerUrl) {
|
||||
const scriptUrl = buildOnlyOfficeScriptUrl(documentServerUrl)
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('ONLYOFFICE 只能在浏览器环境中加载。'))
|
||||
}
|
||||
|
||||
if (window.DocsAPI?.DocEditor) {
|
||||
return Promise.resolve(window.DocsAPI)
|
||||
}
|
||||
|
||||
if (scriptPromises.has(scriptUrl)) {
|
||||
return scriptPromises.get(scriptUrl)
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[src="${scriptUrl}"]`)
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(window.DocsAPI), { once: true })
|
||||
existing.addEventListener('error', () => reject(new Error('ONLYOFFICE 脚本加载失败。')), { once: true })
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = scriptUrl
|
||||
script.async = true
|
||||
script.onload = () => resolve(window.DocsAPI)
|
||||
script.onerror = () => reject(new Error('ONLYOFFICE 脚本加载失败。'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
scriptPromises.set(scriptUrl, promise)
|
||||
return promise
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
:ranges="ranges"
|
||||
:active-range="activeRange"
|
||||
:employee-summary="employeeSummary"
|
||||
:knowledge-summary="knowledgeSummary"
|
||||
:custom-range="customRange"
|
||||
@update:search="search = $event"
|
||||
@update:active-range="activeRange = $event"
|
||||
@@ -110,7 +111,7 @@
|
||||
/>
|
||||
|
||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||
<PoliciesView v-else-if="activeView === 'policies'" />
|
||||
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
|
||||
<AuditView v-else-if="activeView === 'audit'" />
|
||||
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
|
||||
<SettingsView v-else />
|
||||
@@ -151,6 +152,7 @@ import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
||||
|
||||
const employeeSummary = ref(null)
|
||||
const knowledgeSummary = ref(null)
|
||||
|
||||
const {
|
||||
activeCase,
|
||||
|
||||
@@ -8,19 +8,14 @@
|
||||
<h2>文档库 / 文件夹</h2>
|
||||
<p>默认展示文件列表,点击具体文件后可在右侧展开预览。</p>
|
||||
</div>
|
||||
<span class="preview-hint" :class="{ active: selectedDocument }">
|
||||
{{ selectedDocument ? '预览已展开' : '点击文件可预览' }}
|
||||
</span>
|
||||
<label class="file-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<div class="library-body">
|
||||
<aside class="folder-rail">
|
||||
<label class="folder-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input v-model="folderSearch" type="search" placeholder="搜索文件夹" />
|
||||
<button type="button" aria-label="新增文件夹"><i class="mdi mdi-plus"></i></button>
|
||||
</label>
|
||||
|
||||
<nav class="folder-tree" aria-label="知识库文件夹">
|
||||
<button
|
||||
v-for="folder in filteredFolders"
|
||||
@@ -35,17 +30,30 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button class="new-folder-btn" type="button">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新建文件夹</span>
|
||||
<button class="new-folder-btn fixed" type="button" disabled>
|
||||
<i class="mdi mdi-lock-outline"></i>
|
||||
<span>固定文件夹</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section class="document-area">
|
||||
<div class="upload-zone">
|
||||
<div
|
||||
class="upload-zone"
|
||||
:class="{ disabled: !isAdmin, busy: uploading }"
|
||||
@click="triggerUpload"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
class="upload-input"
|
||||
type="file"
|
||||
multiple
|
||||
@change="handleFileInput"
|
||||
/>
|
||||
<i class="mdi mdi-cloud-upload"></i>
|
||||
<strong>拖拽文档到此处,或点击上传</strong>
|
||||
<span>支持 PDF / Word / Excel / PPT 文档,单个文件不超过 100MB</span>
|
||||
<strong>{{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }}</strong>
|
||||
<span>{{ uploadHint }}</span>
|
||||
</div>
|
||||
|
||||
<div class="doc-table-wrap">
|
||||
@@ -64,10 +72,10 @@
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="doc in visibleDocuments"
|
||||
:key="doc.name"
|
||||
:key="doc.id"
|
||||
class="doc-row"
|
||||
:class="{ selected: selectedDocument?.name === doc.name }"
|
||||
@click="selectedDocument = doc"
|
||||
:class="{ selected: selectedDocument?.id === doc.id }"
|
||||
@click="selectDocument(doc.id)"
|
||||
>
|
||||
<td>
|
||||
<span class="file-name">
|
||||
@@ -83,9 +91,26 @@
|
||||
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
|
||||
<td>{{ doc.owner }}</td>
|
||||
<td>
|
||||
<button class="more-btn" type="button" aria-label="更多操作" @click.stop>
|
||||
<i class="mdi mdi-dots-horizontal"></i>
|
||||
<div class="row-actions" @click.stop>
|
||||
<button class="more-btn" type="button" aria-label="下载文件" @click="handleDownload(doc)">
|
||||
<i class="mdi mdi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
class="more-btn danger"
|
||||
type="button"
|
||||
:disabled="deletingId === doc.id"
|
||||
aria-label="删除文件"
|
||||
@click="handleDelete(doc)"
|
||||
>
|
||||
<i class="mdi mdi-delete-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!visibleDocuments.length">
|
||||
<td colspan="7" class="empty-row">
|
||||
{{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -93,17 +118,6 @@
|
||||
</div>
|
||||
|
||||
<footer class="list-foot">
|
||||
<template v-if="false">
|
||||
<span>共 {{ filteredDocuments.length }} 条</span>
|
||||
<button type="button">10条/页 <i class="mdi mdi-chevron-down"></i></button>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button type="button" aria-label="上一页"><i class="mdi mdi-chevron-left"></i></button>
|
||||
<button class="active" type="button" aria-current="page">1</button>
|
||||
<button type="button">2</button>
|
||||
<button type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
|
||||
</div>
|
||||
<label>前往 <input value="1" aria-label="页码" /> 页</label>
|
||||
</template>
|
||||
<span class="page-summary">共 {{ totalCount }} 条,目前第 {{ currentPage }} 页</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
|
||||
@@ -152,67 +166,78 @@
|
||||
<aside v-if="selectedDocument" class="preview-column">
|
||||
<article class="preview-panel panel">
|
||||
<header class="preview-head">
|
||||
<div>
|
||||
<span class="preview-kicker">文件预览</span>
|
||||
<div class="preview-copy">
|
||||
<h2>{{ selectedDocument.name }}</h2>
|
||||
<p>{{ selectedDocument.summary }}</p>
|
||||
<p class="preview-summary-line">
|
||||
<span v-for="part in previewMetaLine" :key="part">{{ part }}</span>
|
||||
</p>
|
||||
<div v-if="previewSecondaryMetaLine.length" class="preview-secondary-line">
|
||||
<span v-for="part in previewSecondaryMetaLine" :key="part">{{ part }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-actions">
|
||||
<button type="button" class="mini-action">
|
||||
<button type="button" class="mini-action" @click="handleDownload(selectedDocument)">
|
||||
<i class="mdi mdi-download"></i>
|
||||
<span>下载</span>
|
||||
</button>
|
||||
<button type="button" class="icon-action" aria-label="关闭预览" @click="selectedDocument = null">
|
||||
<button type="button" class="icon-action" aria-label="关闭预览" @click="closePreview">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="preview-meta">
|
||||
<span><i class="mdi mdi-tag-outline"></i>{{ selectedDocument.tag }}</span>
|
||||
<span><i class="mdi mdi-history"></i>{{ selectedDocument.time }}</span>
|
||||
<span><i class="mdi mdi-account-circle-outline"></i>{{ selectedDocument.owner }}</span>
|
||||
<span><i class="mdi mdi-source-branch"></i>{{ selectedDocument.version }}</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-viewer">
|
||||
<div class="viewer-toolbar">
|
||||
<div class="viewer-filetype" :class="selectedDocument.fileType">
|
||||
<i :class="selectedDocument.icon"></i>
|
||||
<span>{{ selectedDocument.fileTypeLabel }}</span>
|
||||
<div v-if="previewLoading" class="preview-status">正在加载预览...</div>
|
||||
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
|
||||
<div v-else-if="selectedDocument.previewKind === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
|
||||
<iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe>
|
||||
</div>
|
||||
<div class="viewer-toolbar-actions">
|
||||
<button type="button"><i class="mdi mdi-magnify-minus-outline"></i></button>
|
||||
<button type="button"><i class="mdi mdi-magnify-plus-outline"></i></button>
|
||||
<button type="button"><i class="mdi mdi-fit-to-page-outline"></i></button>
|
||||
<div v-else-if="selectedDocument.previewKind === 'image' && previewBlobUrl" class="preview-image-wrap">
|
||||
<img :src="previewBlobUrl" :alt="selectedDocument.name" class="preview-image" />
|
||||
</div>
|
||||
<div v-else-if="shouldUseOnlyOffice" class="onlyoffice-preview-wrap">
|
||||
<div v-if="onlyOfficeLoading" class="preview-status">正在加载 ONLYOFFICE 预览...</div>
|
||||
<div v-else-if="onlyOfficeError" class="preview-status error">{{ onlyOfficeError }}</div>
|
||||
<div v-else :id="onlyOfficeHostId" class="onlyoffice-preview-host"></div>
|
||||
</div>
|
||||
|
||||
<div class="page-stage">
|
||||
<div v-else-if="selectedDocument.previewKind === 'table'" class="excel-preview-wrap">
|
||||
<div v-if="selectedDocument.previewPages.length > 1" class="excel-sheet-tabs" role="tablist" aria-label="Excel 工作表页签">
|
||||
<button
|
||||
v-for="(page, index) in selectedDocument.previewPages"
|
||||
:key="`${selectedDocument.id}-sheet-${index}`"
|
||||
type="button"
|
||||
class="excel-sheet-tab"
|
||||
:class="{ active: currentPreviewPageIndex === index }"
|
||||
:aria-selected="currentPreviewPageIndex === index"
|
||||
@click="selectPreviewPage(index)"
|
||||
>
|
||||
{{ page.title }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="excelPreviewTable.headers.length" class="excel-preview-scroll">
|
||||
<table class="excel-preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in excelPreviewTable.headers" :key="header">{{ header }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in excelPreviewTable.rows" :key="`row-${rowIndex}`">
|
||||
<td v-for="(cell, cellIndex) in row" :key="`cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="preview-status">当前表格暂未提取到可展示内容。</div>
|
||||
</div>
|
||||
<div v-else class="page-stage">
|
||||
<article
|
||||
v-for="(page, index) in selectedDocument.previewPages"
|
||||
:key="`${selectedDocument.name}-${index}`"
|
||||
:key="`${selectedDocument.id}-${index}`"
|
||||
class="page-sheet"
|
||||
:style="{ '--page-delay': `${index * 70}ms` }"
|
||||
>
|
||||
<header class="page-title">
|
||||
<div>
|
||||
<strong>{{ page.title }}</strong>
|
||||
<span>{{ page.subtitle }}</span>
|
||||
</div>
|
||||
<b>第 {{ index + 1 }} 页</b>
|
||||
</header>
|
||||
|
||||
<section class="page-summary">
|
||||
<div class="summary-grid">
|
||||
<div v-for="item in page.stats" :key="item.label" class="summary-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-content">
|
||||
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
|
||||
<h3>{{ block.heading }}</h3>
|
||||
@@ -222,6 +247,9 @@
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
<div v-if="!selectedDocument.previewPages.length" class="preview-status">
|
||||
当前文件暂未生成结构化预览,请下载后查看。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,249 +1,281 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { watch } from 'vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
deleteKnowledgeDocument,
|
||||
fetchKnowledgeDocument,
|
||||
fetchKnowledgeDocumentBlob,
|
||||
fetchKnowledgeLibrary,
|
||||
fetchKnowledgeOnlyOfficeConfig,
|
||||
uploadKnowledgeDocument
|
||||
} from '../../services/knowledge.js'
|
||||
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
|
||||
import { isManagerUser } from '../../utils/accessControl.js'
|
||||
import {
|
||||
buildExcelPreviewTable,
|
||||
buildPreviewMetaLine,
|
||||
buildPreviewSecondaryMetaLine
|
||||
} from './policiesPreviewFormatters.js'
|
||||
|
||||
function triggerFileDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const ONLYOFFICE_EXTENSIONS = new Set(['docx', 'xlsx', 'pptx'])
|
||||
|
||||
function supportsOnlyOfficePreview(document) {
|
||||
return ONLYOFFICE_EXTENSIONS.has(String(document?.extension || '').toLowerCase())
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'PoliciesView' ,
|
||||
setup(props, { emit }) {
|
||||
const folderSearch = ref('')
|
||||
name: 'PoliciesView',
|
||||
emits: ['summary-change'],
|
||||
setup(_, { emit }) {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
const documentSearch = ref('')
|
||||
const activeFolder = ref('差旅规范')
|
||||
const folders = ref([])
|
||||
const documents = ref([])
|
||||
const selectedDocument = ref(null)
|
||||
|
||||
const folders = [
|
||||
{ name: '财务知识库', count: 36, icon: 'mdi mdi-folder' },
|
||||
{ name: '制度政策', count: 8, icon: 'mdi mdi-folder' },
|
||||
{ name: '报销制度', count: 12, icon: 'mdi mdi-folder-open' },
|
||||
{ name: '差旅规范', count: 18, icon: 'mdi mdi-folder' },
|
||||
{ name: '发票管理', count: 14, icon: 'mdi mdi-folder' },
|
||||
{ name: '税务合规', count: 16, icon: 'mdi mdi-folder' },
|
||||
{ name: '预算管理', count: 9, icon: 'mdi mdi-folder' },
|
||||
{ name: '财务共享', count: 7, icon: 'mdi mdi-folder' },
|
||||
{ name: '培训资料', count: 6, icon: 'mdi mdi-folder' },
|
||||
{ name: '常见问答', count: 11, icon: 'mdi mdi-folder' }
|
||||
]
|
||||
|
||||
const documents = [
|
||||
{
|
||||
name: '差旅报销管理办法(2024版)',
|
||||
folder: '差旅规范',
|
||||
tag: '差旅 / 制度',
|
||||
time: '2024-05-12 14:35',
|
||||
version: 'v3.2',
|
||||
state: '已生效',
|
||||
stateTone: 'success',
|
||||
owner: '张明',
|
||||
icon: 'mdi mdi-file-document-outline-pdf pdf',
|
||||
fileType: 'pdf',
|
||||
fileTypeLabel: 'PDF 预览',
|
||||
summary: '面向员工与财务共享团队的差旅费用标准、审批边界和附件要求。',
|
||||
previewPages: [
|
||||
{
|
||||
title: '差旅报销管理办法(2024版)',
|
||||
subtitle: '住宿、交通、审批与附件要求',
|
||||
stats: [
|
||||
{ label: '适用范围', value: '全员' },
|
||||
{ label: '生效日期', value: '2024-05-12' },
|
||||
{ label: '更新重点', value: '住宿标准' }
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
heading: '一、适用范围',
|
||||
lines: ['适用于国内差旅申请、预订、报销与借款冲销。', '共享中心审核以出差申请、票据与预算中心为准。']
|
||||
},
|
||||
{
|
||||
heading: '二、住宿标准',
|
||||
lines: ['一线城市单晚标准 650 元,超标需附业务说明。', '连续住宿超过 3 晚需补充行程与客户拜访记录。']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '审批与附件要求',
|
||||
subtitle: '流程节点与必要凭证',
|
||||
stats: [
|
||||
{ label: '附件校验', value: '7 项' },
|
||||
{ label: '审批节点', value: '4 级' },
|
||||
{ label: '自动拦截', value: '超标 / 重复' }
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
heading: '三、审批规则',
|
||||
lines: ['直属主管审批通过后进入财务复核。', '超预算或超标申请需追加部门负责人审批。']
|
||||
},
|
||||
{
|
||||
heading: '四、附件清单',
|
||||
lines: ['机票行程单、酒店发票、住宿水单、出租车发票。', '如存在改签、退票或异常情况,需补充说明材料。']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '发票查验规范及操作指引',
|
||||
folder: '发票管理',
|
||||
tag: '发票 / 操作',
|
||||
time: '2024-05-10 10:22',
|
||||
version: 'v1.5',
|
||||
state: '已生效',
|
||||
stateTone: 'success',
|
||||
owner: '李娜',
|
||||
icon: 'mdi mdi-file-document-outline-word word',
|
||||
fileType: 'word',
|
||||
fileTypeLabel: 'Word 预览',
|
||||
summary: '说明发票验真路径、异常票据处理方式以及入账留痕要求。',
|
||||
previewPages: [
|
||||
{
|
||||
title: '发票查验规范及操作指引',
|
||||
subtitle: '验真流程与异常识别',
|
||||
stats: [
|
||||
{ label: '查验入口', value: '3 个' },
|
||||
{ label: '异常类型', value: '6 类' },
|
||||
{ label: '责任角色', value: '财务专员' }
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
heading: '一、查验入口',
|
||||
lines: ['优先通过税务查验接口进行自动验真。', '无法自动识别时转人工核验并保留截图。']
|
||||
},
|
||||
{
|
||||
heading: '二、异常票据',
|
||||
lines: ['票面抬头不一致、号码重复、跨月补录需重点标注。', '出现红冲票据时需关联原单据并补充说明。']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '费用报销标准细则(2024)',
|
||||
folder: '报销制度',
|
||||
tag: '报销 / 标准',
|
||||
time: '2024-05-08 09:16',
|
||||
version: 'v2.1',
|
||||
state: '已生效',
|
||||
stateTone: 'success',
|
||||
owner: '王磊',
|
||||
icon: 'mdi mdi-file-document-outline-pdf pdf',
|
||||
fileType: 'pdf',
|
||||
fileTypeLabel: 'PDF 预览',
|
||||
summary: '定义招待、差旅、办公采购与培训等费用类型的标准与限制。',
|
||||
previewPages: [
|
||||
{
|
||||
title: '费用报销标准细则(2024)',
|
||||
subtitle: '费用口径与报销边界',
|
||||
stats: [
|
||||
{ label: '费用大类', value: '8 类' },
|
||||
{ label: '更新日期', value: '2024-05-08' },
|
||||
{ label: '重点事项', value: '招待 / 交通' }
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
heading: '一、业务招待',
|
||||
lines: ['需填写客户单位、参与人数及招待事由。', '单次超过 2000 元需上传审批邮件或会议纪要。']
|
||||
},
|
||||
{
|
||||
heading: '二、交通与差旅',
|
||||
lines: ['市内交通按真实票据报销,超标部分需说明。', '夜间出行或跨城交通需关联出差申请。']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '差旅费用标准对照表(国内)',
|
||||
folder: '差旅规范',
|
||||
tag: '差旅 / 标准',
|
||||
time: '2024-05-05 08:20',
|
||||
version: 'v1.3',
|
||||
state: '审批中',
|
||||
stateTone: 'warning',
|
||||
owner: '陈杰',
|
||||
icon: 'mdi mdi-file-document-outline-excel excel',
|
||||
fileType: 'excel',
|
||||
fileTypeLabel: 'Excel 预览',
|
||||
summary: '各城市住宿、餐补与交通等级对照表,供申请与审核环节快速查询。',
|
||||
previewPages: [
|
||||
{
|
||||
title: '差旅费用标准对照表(国内)',
|
||||
subtitle: '城市维度对照',
|
||||
stats: [
|
||||
{ label: '覆盖城市', value: '48 个' },
|
||||
{ label: '住宿档位', value: '4 级' },
|
||||
{ label: '餐补标准', value: '日维度' }
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
heading: '一、住宿标准',
|
||||
lines: ['北京 / 上海 / 深圳:650 元 / 晚。', '新一线城市:500 元 / 晚,其余城市按 380 元 / 晚执行。']
|
||||
},
|
||||
{
|
||||
heading: '二、交通等级',
|
||||
lines: ['总监及以上可乘坐高铁商务座或机票公务舱。', '其他员工默认经济舱、高铁二等座。']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '借款管理办法及流程',
|
||||
folder: '财务共享',
|
||||
tag: '借款 / 流程',
|
||||
time: '2024-05-03 11:05',
|
||||
version: 'v1.0',
|
||||
state: '已生效',
|
||||
stateTone: 'success',
|
||||
owner: '刘洋',
|
||||
icon: 'mdi mdi-file-document-outline-pdf pdf',
|
||||
fileType: 'pdf',
|
||||
fileTypeLabel: 'PDF 预览',
|
||||
summary: '覆盖差旅借款、项目借款和借款冲销的全流程要求。',
|
||||
previewPages: [
|
||||
{
|
||||
title: '借款管理办法及流程',
|
||||
subtitle: '借款申请与冲销闭环',
|
||||
stats: [
|
||||
{ label: '适用场景', value: '差旅 / 项目' },
|
||||
{ label: '冲销时限', value: '30 天' },
|
||||
{ label: '审批路径', value: '3 级' }
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
heading: '一、借款申请',
|
||||
lines: ['借款申请需绑定预算中心与费用类型。', '超过 5000 元需部门负责人额外审批。']
|
||||
},
|
||||
{
|
||||
heading: '二、冲销要求',
|
||||
lines: ['借款发生后 30 日内完成报销与冲销。', '逾期未冲销将纳入月度风险提醒。']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const filteredFolders = computed(() => {
|
||||
const key = folderSearch.value.trim()
|
||||
if (!key) return folders
|
||||
return folders.filter((folder) => folder.name.includes(key))
|
||||
})
|
||||
|
||||
const filteredDocuments = computed(() =>
|
||||
documents.filter((doc) => {
|
||||
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
|
||||
return inFolder
|
||||
})
|
||||
)
|
||||
|
||||
const pageSizeOpen = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pageSizes = [10, 20, 50]
|
||||
const pageSizeOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const uploadInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const deletingId = ref('')
|
||||
const previewLoading = ref(false)
|
||||
const previewBlobUrl = ref('')
|
||||
const previewError = ref('')
|
||||
const onlyOfficeLoading = ref(false)
|
||||
const onlyOfficeError = ref('')
|
||||
const onlyOfficeEditor = ref(null)
|
||||
const onlyOfficeHostId = ref('knowledge-onlyoffice-preview')
|
||||
const currentPreviewPageIndex = ref(0)
|
||||
|
||||
const isAdmin = computed(() => isManagerUser(currentUser.value))
|
||||
const uploadHint = computed(() =>
|
||||
isAdmin.value
|
||||
? '支持 PDF / Word / Excel / PPT / 图片 / 文本文件,重复同名文件将自动覆盖并升级版本'
|
||||
: '当前账号只有查阅权限,上传、删除和修改仅管理员可用'
|
||||
)
|
||||
|
||||
const filteredFolders = computed(() => folders.value)
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
const key = documentSearch.value.trim()
|
||||
|
||||
return documents.value.filter((doc) => {
|
||||
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
|
||||
const matchesSearch = key ? doc.name.includes(key) : true
|
||||
return inFolder && matchesSearch
|
||||
})
|
||||
})
|
||||
|
||||
const totalCount = computed(() => filteredDocuments.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
||||
|
||||
const visibleDocuments = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredDocuments.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
const activePreviewPage = computed(() => {
|
||||
const pages = selectedDocument.value?.previewPages || []
|
||||
return pages[currentPreviewPageIndex.value] || pages[0] || null
|
||||
})
|
||||
const previewMetaLine = computed(() => buildPreviewMetaLine(selectedDocument.value))
|
||||
const previewSecondaryMetaLine = computed(() =>
|
||||
buildPreviewSecondaryMetaLine(selectedDocument.value, activePreviewPage.value)
|
||||
)
|
||||
const shouldUseOnlyOffice = computed(() => supportsOnlyOfficePreview(selectedDocument.value))
|
||||
const excelPreviewTable = computed(() =>
|
||||
selectedDocument.value?.previewKind === 'table'
|
||||
? buildExcelPreviewTable(activePreviewPage.value)
|
||||
: { headers: [], rows: [] }
|
||||
)
|
||||
|
||||
function revokePreviewBlob() {
|
||||
if (previewBlobUrl.value) {
|
||||
URL.revokeObjectURL(previewBlobUrl.value)
|
||||
previewBlobUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function destroyOnlyOfficeEditor() {
|
||||
if (onlyOfficeEditor.value?.destroyEditor) {
|
||||
onlyOfficeEditor.value.destroyEditor()
|
||||
}
|
||||
onlyOfficeEditor.value = null
|
||||
}
|
||||
|
||||
async function mountOnlyOfficeEditor(documentId) {
|
||||
onlyOfficeLoading.value = true
|
||||
onlyOfficeError.value = ''
|
||||
destroyOnlyOfficeEditor()
|
||||
|
||||
try {
|
||||
const payload = await fetchKnowledgeOnlyOfficeConfig(documentId)
|
||||
await loadOnlyOfficeApi(payload.documentServerUrl)
|
||||
await nextTick()
|
||||
|
||||
if (!window.DocsAPI?.DocEditor) {
|
||||
throw new Error('ONLYOFFICE 编辑器未正确加载。')
|
||||
}
|
||||
|
||||
onlyOfficeHostId.value = `knowledge-onlyoffice-preview-${documentId}`
|
||||
await nextTick()
|
||||
onlyOfficeEditor.value = new window.DocsAPI.DocEditor(onlyOfficeHostId.value, payload.config)
|
||||
} catch (error) {
|
||||
onlyOfficeError.value = error.message || 'ONLYOFFICE 预览加载失败。'
|
||||
} finally {
|
||||
onlyOfficeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLibrary(options = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = await fetchKnowledgeLibrary()
|
||||
folders.value = payload.folders || []
|
||||
documents.value = payload.documents || []
|
||||
emit('summary-change', { totalDocuments: documents.value.length })
|
||||
|
||||
const activeExists = folders.value.some((folder) => folder.name === activeFolder.value)
|
||||
if (!activeExists) {
|
||||
activeFolder.value = folders.value[0]?.name || ''
|
||||
}
|
||||
|
||||
if (options.preserveSelection && selectedDocument.value?.id) {
|
||||
const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id)
|
||||
if (!exists) {
|
||||
selectedDocument.value = null
|
||||
revokePreviewBlob()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
emit('summary-change', { totalDocuments: 0 })
|
||||
toast(error.message || '知识库加载失败。')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDocument(documentId) {
|
||||
previewLoading.value = true
|
||||
previewError.value = ''
|
||||
onlyOfficeError.value = ''
|
||||
revokePreviewBlob()
|
||||
destroyOnlyOfficeEditor()
|
||||
|
||||
try {
|
||||
const payload = await fetchKnowledgeDocument(documentId)
|
||||
selectedDocument.value = payload
|
||||
currentPreviewPageIndex.value = 0
|
||||
|
||||
if (supportsOnlyOfficePreview(payload)) {
|
||||
await mountOnlyOfficeEditor(documentId)
|
||||
} else if (payload.previewKind === 'pdf' || payload.previewKind === 'image') {
|
||||
const blob = await fetchKnowledgeDocumentBlob(documentId, 'inline')
|
||||
previewBlobUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
} catch (error) {
|
||||
previewError.value = error.message || '预览加载失败。'
|
||||
toast(previewError.value)
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(document) {
|
||||
try {
|
||||
const blob = await fetchKnowledgeDocumentBlob(document.id, 'attachment')
|
||||
triggerFileDownload(blob, document.name)
|
||||
} catch (error) {
|
||||
toast(error.message || '下载失败。')
|
||||
}
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
if (!isAdmin.value || uploading.value) {
|
||||
return
|
||||
}
|
||||
uploadInput.value?.click()
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList) {
|
||||
const files = Array.from(fileList || []).filter(Boolean)
|
||||
if (!files.length || !activeFolder.value || !isAdmin.value) {
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
let latestDocumentId = ''
|
||||
for (const file of files) {
|
||||
const payload = await uploadKnowledgeDocument({ folder: activeFolder.value, file })
|
||||
latestDocumentId = payload.id
|
||||
}
|
||||
|
||||
await loadLibrary({ preserveSelection: true })
|
||||
toast(files.length > 1 ? `已上传 ${files.length} 个知识库文件。` : '知识库文件已上传。')
|
||||
|
||||
if (latestDocumentId) {
|
||||
await selectDocument(latestDocumentId)
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error.message || '上传失败。')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
if (uploadInput.value) {
|
||||
uploadInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileInput(event) {
|
||||
await uploadFiles(event.target.files)
|
||||
}
|
||||
|
||||
async function handleDrop(event) {
|
||||
if (!isAdmin.value) {
|
||||
return
|
||||
}
|
||||
await uploadFiles(event.dataTransfer?.files)
|
||||
}
|
||||
|
||||
async function handleDelete(document) {
|
||||
if (!isAdmin.value || deletingId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingId.value = document.id
|
||||
try {
|
||||
await deleteKnowledgeDocument(document.id)
|
||||
if (selectedDocument.value?.id === document.id) {
|
||||
selectedDocument.value = null
|
||||
revokePreviewBlob()
|
||||
}
|
||||
await loadLibrary()
|
||||
toast('知识库文件已删除。')
|
||||
} catch (error) {
|
||||
toast(error.message || '删除失败。')
|
||||
} finally {
|
||||
deletingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function changePageSize(size) {
|
||||
pageSize.value = size
|
||||
@@ -251,28 +283,79 @@ export default {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
selectedDocument.value = null
|
||||
previewError.value = ''
|
||||
currentPreviewPageIndex.value = 0
|
||||
revokePreviewBlob()
|
||||
destroyOnlyOfficeEditor()
|
||||
onlyOfficeError.value = ''
|
||||
}
|
||||
|
||||
function selectPreviewPage(index) {
|
||||
currentPreviewPageIndex.value = index
|
||||
}
|
||||
|
||||
watch(filteredDocuments, () => {
|
||||
currentPage.value = 1
|
||||
pageSizeOpen.value = false
|
||||
|
||||
if (selectedDocument.value && !filteredDocuments.value.some((doc) => doc.id === selectedDocument.value.id)) {
|
||||
closePreview()
|
||||
}
|
||||
})
|
||||
|
||||
watch(activeFolder, () => {
|
||||
closePreview()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadLibrary()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokePreviewBlob()
|
||||
})
|
||||
|
||||
return {
|
||||
folderSearch,
|
||||
activeFolder,
|
||||
selectedDocument,
|
||||
folders,
|
||||
documents,
|
||||
filteredFolders,
|
||||
filteredDocuments,
|
||||
activePreviewPage,
|
||||
changePageSize,
|
||||
closePreview,
|
||||
excelPreviewTable,
|
||||
currentPage,
|
||||
currentPreviewPageIndex,
|
||||
deletingId,
|
||||
documentSearch,
|
||||
filteredFolders,
|
||||
handleDelete,
|
||||
handleDownload,
|
||||
handleDrop,
|
||||
handleFileInput,
|
||||
isAdmin,
|
||||
loading,
|
||||
pageSize,
|
||||
pageSizes,
|
||||
pageSizeOpen,
|
||||
pageSizes,
|
||||
onlyOfficeError,
|
||||
onlyOfficeHostId,
|
||||
onlyOfficeLoading,
|
||||
previewMetaLine,
|
||||
previewSecondaryMetaLine,
|
||||
previewBlobUrl,
|
||||
previewError,
|
||||
previewLoading,
|
||||
shouldUseOnlyOffice,
|
||||
selectDocument,
|
||||
selectPreviewPage,
|
||||
selectedDocument,
|
||||
totalCount,
|
||||
totalPages,
|
||||
visibleDocuments,
|
||||
changePageSize
|
||||
triggerUpload,
|
||||
uploadHint,
|
||||
uploadInput,
|
||||
uploading,
|
||||
visibleDocuments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
65
web/src/views/scripts/policiesPreviewFormatters.js
Normal file
65
web/src/views/scripts/policiesPreviewFormatters.js
Normal file
@@ -0,0 +1,65 @@
|
||||
function splitPreviewRow(line) {
|
||||
return String(line || '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
}
|
||||
|
||||
export function buildPreviewMetaLine(document) {
|
||||
if (!document) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [document.summary, document.time].filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildPreviewSecondaryMetaLine(document, page = null) {
|
||||
if (!document) {
|
||||
return []
|
||||
}
|
||||
|
||||
const activePage = page || (Array.isArray(document.previewPages) ? document.previewPages[0] : null)
|
||||
if (!activePage) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parts = []
|
||||
|
||||
if (activePage.subtitle) {
|
||||
parts.push(activePage.subtitle)
|
||||
}
|
||||
|
||||
if (document.previewKind === 'table') {
|
||||
for (const item of activePage.stats || []) {
|
||||
if (!item?.label || !item?.value || item.label === '文件大小') {
|
||||
continue
|
||||
}
|
||||
parts.push(`${item.label} ${item.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
export function buildExcelPreviewTable(page) {
|
||||
const rawRows = (page?.blocks || [])
|
||||
.flatMap((block) => block.lines || [])
|
||||
.map(splitPreviewRow)
|
||||
.filter((row) => row.length > 0 && row.some((cell) => cell !== ''))
|
||||
|
||||
if (!rawRows.length) {
|
||||
return { headers: [], rows: [] }
|
||||
}
|
||||
|
||||
const columnCount = rawRows.reduce((max, row) => Math.max(max, row.length), 0)
|
||||
const normalizedRows = rawRows.map((row) =>
|
||||
Array.from({ length: columnCount }, (_, index) => row[index] ?? '')
|
||||
)
|
||||
|
||||
const [headerRow, ...bodyRows] = normalizedRows
|
||||
const headers = headerRow.map((cell, index) => cell || `列 ${index + 1}`)
|
||||
|
||||
return {
|
||||
headers,
|
||||
rows: bodyRows
|
||||
}
|
||||
}
|
||||
99
web/tests/api-request.test.mjs
Normal file
99
web/tests/api-request.test.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { apiRequest } from '../src/services/api.js'
|
||||
|
||||
async function testUsesCustomContentTypeHeader() {
|
||||
let capturedOptions = null
|
||||
|
||||
global.fetch = async (_url, options) => {
|
||||
capturedOptions = options
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await apiRequest('/knowledge/documents', {
|
||||
method: 'POST',
|
||||
body: 'payload',
|
||||
contentType: 'application/octet-stream'
|
||||
})
|
||||
|
||||
assert.equal(capturedOptions.headers['Content-Type'], 'application/octet-stream')
|
||||
}
|
||||
|
||||
async function testSupportsBlobResponses() {
|
||||
const blob = new Blob(['preview'])
|
||||
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
async blob() {
|
||||
return blob
|
||||
},
|
||||
async json() {
|
||||
throw new Error('json parser should not be used for blob responses')
|
||||
}
|
||||
})
|
||||
|
||||
const payload = await apiRequest('/knowledge/documents/demo/content', {
|
||||
responseType: 'blob',
|
||||
contentType: null
|
||||
})
|
||||
|
||||
assert.equal(payload, blob)
|
||||
}
|
||||
|
||||
async function testInjectsAuthenticatedUserHeaders() {
|
||||
const sessionStorage = new Map([
|
||||
[
|
||||
'x-financial-auth-user',
|
||||
JSON.stringify({
|
||||
username: 'admin',
|
||||
name: '系统管理员',
|
||||
roleCodes: ['manager'],
|
||||
isAdmin: true
|
||||
})
|
||||
]
|
||||
])
|
||||
|
||||
global.window = {
|
||||
sessionStorage: {
|
||||
getItem(key) {
|
||||
return sessionStorage.get(key) ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let capturedOptions = null
|
||||
|
||||
global.fetch = async (_url, options) => {
|
||||
capturedOptions = options
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await apiRequest('/knowledge/library')
|
||||
|
||||
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
|
||||
assert.equal(capturedOptions.headers['x-auth-name'], '系统管理员')
|
||||
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
|
||||
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await testUsesCustomContentTypeHeader()
|
||||
await testSupportsBlobResponses()
|
||||
await testInjectsAuthenticatedUserHeaders()
|
||||
console.log('api-request tests passed')
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
13
web/tests/onlyoffice-service.test.mjs
Normal file
13
web/tests/onlyoffice-service.test.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { buildOnlyOfficeScriptUrl } from '../src/services/onlyoffice.js'
|
||||
|
||||
function run() {
|
||||
assert.equal(
|
||||
buildOnlyOfficeScriptUrl('http://127.0.0.1:8082/'),
|
||||
'http://127.0.0.1:8082/web-apps/apps/api/documents/api.js'
|
||||
)
|
||||
console.log('onlyoffice service tests passed')
|
||||
}
|
||||
|
||||
run()
|
||||
69
web/tests/policies-preview-formatters.test.mjs
Normal file
69
web/tests/policies-preview-formatters.test.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildExcelPreviewTable,
|
||||
buildPreviewMetaLine,
|
||||
buildPreviewSecondaryMetaLine
|
||||
} from '../src/views/scripts/policiesPreviewFormatters.js'
|
||||
|
||||
function testBuildPreviewMetaLineUsesRealDocumentFields() {
|
||||
const document = {
|
||||
summary: '财务知识库 · XLSX · 10.9 KB',
|
||||
time: '2026-05-09 12:30'
|
||||
}
|
||||
|
||||
assert.deepEqual(buildPreviewMetaLine(document), ['财务知识库 · XLSX · 10.9 KB', '2026-05-09 12:30'])
|
||||
}
|
||||
|
||||
function testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats() {
|
||||
const document = {
|
||||
previewKind: 'table',
|
||||
previewPages: [
|
||||
{
|
||||
subtitle: '表格内容预览',
|
||||
stats: [
|
||||
{ label: '工作表数量', value: '4' },
|
||||
{ label: '预览行数', value: '7' },
|
||||
{ label: '文件大小', value: '10.9 KB' }
|
||||
]
|
||||
},
|
||||
{
|
||||
subtitle: '第二页签预览',
|
||||
stats: [
|
||||
{ label: '工作表数量', value: '4' },
|
||||
{ label: '预览行数', value: '3' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[0]), ['表格内容预览', '工作表数量 4', '预览行数 7'])
|
||||
assert.deepEqual(buildPreviewSecondaryMetaLine(document, document.previewPages[1]), ['第二页签预览', '工作表数量 4', '预览行数 3'])
|
||||
}
|
||||
|
||||
function testBuildExcelPreviewTableParsesHeaderAndRows() {
|
||||
const page = {
|
||||
blocks: [
|
||||
{ heading: '第 1 行', lines: ['日期 | 部门 | 金额 | 备注'] },
|
||||
{ heading: '第 2 行', lines: ['2026-05-01 | 财务部 | 300 | 差旅'] },
|
||||
{ heading: '第 3 行', lines: ['2026-05-02 | 行政部 | 120 | '] }
|
||||
]
|
||||
}
|
||||
|
||||
assert.deepEqual(buildExcelPreviewTable(page), {
|
||||
headers: ['日期', '部门', '金额', '备注'],
|
||||
rows: [
|
||||
['2026-05-01', '财务部', '300', '差旅'],
|
||||
['2026-05-02', '行政部', '120', '']
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function run() {
|
||||
testBuildPreviewMetaLineUsesRealDocumentFields()
|
||||
testBuildPreviewSecondaryMetaLineForExcelUsesSubtitleAndStats()
|
||||
testBuildExcelPreviewTableParsesHeaderAndRows()
|
||||
console.log('policies preview formatter tests passed')
|
||||
}
|
||||
|
||||
run()
|
||||
Reference in New Issue
Block a user