From d9ffa9ce2cd653f0a140415a4912a469fb1d9db1 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Sat, 9 May 2026 04:25:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E3=80=81=E7=AD=96=E7=95=A5=E9=A2=84=E8=A7=88=E4=B8=8E?= =?UTF-8?q?OnlyOffice=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 配置与环境 - .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: 策略预览格式化测试 --- .env.example | 4 + docker-compose.yml | 22 + docker/README.md | 4 + server/pyproject.toml | 1 + server/src/app/api/v1/endpoints/knowledge.py | 48 ++ server/src/app/core/config.py | 12 +- server/src/app/schemas/knowledge.py | 11 + server/src/app/services/knowledge.py | 324 ++++++++-- server/storage/knowledge/.index.json | 17 +- web/src/assets/styles/views/policies-view.css | 397 ++++++++---- web/src/components/layout/TopBar.vue | 26 +- web/src/services/api.js | 73 ++- web/src/services/knowledge.js | 4 + web/src/services/onlyoffice.js | 43 ++ web/src/views/AppShellRouteView.vue | 4 +- web/src/views/PoliciesView.vue | 176 +++--- web/src/views/scripts/PoliciesView.js | 565 ++++++++++-------- .../scripts/policiesPreviewFormatters.js | 65 ++ web/tests/api-request.test.mjs | 99 +++ web/tests/onlyoffice-service.test.mjs | 13 + .../policies-preview-formatters.test.mjs | 69 +++ 21 files changed, 1469 insertions(+), 508 deletions(-) create mode 100644 web/src/services/onlyoffice.js create mode 100644 web/src/views/scripts/policiesPreviewFormatters.js create mode 100644 web/tests/api-request.test.mjs create mode 100644 web/tests/onlyoffice-service.test.mjs create mode 100644 web/tests/policies-preview-formatters.test.mjs diff --git a/.env.example b/.env.example index 3ae5bab..beb0a1c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index ae5a2b3..7982921 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/README.md b/docker/README.md index 14a6d4c..9abd576 100644 --- a/docker/README.md +++ b/docker/README.md @@ -20,6 +20,7 @@ http://: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. diff --git a/server/pyproject.toml b/server/pyproject.toml index 8dc9eb2..ec3e763 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -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", diff --git a/server/src/app/api/v1/endpoints/knowledge.py b/server/src/app/api/v1/endpoints/knowledge.py index 35def5f..a0ffcd0 100644 --- a/server/src/app/api/v1/endpoints/knowledge.py +++ b/server/src/app/api/v1/endpoints/knowledge.py @@ -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() diff --git a/server/src/app/core/config.py b/server/src/app/core/config.py index 5c98e25..3d24fdf 100644 --- a/server/src/app/core/config.py +++ b/server/src/app/core/config.py @@ -44,10 +44,14 @@ class Settings(BaseSettings): redis_url: str | None = Field(default=None, alias="REDIS_URL") cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS") - vite_api_base_url: str = Field( - default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL" - ) - + 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") log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED") diff --git a/server/src/app/schemas/knowledge.py b/server/src/app/schemas/knowledge.py index e8b181d..99b2fea 100644 --- a/server/src/app/schemas/knowledge.py +++ b/server/src/app/schemas/knowledge.py @@ -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) diff --git a/server/src/app/services/knowledge.py b/server/src/app/services/knowledge.py index 454002e..967e327 100644 --- a/server/src/app/services/knowledge.py +++ b/server/src/app/services/knowledge.py @@ -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", [["未提取到表格内容。"]])] - blocks = [ - KnowledgePreviewBlockRead( - heading=f"第 {index + 1} 行", - lines=[" | ".join(cell for cell in row if cell) or "(空行)"], + 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 or "") for cell in row)], + ) + for index, row in enumerate(visible_rows) + ] + + preview_pages.append( + KnowledgePreviewPageRead( + title=sheet_name, + subtitle="表格内容预览", + stats=[ + KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)), + KnowledgePreviewStatRead(label="预览行数", value=str(len(visible_rows))), + KnowledgePreviewStatRead(label="文件大小", value=self._format_size(entry["size_bytes"])), + ], + blocks=blocks, + ) ) - for index, row in enumerate(rows[:12]) - ] - return KnowledgePreviewPageRead( - title=entry["original_name"], - subtitle="表格内容预览", - stats=[ - KnowledgePreviewStatRead(label="工作表数量", value=str(sheet_count)), - KnowledgePreviewStatRead(label="预览行数", value=str(min(len(rows), 12))), - 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,40 +756,90 @@ 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])) - rows: list[list[str]] = [] - for row in first_sheet.iter(): - if not row.tag.endswith("}row"): + 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 - row_values: list[str] = [] - for cell in row: - if not cell.tag.endswith("}c"): - continue - cell_type = cell.attrib.get("t") - value_node = next((item for item in cell if item.tag.endswith("}v")), None) - 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) - row_values.append(shared_strings[index] if index < len(shared_strings) else raw_value) - else: - row_values.append(raw_value) - if row_values: - rows.append(row_values) - return rows, len(sheet_names) + sheet_root = ElementTree.fromstring(archive.read(target)) + rows: list[list[str]] = [] + for row in sheet_root.iter(): + if not row.tag.endswith("}row"): + continue + row_values: list[str] = [] + for cell in row: + if not cell.tag.endswith("}c"): + 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) + row_values.append(shared_strings[index] if index < len(shared_strings) else raw_value) + else: + row_values.append(raw_value) + if row_values: + rows.append(row_values) + + 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]]: diff --git a/server/storage/knowledge/.index.json b/server/storage/knowledge/.index.json index 56ca49d..aea83b8 100644 --- a/server/storage/knowledge/.index.json +++ b/server/storage/knowledge/.index.json @@ -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 + } + ] } \ No newline at end of file diff --git a/web/src/assets/styles/views/policies-view.css b/web/src/assets/styles/views/policies-view.css index ce8e6c0..a65e883 100644 --- a/web/src/assets/styles/views/policies-view.css +++ b/web/src/assets/styles/views/policies-view.css @@ -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, diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue index 6cacee0..b3cbbb1 100644 --- a/web/src/components/layout/TopBar.vue +++ b/web/src/components/layout/TopBar.vue @@ -113,7 +113,7 @@
{{ kpi.value }} {{ kpi.label }} - {{ kpi.meta }} + {{ kpi.meta }}
@@ -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 ?? {} diff --git a/web/src/services/api.js b/web/src/services/api.js index 1685ac6..363c344 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -1,4 +1,37 @@ const API_BASE_STORAGE_KEY = 'x-financial-api-base-url' +const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user' + +function readCurrentUserHeaders() { + if (typeof window === 'undefined') { + return {} + } + + const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY) + if (!raw) { + return {} + } + + try { + const payload = JSON.parse(raw) + const username = String(payload?.username || '').trim() + const name = String(payload?.name || username).trim() + const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : [] + const isAdmin = Boolean(payload?.isAdmin) + + if (!username && !name) { + return {} + } + + return { + 'x-auth-username': username, + 'x-auth-name': name, + 'x-auth-role-codes': roleCodes.join(','), + 'x-auth-is-admin': String(isAdmin) + } + } catch { + return {} + } +} function normalizeApiBaseUrl(value) { return String(value || '/api/v1').replace(/\/$/, '') @@ -66,22 +99,50 @@ function buildUrl(path) { } export async function apiRequest(path, options = {}) { + const { + contentType = 'application/json', + responseType = 'json', + headers: customHeaders, + ...fetchOptions + } = options + + const headers = { + ...readCurrentUserHeaders(), + ...(customHeaders || {}) + } + + if (contentType !== null && typeof headers['Content-Type'] === 'undefined') { + headers['Content-Type'] = contentType + } + let response try { response = await fetch(buildUrl(path), { - headers: { - 'Content-Type': 'application/json', - ...(options.headers || {}) - }, - ...options + ...fetchOptions, + headers }) } catch { throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。') } - let payload = null + if (responseType === 'blob') { + if (!response.ok) { + let payload = null + try { + payload = await response.json() + } catch { + payload = null + } + + throw new Error(payload?.detail || '接口请求失败,请稍后重试。') + } + + return response.blob() + } + + let payload = null try { payload = await response.json() } catch { diff --git a/web/src/services/knowledge.js b/web/src/services/knowledge.js index 6296772..82e4a69 100644 --- a/web/src/services/knowledge.js +++ b/web/src/services/knowledge.js @@ -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)}`, diff --git a/web/src/services/onlyoffice.js b/web/src/services/onlyoffice.js new file mode 100644 index 0000000..b4e4d6b --- /dev/null +++ b/web/src/services/onlyoffice.js @@ -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 +} diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index d8ce21a..7f4e701 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -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 @@ /> - + @@ -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, diff --git a/web/src/views/PoliciesView.vue b/web/src/views/PoliciesView.vue index 8b58888..08dc1e9 100644 --- a/web/src/views/PoliciesView.vue +++ b/web/src/views/PoliciesView.vue @@ -8,19 +8,14 @@

文档库 / 文件夹

默认展示文件列表,点击具体文件后可在右侧展开预览。

- - {{ selectedDocument ? '预览已展开' : '点击文件可预览' }} - +
-
+
+ - 拖拽文档到此处,或点击上传 - 支持 PDF / Word / Excel / PPT 文档,单个文件不超过 100MB + {{ isAdmin ? (uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传') : '知识文件只读查阅' }} + {{ uploadHint }}
@@ -64,10 +72,10 @@ @@ -83,9 +91,26 @@ {{ doc.state }} {{ doc.owner }} - +
+ + +
+ + + + + {{ loading ? '正在加载知识库文件...' : '当前文件夹暂无文件' }} @@ -93,17 +118,6 @@