diff --git a/README.md b/README.md index 91bb2968..e4bfee7f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ - Web 与桌面端共用产品版本;执行 `npm run version:set -- ` 统一升级,执行 `npm run version:check` 检查漂移。 - 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`。 +## ONLYOFFICE 开发预览 + +- 普通开发环境仍不强制启动 Document Server。 +- 需要 Office 只读预览时执行 `bash scripts/onlyoffice-dev-up.sh`。 +- 脚本会自动生成并持久化本机开发密钥,不需要手工填写;详细说明见 `docs/guides/onlyoffice-preview.md`。 + ## 仓库治理文档 - 分支治理规范:`docs/branch-governance.md` - 分支维护中文 SOP:`docs/guides/branch-maintenance-sop-zh.md` diff --git a/backend/app/api/v1/onlyoffice.py b/backend/app/api/v1/onlyoffice.py new file mode 100644 index 00000000..278d1b67 --- /dev/null +++ b/backend/app/api/v1/onlyoffice.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import json +import os +import uuid +from pathlib import Path +from urllib.parse import quote + +from fastapi import APIRouter, Depends, Request, Response, status +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists +from app.core.deps import get_current_user, get_db_session, get_operator_role_label +from app.crud import attachment as attachment_crud +from app.crud import audit as audit_crud +from app.crud import document as document_crud +from app.crud import document_version as version_crud +from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead +from app.services import document_service, onlyoffice_service + +router = APIRouter() +internal_router = APIRouter(include_in_schema=False) + + +def _content_disposition(filename: str) -> str: + fallback = "".join( + character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_" + for character in filename + ) or "document" + encoded = quote(filename, safe="") + return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}' + + +async def _log_preview_open( + db: AsyncSession, + *, + study_id: uuid.UUID, + resource_type: str, + resource_id: uuid.UUID, + current_user, +) -> None: + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="ATTACHMENT" if resource_type == "attachment" else "DOCUMENT_VERSION", + entity_id=resource_id, + action="OFFICE_PREVIEW_OPEN", + detail=json.dumps( + {"resource_type": resource_type, "resource_id": str(resource_id), "result": "issued"}, + ensure_ascii=True, + ), + operator_id=current_user.id, + operator_role=await get_operator_role_label(db, study_id, current_user), + ) + + +@router.get( + "/attachments/{attachment_id}/config", + response_model=OnlyOfficePreviewConfigRead, +) +async def get_attachment_preview_config( + attachment_id: uuid.UUID, + response: Response, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> OnlyOfficePreviewConfigRead: + attachment = await attachment_crud.get_attachment(db, attachment_id) + if not attachment: + raise onlyoffice_service.onlyoffice_error( + "ATTACHMENT_NOT_FOUND", "附件不存在", status.HTTP_404_NOT_FOUND + ) + await _ensure_study_exists(db, attachment.study_id) + await _ensure_attachment_permission( + db, + attachment.study_id, + attachment.entity_type, + attachment.entity_id, + "read", + current_user, + ) + if not os.path.exists(attachment.file_path): + raise onlyoffice_service.onlyoffice_error( + "ATTACHMENT_FILE_NOT_FOUND", "服务器未找到文件", status.HTTP_404_NOT_FOUND + ) + if not onlyoffice_service.office_format_for_filename(attachment.filename): + raise onlyoffice_service.onlyoffice_error( + "ONLYOFFICE_FORMAT_UNSUPPORTED", + "该文件格式不支持 Office 在线预览", + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + ) + await onlyoffice_service.ensure_onlyoffice_available() + result = onlyoffice_service.build_preview_config( + resource_type="attachment", + resource_id=attachment.id, + file_name=attachment.filename, + user_id=current_user.id, + user_name=current_user.full_name, + ) + await _log_preview_open( + db, + study_id=attachment.study_id, + resource_type="attachment", + resource_id=attachment.id, + current_user=current_user, + ) + response.headers["Cache-Control"] = "no-store" + return result + + +@router.get( + "/versions/{version_id}/config", + response_model=OnlyOfficePreviewConfigRead, +) +async def get_version_preview_config( + version_id: uuid.UUID, + response: Response, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> OnlyOfficePreviewConfigRead: + version = await version_crud.get(db, version_id) + if not version: + raise onlyoffice_service.onlyoffice_error( + "DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND + ) + document = await document_crud.get(db, version.document_id) + if not document: + raise onlyoffice_service.onlyoffice_error( + "DOCUMENT_NOT_FOUND", "文档不存在", status.HTTP_404_NOT_FOUND + ) + await document_service._ensure_study_access(db, document.trial_id, current_user, action="view") + file_path = Path(version.file_uri) + if not file_path.exists(): + raise onlyoffice_service.onlyoffice_error( + "DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND + ) + file_name = version.original_filename or document_service._legacy_download_filename(version, document) + if not onlyoffice_service.office_format_for_filename(file_name): + raise onlyoffice_service.onlyoffice_error( + "ONLYOFFICE_FORMAT_UNSUPPORTED", + "该文件格式不支持 Office 在线预览", + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + ) + await onlyoffice_service.ensure_onlyoffice_available() + result = onlyoffice_service.build_preview_config( + resource_type="version", + resource_id=version.id, + file_name=file_name, + file_hash=version.file_hash, + user_id=current_user.id, + user_name=current_user.full_name, + ) + await _log_preview_open( + db, + study_id=document.trial_id, + resource_type="version", + resource_id=version.id, + current_user=current_user, + ) + response.headers["Cache-Control"] = "no-store" + return result + + +def _authorize_internal_file_request(request: Request, expected_url: str) -> None: + onlyoffice_service.validate_outbox_token(request.headers.get("AuthorizationJwt"), expected_url) + + +@internal_router.get("/internal/onlyoffice/attachments/{attachment_id}/content") +async def get_internal_attachment_content( + attachment_id: uuid.UUID, + request: Request, + db: AsyncSession = Depends(get_db_session), +) -> FileResponse: + expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment_id) + _authorize_internal_file_request(request, expected_url) + attachment = await attachment_crud.get_attachment(db, attachment_id) + if not attachment or not os.path.exists(attachment.file_path): + raise onlyoffice_service.onlyoffice_error( + "ATTACHMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND + ) + return FileResponse( + path=attachment.file_path, + media_type=attachment.content_type or "application/octet-stream", + headers={"Content-Disposition": _content_disposition(attachment.filename)}, + ) + + +@internal_router.get("/internal/onlyoffice/versions/{version_id}/content") +async def get_internal_version_content( + version_id: uuid.UUID, + request: Request, + db: AsyncSession = Depends(get_db_session), +) -> FileResponse: + expected_url = onlyoffice_service.onlyoffice_content_url("version", version_id) + _authorize_internal_file_request(request, expected_url) + version = await version_crud.get(db, version_id) + if not version: + raise onlyoffice_service.onlyoffice_error( + "DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND + ) + document = await document_crud.get(db, version.document_id) + file_path = Path(version.file_uri) + if not document or not file_path.exists(): + raise onlyoffice_service.onlyoffice_error( + "DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND + ) + file_name = version.original_filename or document_service._legacy_download_filename(version, document) + return FileResponse( + path=str(file_path), + media_type=version.mime_type or "application/octet-stream", + headers={"Content-Disposition": _content_disposition(file_name)}, + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 5db13f67..30eaeb55 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles +from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice api_router = APIRouter() @@ -17,6 +17,7 @@ api_router.include_router(api_permissions.router, tags=["api-permissions"]) api_router.include_router(api_permissions.study_router, prefix="/studies/{study_id}", tags=["api-permissions"]) api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"]) api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"]) +api_router.include_router(onlyoffice.router, prefix="/onlyoffice", tags=["onlyoffice"]) api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"]) api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"]) api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7ee125fe..ed90ac6c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,5 +1,6 @@ from functools import lru_cache from typing import Literal, Optional +from urllib.parse import urlsplit from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -50,6 +51,12 @@ class Settings(BaseSettings): MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800) USER_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650) USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600) + ONLYOFFICE_ENABLED: bool = False + ONLYOFFICE_JWT_SECRET: Optional[str] = None + ONLYOFFICE_INTERNAL_URL: str = "http://onlyoffice" + ONLYOFFICE_STORAGE_BASE_URL: str = "http://backend:8000" + ONLYOFFICE_INSTANCE_ID: Optional[str] = None + ONLYOFFICE_CONFIG_TTL_SECONDS: int = Field(default=300, ge=60, le=900) @lru_cache @@ -60,6 +67,30 @@ def get_settings() -> Settings: settings = get_settings() +def validate_onlyoffice_configuration() -> None: + if not settings.ONLYOFFICE_ENABLED: + return + secret = (settings.ONLYOFFICE_JWT_SECRET or "").strip() + instance_id = (settings.ONLYOFFICE_INSTANCE_ID or "").strip() + if secret == settings.JWT_SECRET_KEY: + raise RuntimeError("ONLYOFFICE_JWT_SECRET must not reuse JWT_SECRET_KEY") + if len(secret) < 32: + raise RuntimeError("ONLYOFFICE_JWT_SECRET must contain at least 32 characters when ONLYOFFICE is enabled") + if not instance_id: + raise RuntimeError("ONLYOFFICE_INSTANCE_ID is required when ONLYOFFICE is enabled") + for name, value in ( + ("ONLYOFFICE_INTERNAL_URL", settings.ONLYOFFICE_INTERNAL_URL), + ("ONLYOFFICE_STORAGE_BASE_URL", settings.ONLYOFFICE_STORAGE_BASE_URL), + ): + parsed = urlsplit(value.strip()) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname: + raise RuntimeError(f"{name} must be an HTTP(S) URL") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise RuntimeError(f"{name} must not contain credentials, a query, or a fragment") + if parsed.path not in {"", "/"}: + raise RuntimeError(f"{name} must not contain a path") + + def get_cors_allowed_origins() -> list[str]: return [ origin.strip() diff --git a/backend/app/main.py b/backend/app/main.py index d3c6b701..97901745 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,8 @@ from fastapi.responses import JSONResponse from sqlalchemy import text from app.api.v1.router import api_router -from app.core.config import get_cors_allowed_origins, settings +from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_router +from app.core.config import get_cors_allowed_origins, settings, validate_onlyoffice_configuration from app.core.exceptions import register_exception_handlers from app.core.login_crypto import validate_login_crypto_configuration from app.crud.user import ensure_admin_exists @@ -111,6 +112,7 @@ async def _ensure_legacy_primary_keys(conn) -> None: def create_app() -> FastAPI: validate_login_crypto_configuration() + validate_onlyoffice_configuration() app = FastAPI( title="CTMS 后端 API", description="临床试验项目管理系统后端接口文档", @@ -297,6 +299,7 @@ def create_app() -> FastAPI: return {"totals": totals, "by_endpoint": summary} app.include_router(api_router, prefix="/api/v1") + app.include_router(onlyoffice_internal_router) return app diff --git a/backend/app/schemas/onlyoffice.py b/backend/app/schemas/onlyoffice.py new file mode 100644 index 00000000..3c3d48f2 --- /dev/null +++ b/backend/app/schemas/onlyoffice.py @@ -0,0 +1,14 @@ +from datetime import datetime +from typing import Any, Literal +import uuid + +from pydantic import BaseModel + + +class OnlyOfficePreviewConfigRead(BaseModel): + resource_type: Literal["attachment", "version"] + resource_id: uuid.UUID + file_name: str + host_path: str = "/onlyoffice-host.html" + expires_at: datetime + config: dict[str, Any] diff --git a/backend/app/services/onlyoffice_service.py b/backend/app/services/onlyoffice_service.py new file mode 100644 index 00000000..577e4e72 --- /dev/null +++ b/backend/app/services/onlyoffice_service.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import time +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Literal + +import httpx +from fastapi import status +from jose import JWTError, jwt + +from app.core.config import settings +from app.core.exceptions import AppException +from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead + +OnlyOfficeDocumentType = Literal["word", "cell", "slide"] +OnlyOfficeResourceType = Literal["attachment", "version"] + +WORD_FORMATS = frozenset({ + "doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt", +}) +CELL_FORMATS = frozenset({ + "xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm", "ods", "ots", "csv", "et", "ett", +}) +SLIDE_FORMATS = frozenset({ + "ppt", "pptx", "pptm", "pps", "ppsx", "ppsm", "pot", "potx", "potm", "odp", "otp", "dps", "dpt", +}) + +_health_lock = asyncio.Lock() +_health_checked_at = 0.0 +_health_available = False +_HEALTH_CACHE_SECONDS = 15.0 + + +def onlyoffice_error(code: str, message: str, status_code: int) -> AppException: + return AppException(code=code, message=message, status_code=status_code) + + +def office_format_for_filename(filename: str) -> tuple[str, OnlyOfficeDocumentType] | None: + suffix = Path(filename).suffix.lower().lstrip(".") + if suffix in WORD_FORMATS: + return suffix, "word" + if suffix in CELL_FORMATS: + return suffix, "cell" + if suffix in SLIDE_FORMATS: + return suffix, "slide" + return None + + +def onlyoffice_content_url(resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID) -> str: + plural = "attachments" if resource_type == "attachment" else "versions" + return ( + f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}" + f"/internal/onlyoffice/{plural}/{resource_id}/content" + ) + + +def onlyoffice_document_key( + resource_type: OnlyOfficeResourceType, + resource_id: uuid.UUID, + *, + file_hash: str | None = None, +) -> str: + instance_id = (settings.ONLYOFFICE_INSTANCE_ID or "").strip() + fingerprint = f"{instance_id}{resource_type}{resource_id}" + if resource_type == "version": + fingerprint = f"{fingerprint}{file_hash or ''}" + return f"ctms-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}" + + +async def ensure_onlyoffice_available() -> None: + global _health_available, _health_checked_at + if not settings.ONLYOFFICE_ENABLED: + raise onlyoffice_error( + "ONLYOFFICE_DISABLED", + "Office 预览服务尚未启用", + status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + now = time.monotonic() + if now - _health_checked_at < _HEALTH_CACHE_SECONDS: + if _health_available: + return + raise onlyoffice_error( + "ONLYOFFICE_UNAVAILABLE", + "Office 预览服务暂不可用,请稍后重试", + status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + async with _health_lock: + now = time.monotonic() + if now - _health_checked_at >= _HEALTH_CACHE_SECONDS: + available = False + try: + async with httpx.AsyncClient(timeout=2.0, follow_redirects=False) as client: + response = await client.get(f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/healthcheck") + available = response.status_code == status.HTTP_200_OK + except httpx.HTTPError: + available = False + _health_available = available + _health_checked_at = now + + if not _health_available: + raise onlyoffice_error( + "ONLYOFFICE_UNAVAILABLE", + "Office 预览服务暂不可用,请稍后重试", + status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + +def build_preview_config( + *, + resource_type: OnlyOfficeResourceType, + resource_id: uuid.UUID, + file_name: str, + user_id: uuid.UUID, + user_name: str, + file_hash: str | None = None, +) -> OnlyOfficePreviewConfigRead: + format_info = office_format_for_filename(file_name) + if not format_info: + raise onlyoffice_error( + "ONLYOFFICE_FORMAT_UNSUPPORTED", + "该文件格式不支持 Office 在线预览", + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + ) + file_type, document_type = format_info + now = datetime.now(timezone.utc) + expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS) + config: dict[str, Any] = { + "type": "desktop", + "documentType": document_type, + "document": { + "fileType": file_type, + "key": onlyoffice_document_key(resource_type, resource_id, file_hash=file_hash), + "title": file_name, + "url": onlyoffice_content_url(resource_type, resource_id), + "permissions": { + "copy": False, + "comment": False, + "download": False, + "edit": False, + "fillForms": False, + "modifyContentControl": False, + "modifyFilter": False, + "print": False, + "review": False, + }, + }, + "editorConfig": { + "coEditing": {"mode": "strict", "change": False}, + "customization": { + "chat": False, + "comments": False, + "forcesave": False, + }, + "lang": "zh-CN", + "mode": "view", + "user": {"id": str(user_id), "name": user_name}, + }, + } + token_payload = { + **config, + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + } + config["token"] = jwt.encode( + token_payload, + settings.ONLYOFFICE_JWT_SECRET or "", + algorithm="HS256", + ) + return OnlyOfficePreviewConfigRead( + resource_type=resource_type, + resource_id=resource_id, + file_name=file_name, + expires_at=expires_at, + config=config, + ) + + +def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any]: + if not token: + raise onlyoffice_error( + "ONLYOFFICE_SOURCE_UNAUTHORIZED", + "无法验证 Office 文件请求", + status.HTTP_401_UNAUTHORIZED, + ) + token = token.strip() + if " " in token: + scheme, credential = token.split(" ", 1) + if scheme.lower() != "bearer" or not credential.strip(): + raise onlyoffice_error( + "ONLYOFFICE_SOURCE_UNAUTHORIZED", + "无法验证 Office 文件请求", + status.HTTP_401_UNAUTHORIZED, + ) + token = credential.strip() + try: + header = jwt.get_unverified_header(token) + if header.get("alg") != "HS256": + raise JWTError("unexpected algorithm") + payload = jwt.decode( + token, + settings.ONLYOFFICE_JWT_SECRET or "", + algorithms=["HS256"], + options={"verify_aud": False}, + ) + except JWTError as exc: + raise onlyoffice_error( + "ONLYOFFICE_SOURCE_UNAUTHORIZED", + "无法验证 Office 文件请求", + status.HTTP_401_UNAUTHORIZED, + ) from exc + + request_payload = payload.get("payload") + token_url = request_payload.get("url") if isinstance(request_payload, dict) else None + if not isinstance(token_url, str) or not hmac.compare_digest(token_url, expected_url): + raise onlyoffice_error( + "ONLYOFFICE_SOURCE_URL_MISMATCH", + "Office 文件请求地址不匹配", + status.HTTP_401_UNAUTHORIZED, + ) + return payload diff --git a/backend/tests/test_onlyoffice_api.py b/backend/tests/test_onlyoffice_api.py new file mode 100644 index 00000000..ee16031b --- /dev/null +++ b/backend/tests/test_onlyoffice_api.py @@ -0,0 +1,136 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +import uuid + +import pytest +from fastapi import Response +from jose import jwt +from starlette.requests import Request + +from app.api.v1 import onlyoffice as onlyoffice_api +from app.core.config import settings +from app.core.exceptions import AppException +from app.services import onlyoffice_service + + +@pytest.fixture(autouse=True) +def onlyoffice_settings(monkeypatch): + monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", True) + monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough") + monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "ctms-test") + monkeypatch.setattr(settings, "ONLYOFFICE_STORAGE_BASE_URL", "http://backend:8000") + + +@pytest.mark.asyncio +async def test_attachment_config_reuses_permission_and_preserves_original_name(monkeypatch, tmp_path): + resource_id = uuid.uuid4() + study_id = uuid.uuid4() + entity_id = uuid.uuid4() + user = SimpleNamespace(id=uuid.uuid4(), full_name="预览用户") + source_file = tmp_path / "stored-uuid" + source_file.write_bytes(b"office") + attachment = SimpleNamespace( + id=resource_id, + study_id=study_id, + entity_type="startup_initiation", + entity_id=entity_id, + filename="研究方案最终版.DOCX", + file_path=str(source_file), + content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + permission = AsyncMock() + audit = AsyncMock() + monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment)) + monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock()) + monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission) + monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock()) + monkeypatch.setattr(onlyoffice_api, "_log_preview_open", audit) + response = Response() + db = object() + + result = await onlyoffice_api.get_attachment_preview_config(resource_id, response, db, user) + + permission.assert_awaited_once_with(db, study_id, "startup_initiation", entity_id, "read", user) + assert result.file_name == "研究方案最终版.DOCX" + assert result.config["document"]["title"] == "研究方案最终版.DOCX" + assert response.headers["Cache-Control"] == "no-store" + audit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_attachment_permission_failure_does_not_issue_config(monkeypatch, tmp_path): + source_file = tmp_path / "source.xlsx" + source_file.write_bytes(b"office") + attachment = SimpleNamespace( + id=uuid.uuid4(), study_id=uuid.uuid4(), entity_type="ethics", entity_id=uuid.uuid4(), + filename="数据.xlsx", file_path=str(source_file), + ) + denied = AppException(code="FORBIDDEN", message="权限不足", status_code=403) + monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment)) + monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock()) + monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", AsyncMock(side_effect=denied)) + health = AsyncMock() + monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", health) + + with pytest.raises(AppException) as error: + await onlyoffice_api.get_attachment_preview_config( + attachment.id, Response(), object(), SimpleNamespace(id=uuid.uuid4(), full_name="无权限用户") + ) + + assert error.value.status_code == 403 + health.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_version_config_reuses_document_access_and_version_hash(monkeypatch, tmp_path): + source_file = tmp_path / "uuid-storage" + source_file.write_bytes(b"office") + version = SimpleNamespace( + id=uuid.uuid4(), document_id=uuid.uuid4(), file_uri=str(source_file), original_filename="统计表.xlsx", + file_hash="sha256-current", mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + document = SimpleNamespace(id=version.document_id, trial_id=uuid.uuid4()) + user = SimpleNamespace(id=uuid.uuid4(), full_name="管理员") + access = AsyncMock() + monkeypatch.setattr(onlyoffice_api.version_crud, "get", AsyncMock(return_value=version)) + monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document)) + monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access) + monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock()) + monkeypatch.setattr(onlyoffice_api, "_log_preview_open", AsyncMock()) + + result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user) + + assert access.await_args.args[1:] == (document.trial_id, user) + assert access.await_args.kwargs == {"action": "view"} + assert result.file_name == "统计表.xlsx" + assert result.config["document"]["key"] == onlyoffice_service.onlyoffice_document_key( + "version", version.id, file_hash="sha256-current" + ) + + +@pytest.mark.asyncio +async def test_internal_attachment_requires_url_bound_jwt_and_returns_original_name(monkeypatch, tmp_path): + source_file = tmp_path / "uuid-storage-name" + source_file.write_bytes(b"office") + attachment = SimpleNamespace( + id=uuid.uuid4(), filename="中文原始文件名.xlsx", file_path=str(source_file), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment.id) + token = jwt.encode({"payload": {"url": expected_url}}, settings.ONLYOFFICE_JWT_SECRET, algorithm="HS256") + request = Request({ + "type": "http", "method": "GET", "path": f"/internal/onlyoffice/attachments/{attachment.id}/content", + "headers": [(b"authorizationjwt", token.encode())], + }) + monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment)) + + response = await onlyoffice_api.get_internal_attachment_content(attachment.id, request, object()) + + assert "uuid-storage-name" not in response.headers["content-disposition"] + assert "UTF-8''" in response.headers["content-disposition"] + assert response.media_type == attachment.content_type + + missing_token_request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + with pytest.raises(AppException) as error: + await onlyoffice_api.get_internal_attachment_content(attachment.id, missing_token_request, object()) + assert error.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED" diff --git a/backend/tests/test_onlyoffice_service.py b/backend/tests/test_onlyoffice_service.py new file mode 100644 index 00000000..43448111 --- /dev/null +++ b/backend/tests/test_onlyoffice_service.py @@ -0,0 +1,194 @@ +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from jose import jwt + +from app.core.config import settings, validate_onlyoffice_configuration +from app.core.exceptions import AppException +from app.services import onlyoffice_service + + +@pytest.fixture(autouse=True) +def onlyoffice_settings(monkeypatch): + monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", True) + monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough") + monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "ctms-test") + monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://onlyoffice") + monkeypatch.setattr(settings, "ONLYOFFICE_STORAGE_BASE_URL", "http://backend:8000") + monkeypatch.setattr(settings, "ONLYOFFICE_CONFIG_TTL_SECONDS", 300) + + +@pytest.mark.parametrize( + ("filename", "expected"), + [ + ("方案.DOCX", ("docx", "word")), + ("数据.xlsx", ("xlsx", "cell")), + ("培训.PPTX", ("pptx", "slide")), + ("国产格式.wps", ("wps", "word")), + ("国产格式.et", ("et", "cell")), + ("国产格式.dps", ("dps", "slide")), + ("扫描件.pdf", None), + ("图片.png", None), + ], +) +def test_office_format_contract(filename, expected): + assert onlyoffice_service.office_format_for_filename(filename) == expected + + +def test_all_supported_format_groups_match_the_public_contract(): + expected = { + **{extension: "word" for extension in ("doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt")}, + **{extension: "cell" for extension in ("xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm", "ods", "ots", "csv", "et", "ett")}, + **{extension: "slide" for extension in ("ppt", "pptx", "pptm", "pps", "ppsx", "ppsm", "pot", "potx", "potm", "odp", "otp", "dps", "dpt")}, + } + for extension, document_type in expected.items(): + assert onlyoffice_service.office_format_for_filename(f"原始文件名.{extension.upper()}") == ( + extension, + document_type, + ) + + +def test_preview_config_is_read_only_and_uses_original_filename(): + resource_id = uuid.uuid4() + user_id = uuid.uuid4() + + result = onlyoffice_service.build_preview_config( + resource_type="attachment", + resource_id=resource_id, + file_name="研究方案 V1.0.docx", + user_id=user_id, + user_name="测试用户", + ) + + document = result.config["document"] + permissions = document["permissions"] + assert result.file_name == "研究方案 V1.0.docx" + assert document["title"] == "研究方案 V1.0.docx" + assert document["fileType"] == "docx" + assert result.config["documentType"] == "word" + assert result.config["editorConfig"]["mode"] == "view" + assert all(permissions[key] is False for key in ("copy", "comment", "download", "edit", "fillForms", "print", "review")) + assert "token=" not in document["url"] + assert "access_token=" not in document["url"] + assert document["url"].endswith(f"/internal/onlyoffice/attachments/{resource_id}/content") + + payload = jwt.decode( + result.config["token"], + settings.ONLYOFFICE_JWT_SECRET, + algorithms=["HS256"], + ) + assert payload["document"]["title"] == "研究方案 V1.0.docx" + assert payload["editorConfig"]["user"] == {"id": str(user_id), "name": "测试用户"} + assert payload["exp"] - payload["iat"] == settings.ONLYOFFICE_CONFIG_TTL_SECONDS + + +def test_document_key_is_stable_and_version_hash_changes_it(): + resource_id = uuid.uuid4() + first = onlyoffice_service.onlyoffice_document_key("version", resource_id, file_hash="hash-a") + repeated = onlyoffice_service.onlyoffice_document_key("version", resource_id, file_hash="hash-a") + changed = onlyoffice_service.onlyoffice_document_key("version", resource_id, file_hash="hash-b") + + assert first == repeated + assert first != changed + assert first.startswith("ctms-") + assert len(first) <= 128 + + +def test_outbox_token_must_be_hs256_and_bound_to_exact_url(): + resource_id = uuid.uuid4() + expected_url = onlyoffice_service.onlyoffice_content_url("attachment", resource_id) + token = jwt.encode({"payload": {"url": expected_url}}, settings.ONLYOFFICE_JWT_SECRET, algorithm="HS256") + + assert onlyoffice_service.validate_outbox_token(token, expected_url)["payload"]["url"] == expected_url + assert onlyoffice_service.validate_outbox_token(f"Bearer {token}", expected_url)["payload"]["url"] == expected_url + + with pytest.raises(AppException) as mismatch: + onlyoffice_service.validate_outbox_token(token, f"{expected_url}-other") + assert mismatch.value.code == "ONLYOFFICE_SOURCE_URL_MISMATCH" + + config_token = jwt.encode( + {"document": {"url": expected_url}}, + settings.ONLYOFFICE_JWT_SECRET, + algorithm="HS256", + ) + with pytest.raises(AppException) as wrong_purpose: + onlyoffice_service.validate_outbox_token(config_token, expected_url) + assert wrong_purpose.value.code == "ONLYOFFICE_SOURCE_URL_MISMATCH" + + legacy_top_level_url = jwt.encode( + {"url": expected_url}, + settings.ONLYOFFICE_JWT_SECRET, + algorithm="HS256", + ) + with pytest.raises(AppException) as wrong_shape: + onlyoffice_service.validate_outbox_token(legacy_top_level_url, expected_url) + assert wrong_shape.value.code == "ONLYOFFICE_SOURCE_URL_MISMATCH" + + substituted_algorithm = jwt.encode( + {"payload": {"url": expected_url}}, + settings.ONLYOFFICE_JWT_SECRET, + algorithm="HS512", + ) + with pytest.raises(AppException) as invalid_algorithm: + onlyoffice_service.validate_outbox_token(substituted_algorithm, expected_url) + assert invalid_algorithm.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED" + + with pytest.raises(AppException) as missing: + onlyoffice_service.validate_outbox_token(None, expected_url) + assert missing.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED" + + with pytest.raises(AppException) as wrong_scheme: + onlyoffice_service.validate_outbox_token(f"Basic {token}", expected_url) + assert wrong_scheme.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED" + + +@pytest.mark.asyncio +async def test_health_probe_is_cached_for_fifteen_seconds(monkeypatch): + onlyoffice_service._health_checked_at = 0.0 + onlyoffice_service._health_available = False + request = type("Response", (), {"status_code": 200})() + get = AsyncMock(return_value=request) + client = MagicMock() + client.__aenter__ = AsyncMock(return_value=type("Client", (), {"get": get})()) + client.__aexit__ = AsyncMock(return_value=False) + monkeypatch.setattr(onlyoffice_service.httpx, "AsyncClient", MagicMock(return_value=client)) + + await onlyoffice_service.ensure_onlyoffice_available() + await onlyoffice_service.ensure_onlyoffice_available() + + get.assert_awaited_once() + + +def test_enabled_configuration_requires_a_distinct_secret_and_instance(monkeypatch): + validate_onlyoffice_configuration() + + monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", settings.JWT_SECRET_KEY) + with pytest.raises(RuntimeError, match="must not reuse"): + validate_onlyoffice_configuration() + + monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough") + monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "") + with pytest.raises(RuntimeError, match="INSTANCE_ID"): + validate_onlyoffice_configuration() + + +def test_enabled_configuration_rejects_unsafe_service_urls(monkeypatch): + monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://user:password@onlyoffice") + with pytest.raises(RuntimeError, match="credentials"): + validate_onlyoffice_configuration() + + monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://onlyoffice/internal-path") + with pytest.raises(RuntimeError, match="must not contain a path"): + validate_onlyoffice_configuration() + + +@pytest.mark.asyncio +async def test_disabled_service_returns_stable_error(monkeypatch): + monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", False) + + with pytest.raises(AppException) as error: + await onlyoffice_service.ensure_onlyoffice_available() + + assert error.value.code == "ONLYOFFICE_DISABLED" + assert error.value.status_code == 503 diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index cc3f52b2..c2810a08 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -14,6 +14,11 @@ services: file: docker-compose.yaml service: backend-init + onlyoffice: + extends: + file: docker-compose.yaml + service: onlyoffice + frontend-dev: image: node:22.13-alpine container_name: ctms_frontend_dev @@ -77,6 +82,9 @@ services: volumes: frontend_node_modules_node22: + onlyoffice_data: + onlyoffice_lib: + onlyoffice_logs: networks: ctms_net: diff --git a/docker-compose.yaml b/docker-compose.yaml index a425eaf8..6da1bc5a 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -46,6 +46,12 @@ services: MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: ${MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS:-2.5} MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS: ${MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS:-604800} MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: ${MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS:-10} + ONLYOFFICE_ENABLED: ${ONLYOFFICE_ENABLED:-false} + ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} + ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice} + ONLYOFFICE_STORAGE_BASE_URL: ${ONLYOFFICE_STORAGE_BASE_URL:-http://backend:8000} + ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:-} + ONLYOFFICE_CONFIG_TTL_SECONDS: ${ONLYOFFICE_CONFIG_TTL_SECONDS:-300} depends_on: db: condition: service_healthy @@ -65,6 +71,40 @@ services: networks: - ctms_net + onlyoffice: + build: + context: ./onlyoffice + dockerfile: Dockerfile + args: + ONLYOFFICE_IMAGE: ${ONLYOFFICE_IMAGE:-onlyoffice/documentserver:9.4.0.1} + image: ctms-onlyoffice:9.4.0.1 + container_name: ctms_onlyoffice + restart: always + profiles: ["office"] + environment: + JWT_ENABLED: "true" + JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} + JWT_HEADER: AuthorizationJwt + JWT_IN_BODY: "false" + EXAMPLE_ENABLED: "false" + WOPI_ENABLED: "false" + PLUGINS_ENABLED: "false" + ALLOW_PRIVATE_IP_ADDRESS: "true" + shm_size: "2gb" + stop_grace_period: 60s + volumes: + - onlyoffice_data:/var/www/onlyoffice/Data + - onlyoffice_lib:/var/lib/onlyoffice + - onlyoffice_logs:/var/log/onlyoffice + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1/healthcheck"] + interval: 15s + timeout: 5s + retries: 12 + start_period: 60s + networks: + - ctms_net + backend-init: build: context: ./backend @@ -110,3 +150,8 @@ services: networks: ctms_net: driver: bridge + +volumes: + onlyoffice_data: + onlyoffice_lib: + onlyoffice_logs: diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index ab9d07c0..b0a25131 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -2,7 +2,7 @@ 状态: `active` 适用范围: Web 与 macOS Desktop 统一客户端发布 -最后更新: `2026-07-08` +最后更新: `2026-07-14` 本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。当前允许按 `docs/desktop-local-cache-plan.md` 引入在线辅助本地缓存,但不引入离线登录、离线写入、本地业务权威数据、内嵌后端服务或离线同步。 @@ -44,7 +44,11 @@ npm run desktop:build:app - [ ] Tauri bundle 启用 `app`、`dmg` 和 updater artifacts。 - [ ] updater public key 已配置。 - [ ] CSP 禁止 wildcard source、`unsafe-eval`、宽泛 HTTP API 访问和 `object-src`。 +- [ ] `script-src` 保持仅允许自身;ONLYOFFICE 仅在 `frame-src` 增加 HTTPS 和 localhost/127.0.0.1 开发来源。 +- [ ] ONLYOFFICE frame 地址只由 `frontend/src/runtime/onlyoffice.ts` 使用已校验服务器 origin 与固定 `/onlyoffice-host.html` 生成。 +- [ ] ONLYOFFICE 宿主消息同时校验 `origin`、`source` 和一次性内存 nonce,配置不进入本地缓存或请求去重。 - [ ] Tauri capability 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。 +- [ ] Tauri capability 不包含 `remote` 授权,远程 ONLYOFFICE frame 无文件、通知、对话框或 opener 权限。 - [ ] 文件系统仅开放临时文件所需的 read/write/mkdir/remove 命令,且文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。 - [ ] 单实例插件先于其他桌面插件注册。 - [ ] macOS 首个顶层 submenu 为应用菜单,包含关于、设置、服务、隐藏和退出;文件菜单保持独立。 @@ -90,6 +94,8 @@ npm run desktop:build:app | mutation 缓存失效 | 必测 | 必测 | 新增、编辑、删除或审批成功后相关列表、详情、统计和图表缓存失效 | | 附件上传 | 必测 | 必测 | Web 使用浏览器文件选择,Desktop 使用原生选择 | | 附件下载/保存/打开 | 必测 | 必测 | 使用 Authorization header;无 `?token=` | +| Office 附件/版本只读预览 | 必测 | 必测 | 独立全幅页面;不下载到本地;无编辑、复制、打印、下载入口;标签失活和服务器切换立即销毁 | +| ONLYOFFICE 服务不可用 | 必测 | 必测 | 显示可重试错误,不回退 iframe 或自动下载;原保存/打开功能不受影响 | | 临时文件清理 | 不适用 | 必测 | 启动时清理 `$TEMP/ctms-desktop/**` | | 系统通知开启 | 不适用 | 必测 | 用户主动开启后请求 OS 权限并创建订阅 | | 系统通知拒绝 | 不适用 | 必测 | 开关回退,提示系统权限未开启 | diff --git a/docs/guides/onlyoffice-preview.md b/docs/guides/onlyoffice-preview.md new file mode 100644 index 00000000..e2a88ef3 --- /dev/null +++ b/docs/guides/onlyoffice-preview.md @@ -0,0 +1,45 @@ +# ONLYOFFICE 只读预览运行说明 + +当前集成只用于附件和文档版本的 Office 文件只读预览。PDF、图片、另存为和桌面端“打开”仍使用原有链路;第一期不启用编辑、保存回调、历史记录或强制保存。 + +## 开发环境启动 + +普通 `docker compose up` 不会拉取或启动 Document Server。开发环境需要预览时执行专用启动脚本: + +```bash +bash scripts/onlyoffice-dev-up.sh +``` + +脚本会自动完成以下工作: + +- 首次启动时生成 256-bit 随机 `ONLYOFFICE_JWT_SECRET`,写入根目录 `.env`,且不在终端回显。 +- 自动生成稳定的开发实例标识并写入 `.env`。 +- 将 `.env` 权限设置为 `0600`;该文件已被 Git 忽略。 +- 使用同一环境重建 backend,并显式启用 `office` Profile 启动 Document Server。 +- 后续启动默认复用密钥,防止后端和 Document Server 因签名密钥漂移而无法通信。 + +需要主动轮换开发密钥时执行: + +```bash +bash scripts/onlyoffice-dev-up.sh --rotate-secret +``` + +轮换会强制重建相关容器,已签发但尚未使用的短时预览配置会立即失效。生产环境不使用该自动生成流程,仍必须由部署密钥管理系统显式提供密钥。 + +可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像只增加 Noto Sans CJK/Noto Serif CJK,不包含微软字体。 + +## 配置边界 + +- `ONLYOFFICE_ENABLED` 默认 `false`。 +- `ONLYOFFICE_INTERNAL_URL` 默认 `http://onlyoffice`,只供后端健康探测。 +- `ONLYOFFICE_STORAGE_BASE_URL` 默认 `http://backend:8000`,只允许后端生成固定的内部文件地址。 +- `ONLYOFFICE_CONFIG_TTL_SECONDS` 默认 300 秒,允许范围为 60–900 秒。 +- 内部内容接口不经过 Nginx 公网入口,仅接受 `AuthorizationJwt`,且 JWT 必须绑定到请求的精确 URL。 +- 配置响应禁止缓存;JWT、内部文件 URL 和磁盘路径不得进入日志、审计详情或桌面缓存。 +- `onlyoffice_data`、`onlyoffice_lib` 和 `onlyoffice_logs` 不是 CTMS 业务权威数据,不纳入业务备份或恢复来源。 + +## 生产启用前 + +生产保持功能开关关闭,直至商业许可、批准镜像和真实授权环境回归完成。启用时必须使用生产随机密钥和稳定、唯一的 `ONLYOFFICE_INSTANCE_ID`,并完成 Web、macOS 和 Windows 内测端的真实 DOCX/XLSX/PPTX/WPS/ET/DPS 预览验证。 + +故障回退只关闭 `ONLYOFFICE_ENABLED`;不得影响附件下载、另存为、打开、PDF 或图片预览。Community 与商业版本的授权边界以 [ONLYOFFICE 官方许可说明](https://helpcenter.onlyoffice.com/docs/faq/docs-community.aspx) 为准。 diff --git a/frontend/public/onlyoffice-host.html b/frontend/public/onlyoffice-host.html new file mode 100644 index 00000000..ebb69aa5 --- /dev/null +++ b/frontend/public/onlyoffice-host.html @@ -0,0 +1,26 @@ + + + + + + CTMS Office Preview + + + +
+ + + diff --git a/frontend/public/onlyoffice-host.js b/frontend/public/onlyoffice-host.js new file mode 100644 index 00000000..63bc5ab8 --- /dev/null +++ b/frontend/public/onlyoffice-host.js @@ -0,0 +1,133 @@ +(() => { + "use strict"; + + const MESSAGE = Object.freeze({ + HOST_READY: "ctms.onlyoffice.host-ready", + INIT: "ctms.onlyoffice.init", + DOCUMENT_READY: "ctms.onlyoffice.document-ready", + WARNING: "ctms.onlyoffice.warning", + ERROR: "ctms.onlyoffice.error", + }); + const API_SCRIPT_PATH = "/onlyoffice/web-apps/apps/api/documents/api.js"; + const ALLOWED_TAURI_ORIGINS = new Set([ + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost", + ]); + let initialized = false; + let editor = null; + let parentOrigin = null; + let requestNonce = null; + let apiScriptPromise = null; + let readyTimer = null; + let readyDeadlineTimer = null; + + const isLoopbackOrigin = (origin) => { + try { + const url = new URL(origin); + return url.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); + } catch { + return false; + } + }; + + const isAllowedParentOrigin = (origin) => + origin === window.location.origin || ALLOWED_TAURI_ORIGINS.has(origin) || isLoopbackOrigin(origin); + + const postToParent = (type, detail) => { + if (!parentOrigin || !requestNonce) return; + window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin); + }; + + const safeEventDetail = (event) => { + const raw = event && typeof event === "object" && "data" in event ? event.data : event; + if (!raw || typeof raw !== "object") return {}; + const detail = {}; + if (typeof raw.errorCode === "number" || typeof raw.errorCode === "string") detail.errorCode = raw.errorCode; + if (typeof raw.errorDescription === "string") detail.errorDescription = raw.errorDescription.slice(0, 500); + if (typeof raw.warningCode === "number" || typeof raw.warningCode === "string") detail.warningCode = raw.warningCode; + if (typeof raw.warningDescription === "string") detail.warningDescription = raw.warningDescription.slice(0, 500); + return detail; + }; + + const loadOnlyOfficeApi = () => { + if (window.DocsAPI?.DocEditor) return Promise.resolve(); + if (apiScriptPromise) return apiScriptPromise; + apiScriptPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = API_SCRIPT_PATH; + script.async = true; + script.addEventListener("load", () => { + if (window.DocsAPI?.DocEditor) resolve(); + else reject(new Error("ONLYOFFICE API 未正确加载")); + }, { once: true }); + script.addEventListener("error", () => reject(new Error("ONLYOFFICE API 加载失败")), { once: true }); + document.head.appendChild(script); + }); + return apiScriptPromise; + }; + + const destroyEditor = () => { + if (editor && typeof editor.destroyEditor === "function") { + try { + editor.destroyEditor(); + } catch { + /* The remote editor may already have been torn down. */ + } + } + editor = null; + }; + + const stopReadyAnnouncements = () => { + if (readyTimer !== null) window.clearInterval(readyTimer); + if (readyDeadlineTimer !== null) window.clearTimeout(readyDeadlineTimer); + readyTimer = null; + readyDeadlineTimer = null; + }; + + const announceReady = () => { + if (!initialized) window.parent.postMessage({ type: MESSAGE.HOST_READY }, "*"); + }; + + const initializeEditor = async (message) => { + const config = message?.config; + if (!config || typeof config !== "object" || typeof message?.nonce !== "string" || !message.nonce) { + throw new Error("预览配置格式不正确"); + } + requestNonce = message.nonce; + await loadOnlyOfficeApi(); + const editorConfig = { + ...config, + events: { + onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY), + onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)), + onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)), + }, + }; + editor = new window.DocsAPI.DocEditor("onlyoffice-editor", editorConfig); + }; + + window.addEventListener("message", async (event) => { + if (event.source !== window.parent || initialized || !isAllowedParentOrigin(event.origin)) return; + if (event.data?.type !== MESSAGE.INIT) return; + initialized = true; + stopReadyAnnouncements(); + parentOrigin = event.origin; + requestNonce = typeof event.data?.nonce === "string" ? event.data.nonce : null; + try { + await initializeEditor(event.data); + } catch (error) { + postToParent(MESSAGE.ERROR, { + errorDescription: error instanceof Error ? error.message.slice(0, 500) : "Office 预览初始化失败", + }); + } + }); + + window.addEventListener("pagehide", () => { + stopReadyAnnouncements(); + destroyEditor(); + }, { once: true }); + announceReady(); + readyTimer = window.setInterval(announceReady, 250); + readyDeadlineTimer = window.setTimeout(stopReadyAnnouncements, 15_000); +})(); diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index dd546228..364c4e68 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -30,6 +30,12 @@ const walk = async (directory) => { const permissionIdentifier = (permission) => typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : ""; const toPosixPath = (path) => path.split("\\").join("/"); +const cspDirective = (csp, name) => + csp + .split(";") + .map((directive) => directive.trim().split(/\s+/)) + .find(([directiveName]) => directiveName === name) + ?.slice(1) || []; const assertPathScope = (permission, expectedPrefix, description) => { const allow = Array.isArray(permission.allow) ? permission.allow : []; @@ -70,6 +76,17 @@ const verifyTauriConfig = async () => { "Tauri CSP must not use wildcard sources.", ); assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access."); + const scriptSources = cspDirective(csp, "script-src"); + const frameSources = cspDirective(csp, "frame-src"); + assert( + scriptSources.length === 1 && scriptSources[0] === "'self'", + "Tauri script-src must remain self-only when ONLYOFFICE preview is enabled.", + ); + assert( + JSON.stringify(frameSources) === + JSON.stringify(["'self'", "blob:", "https:", "http://localhost:*", "http://127.0.0.1:*"]), + "Tauri frame-src may only add HTTPS and local development servers for the isolated ONLYOFFICE host.", + ); assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180."); assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760."); assert(mainWindow?.decorations === true, "Main desktop window must keep native window decorations enabled."); @@ -123,6 +140,11 @@ const verifyCapabilities = async () => { for (const file of files) { const capability = await readJson(resolve(capabilitiesDir, file)); + assert(!capability.remote, `${file}: remote origins must not receive Tauri capabilities.`); + assert( + Array.isArray(capability.windows) && capability.windows.length === 1 && capability.windows[0] === "main", + `${file}: capabilities must remain scoped to the main local window.`, + ); const permissions = Array.isArray(capability.permissions) ? capability.permissions : []; const identifiers = permissions.map(permissionIdentifier).filter(Boolean); @@ -165,6 +187,30 @@ const verifyCapabilities = async () => { } }; +const verifyOnlyOfficeBoundary = async () => { + const runtimeSource = await readFile(resolve(sourceDir, "runtime/onlyoffice.ts"), "utf8"); + const pageSource = await readFile(resolve(sourceDir, "views/OfficePreviewWorkspace.vue"), "utf8"); + const viewerSource = await readFile(resolve(sourceDir, "components/OnlyOfficeViewer.vue"), "utf8"); + const apiSource = await readFile(resolve(sourceDir, "api/onlyoffice.ts"), "utf8"); + const hostSource = await readFile(resolve(frontendDir, "public/onlyoffice-host.js"), "utf8"); + + assert(runtimeSource.includes('ONLYOFFICE_HOST_PATH = "/onlyoffice-host.html"'), "ONLYOFFICE host path must remain fixed."); + assert(runtimeSource.includes("resolveApiBaseUrl()"), "ONLYOFFICE host must derive from the validated runtime server base URL."); + assert(!runtimeSource.includes("url:"), "ONLYOFFICE runtime URL resolver must not accept a business-provided URL."); + assert(pageSource.includes("response.data.host_path !== ONLYOFFICE_HOST_PATH"), "ONLYOFFICE config host path must be checked against the fixed host path."); + assert(pageSource.includes("onDeactivated") && pageSource.includes("destroyViewer()"), "ONLYOFFICE viewer must be destroyed when a desktop task is deactivated."); + assert(viewerSource.includes("event.source !== frameWindow"), "ONLYOFFICE bridge must validate the message source window."); + assert(viewerSource.includes("event.origin !== hostUrl.origin"), "ONLYOFFICE bridge must validate message origin."); + assert(viewerSource.includes("message.nonce !== nonce"), "ONLYOFFICE bridge must validate its in-memory nonce."); + assert(hostSource.includes("event.source !== window.parent"), "ONLYOFFICE host must only accept parent-window messages."); + assert(hostSource.includes("!isAllowedParentOrigin(event.origin)"), "ONLYOFFICE host must validate the parent origin."); + assert(hostSource.includes("initialized ||"), "ONLYOFFICE host must only accept one initialization."); + assert(hostSource.includes("message?.nonce"), "ONLYOFFICE host must require the in-memory nonce."); + assert(!/\b(?:localStorage|sessionStorage|console\.)\b/.test(hostSource), "ONLYOFFICE host must not persist or log signed configuration."); + assert(apiSource.includes("cache: false"), "ONLYOFFICE signed config must not enter the desktop data cache."); + assert(apiSource.includes("disableRequestDedupe: true"), "ONLYOFFICE signed config requests must not be deduplicated."); +}; + const verifyRustBoundary = async () => { const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8"); const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"]; @@ -445,6 +491,7 @@ await verifySourceSafety(); await verifyNotificationBoundary(); await verifySessionBoundary(); await verifyUpdaterBoundary(); +await verifyOnlyOfficeBoundary(); await verifyWorkflowGates(); if (failures.length > 0) { diff --git a/frontend/scripts/verify-ui-contract.mjs b/frontend/scripts/verify-ui-contract.mjs index 3627b793..f17acf39 100644 --- a/frontend/scripts/verify-ui-contract.mjs +++ b/frontend/scripts/verify-ui-contract.mjs @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; const main = readFileSync("src/styles/main.css", "utf8"); const unified = readFileSync("src/styles/unified-page.css", "utf8"); +const webLayout = readFileSync("src/components/WebLayout.vue", "utf8"); const requiredMainTokens = [ "--ctms-primary", @@ -31,6 +32,29 @@ const missing = [ ...requiredUnifiedTokens.filter((t) => !unified.includes(t)) ]; +const requiredWebEdgeTokens = [ + ".web-layout-container .ctms-route-shell > *", + ".web-layout-container .ctms-route-shell > .page > .table-card" +]; + +for (const token of requiredWebEdgeTokens) { + if (!main.includes(token)) { + missing.push(`src/styles/main.css:${token}`); + } +} + +if (!/\.web-layout-container \.content-wrapper\s*\{[^}]*padding:\s*0;/s.test(webLayout)) { + missing.push("src/components/WebLayout.vue:web content wrapper edge alignment"); +} + +if (!/\.web-layout-container \.content-wrapper\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0;/s.test(webLayout)) { + missing.push("src/components/WebLayout.vue:web content height chain"); +} + +if (webLayout.includes("padding: 18px 24px 24px;") || webLayout.includes("padding: 22px 32px 32px;")) { + missing.push("src/components/WebLayout.vue:large-screen content padding override"); +} + const pageContractChecks = [ { file: "src/views/ia/ProjectMilestones.vue", diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index a8f25b61..78afe74d 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -27,7 +27,7 @@ } ], "security": { - "csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'" + "csp": "default-src 'self' customprotocol: asset:; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob: https: http://localhost:* http://127.0.0.1:*; object-src 'none'; base-uri 'self'; form-action 'self'" } }, "bundle": { diff --git a/frontend/src/api/onlyoffice.test.ts b/frontend/src/api/onlyoffice.test.ts new file mode 100644 index 00000000..60a97871 --- /dev/null +++ b/frontend/src/api/onlyoffice.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); +vi.mock("./axios", () => ({ apiGet })); + +describe("ONLYOFFICE config api", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + ["attachment", "fetchAttachmentOnlyOfficeConfig", "/api/v1/onlyoffice/attachments/resource-1/config"], + ["version", "fetchVersionOnlyOfficeConfig", "/api/v1/onlyoffice/versions/resource-1/config"], + ] as const)("loads %s config without caching or request dedupe", async (_type, exportName, path) => { + const api = await import("./onlyoffice"); + api[exportName]("resource-1"); + + expect(apiGet).toHaveBeenCalledWith(path, { + cache: false, + disableRequestDedupe: true, + disableNetworkRetry: true, + suppressErrorMessage: true, + }); + }); +}); diff --git a/frontend/src/api/onlyoffice.ts b/frontend/src/api/onlyoffice.ts new file mode 100644 index 00000000..05441d38 --- /dev/null +++ b/frontend/src/api/onlyoffice.ts @@ -0,0 +1,15 @@ +import { apiGet } from "./axios"; +import type { OnlyOfficePreviewConfig } from "../types/onlyoffice"; + +const noStoreConfig = { + cache: false, + disableRequestDedupe: true, + disableNetworkRetry: true, + suppressErrorMessage: true, +} as const; + +export const fetchAttachmentOnlyOfficeConfig = (attachmentId: string) => + apiGet(`/api/v1/onlyoffice/attachments/${attachmentId}/config`, noStoreConfig); + +export const fetchVersionOnlyOfficeConfig = (versionId: string) => + apiGet(`/api/v1/onlyoffice/versions/${versionId}/config`, noStoreConfig); diff --git a/frontend/src/components/AccountConnectionStatus.test.ts b/frontend/src/components/AccountConnectionStatus.test.ts index 2aa8941b..c35d8592 100644 --- a/frontend/src/components/AccountConnectionStatus.test.ts +++ b/frontend/src/components/AccountConnectionStatus.test.ts @@ -36,7 +36,8 @@ describe("account connection status", () => { expect(component).toContain("Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes)"); expect(component).toContain("sessionClockTimer !== undefined"); expect(component).toContain("window.clearInterval(sessionClockTimer)"); - expect(component).toContain("不代表系统健康评分"); + expect(component).not.toContain("连接状态仅表示会话与后端通信"); + expect(component).not.toContain("connection-details-note"); expect(component).not.toContain("ip_address"); expect(component).not.toContain("access_token"); expect(webLayout).toContain(''); diff --git a/frontend/src/components/AccountConnectionStatus.vue b/frontend/src/components/AccountConnectionStatus.vue index 49df76ef..9876b0a5 100644 --- a/frontend/src/components/AccountConnectionStatus.vue +++ b/frontend/src/components/AccountConnectionStatus.vue @@ -83,7 +83,6 @@
{{ lastCommunicationLabel }}
-

连接状态仅表示会话与后端通信,不代表系统健康评分。

@@ -491,13 +490,6 @@ onBeforeUnmount(() => { white-space: nowrap; } -.connection-details-note { - margin: 9px 0 0; - color: #8a98aa; - font-size: 10px; - line-height: 1.45; -} - :global([data-ctms-theme="dark"]) .account-connection-details { color: #dbe5f1; } diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index 75b18315..d35a23d9 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -229,7 +229,7 @@ ⌘K - @@ -424,7 +424,7 @@ -
+
@@ -481,7 +481,7 @@ @click.stop > - @@ -495,7 +495,7 @@ + + diff --git a/frontend/src/components/WebLayout.vue b/frontend/src/components/WebLayout.vue index 1b9eb585..e2365743 100644 --- a/frontend/src/components/WebLayout.vue +++ b/frontend/src/components/WebLayout.vue @@ -5,9 +5,10 @@ @keydown.esc="closeMobileNav" > - -
- -
-
- -
+ +
@@ -424,13 +396,12 @@ import { useRoute, useRouter } from "vue-router"; import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; import { fetchStudies } from "../api/studies"; -import { fetchProjectOverview } from "../api/overview"; import { fetchOverdueAesCount } from "../api/dashboard"; import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues"; import { TEXT } from "../locales"; import { User, Suitcase, House, Calendar, Flag, - CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell, Setting, + CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, UserFilled, Bell, Setting, Search, Star, StarFilled, Clock, Monitor } from "@element-plus/icons-vue"; import { ElMessageBox } from "element-plus"; @@ -490,19 +461,11 @@ const commandPaletteVisible = ref(false); const desktopPreferencesVisible = ref(false); const desktopServerUrl = ref(getDesktopServerUrl()); const desktopFavoriteRoutes = ref(readDesktopFavoriteRoutes()); -const headerOverviewStats = ref<{ - centerActual: number; - enrollmentActual: number; - enrollmentTarget: number; - updatedAt: string; -} | null>(null); const headerRemindersLoading = ref(false); const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0, }); -const headerClockNow = ref(new Date()); -let headerClockTimer: number | undefined; let desktopMenuUnlisten: (() => void) | undefined; let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined; const isAdminContext = computed(() => route.path.startsWith("/admin")); @@ -511,7 +474,7 @@ const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdmin /* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */ const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy); watchEffect(() => { - const width = !hasSidebar.value || isMobileViewport.value ? '0px' : (isCollapsed.value ? '68px' : '200px'); + const width = !hasSidebar.value || isMobileViewport.value || isCollapsed.value ? '0px' : '200px'; document.body.style.setProperty('--ctms-sidebar-width', width); // 在 body 上添加/移除标记类,以便全局 CSS :has() 降级使用 if (hasSidebar.value) { @@ -538,45 +501,6 @@ const toHeaderNumber = (value: unknown) => { return Number.isFinite(num) && num > 0 ? num : 0; }; -const formatHeaderProgress = (actual: number, target?: number | null) => - target && target > 0 ? `${actual}/${target}` : String(actual); - -const formatHeaderDateTime = (date: Date) => { - if (Number.isNaN(date.getTime())) return ""; - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; -}; - -const headerClockText = computed(() => formatHeaderDateTime(headerClockNow.value)); - -const loadHeaderOverviewStats = async () => { - const studyId = study.currentStudy?.id; - if (!studyId || !hasProjectContext.value) { - headerOverviewStats.value = null; - return; - } - try { - const { data } = await fetchProjectOverview(studyId); - if (study.currentStudy?.id !== studyId || isAdminContext.value) return; - const centers = Array.isArray((data as any)?.centers) ? (data as any).centers : []; - headerOverviewStats.value = { - centerActual: centers.length, - enrollmentActual: centers.reduce((sum: number, center: any) => sum + toHeaderNumber(center?.enrollment_actual), 0), - enrollmentTarget: centers.reduce((sum: number, center: any) => sum + toHeaderNumber(center?.enrollment_target), 0), - updatedAt: typeof (data as any)?.updated_at === "string" ? (data as any).updated_at : "", - }; - } catch { - if (study.currentStudy?.id === studyId) { - headerOverviewStats.value = null; - } - } -}; - const loadHeaderReminders = async () => { const studyId = study.currentStudy?.id; if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) { @@ -638,45 +562,6 @@ const headerReminderBadgeValue = computed(() => headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value ); -// 顶栏中间区:项目上下文(状态 + 关键进度 + 数据时间) -const headerProjectInfo = computed(() => { - if (!hasProjectContext.value || !study.currentStudy) return null; - const s = study.currentStudy; - const status = (s.status || "").toUpperCase(); - const statusLabel = (TEXT.enums.projectStatus as Record)[status] || s.status || ""; - const statusTone = status === "ACTIVE" ? "active" : status === "CLOSED" ? "closed" : "draft"; - const plannedSiteCount = s.planned_site_count; - const plannedEnrollmentCount = s.planned_enrollment_count; - const overviewStats = headerOverviewStats.value; - const centerActual = overviewStats?.centerActual || 0; - const enrollmentActual = overviewStats?.enrollmentActual || 0; - const enrollmentTarget = overviewStats?.enrollmentTarget || toHeaderNumber(plannedEnrollmentCount); - const updatedAt = headerClockText.value; - const metrics = [ - plannedSiteCount || centerActual ? { - key: "plannedSiteCount", - label: TEXT.common.labels.plannedSites, - value: formatHeaderProgress(centerActual || toHeaderNumber(plannedSiteCount), plannedSiteCount), - } : null, - plannedEnrollmentCount || enrollmentActual || enrollmentTarget ? { - key: "plannedEnrollmentCount", - label: TEXT.common.labels.plannedEnrollment, - value: formatHeaderProgress(enrollmentActual || toHeaderNumber(plannedEnrollmentCount), enrollmentTarget || plannedEnrollmentCount), - } : null, - updatedAt ? { - key: "updatedAt", - label: TEXT.common.labels.dataUpdatedAt, - value: updatedAt, - } : null, - ].filter(Boolean) as { key: string; label: string; value: string | number }[]; - return { - statusLabel, - statusTone, - isLocked: Boolean((s as any).is_locked), - metrics, - }; -}); - const accountProjectRoleLabel = computed(() => { if (!hasProjectContext.value || !study.currentStudy) return ""; const roleCode = (projectRole.value || "").toUpperCase(); @@ -1088,7 +973,6 @@ watch(() => route.path, () => { watch( () => [study.currentStudy?.id, route.path] as const, () => { - loadHeaderOverviewStats(); loadHeaderReminders(); refreshDesktopRoutePreferences(); }, @@ -1158,10 +1042,6 @@ onMounted(async () => { mobileViewportChangeHandler = (event) => syncMobileViewport(event.matches); mobileViewportMediaQuery.addEventListener("change", mobileViewportChangeHandler); } - headerClockNow.value = new Date(); - headerClockTimer = window.setInterval(() => { - headerClockNow.value = new Date(); - }, 1000); if (isDesktop) { window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl); clearLegacyDesktopRouteHistoryPreference(); @@ -1178,14 +1058,10 @@ onMounted(async () => { } } loadStudies(); - loadHeaderOverviewStats(); loadHeaderReminders(); }); onBeforeUnmount(() => { - if (headerClockTimer) { - window.clearInterval(headerClockTimer); - } window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl); if (mobileViewportMediaQuery && mobileViewportChangeHandler) { mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler); @@ -1273,6 +1149,10 @@ useDesktopShortcuts( border-right: 1px solid rgba(15, 23, 42, 0.2); } +.web-layout-container .layout-aside.collapsed { + border-right-width: 0; +} + .web-layout-container .aside-logo { height: 60px; padding: 0 16px; @@ -1298,9 +1178,9 @@ useDesktopShortcuts( } .web-layout-container .content-wrapper { - min-height: calc(100vh - 52px); - min-height: calc(100dvh - 52px); - padding: 12px 14px; + height: 100%; + min-height: 0; + padding: 0; } .layout-aside { @@ -1549,6 +1429,8 @@ useDesktopShortcuts( } .main-container { + height: 100%; + min-height: 0; background: #f7f8fb; min-width: 0; } @@ -1826,296 +1708,6 @@ useDesktopShortcuts( flex-shrink: 0; } -/* 顶栏中间区:常驻上下文信息 */ -.header-center { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - min-width: 0; - padding: 0 12px; - overflow: hidden; -} - -.header-context-rail { - display: inline-flex; - align-items: center; - gap: 7px; - min-width: 0; - max-width: 100%; - height: 30px; - padding: 1px 0; - border: 1px solid transparent; - border-radius: 14px; - background: transparent; - box-shadow: none; - overflow: visible; - white-space: nowrap; -} - -.header-status-pill { - position: relative; - display: inline-flex; - align-items: center; - gap: 6px; - height: 28px; - padding: 0 13px; - border-radius: 999px; - border: 1px solid transparent; - font-size: 13.5px; - font-weight: 760; - line-height: 1; - white-space: nowrap; - letter-spacing: 0; - overflow: hidden; - transition: - transform 0.24s cubic-bezier(0.34, 1.56, 0.64, 1), - box-shadow 0.24s ease, - border-color 0.24s ease; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.82), - inset 0 -1px 0 rgba(15, 23, 42, 0.04); -} - -/* 状态药丸高光扫光:缓慢横扫一道柔光,营造高级质感 */ -.header-status-pill::after { - content: ""; - position: absolute; - top: 0; - left: -60%; - width: 45%; - height: 100%; - background: linear-gradient( - 100deg, - transparent 0%, - rgba(255, 255, 255, 0.55) 50%, - transparent 100% - ); - transform: skewX(-18deg); - opacity: 0; - pointer-events: none; -} - -.header-status-pill:hover { - transform: translateY(-1px); -} - -.header-status-pill.is-active { - border-color: rgba(96, 188, 146, 0.78); - background: - radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.92) 0, rgba(255, 255, 255, 0) 38%), - linear-gradient(135deg, #e6fbf0 0%, #cdeedd 55%, #b9e7d2 100%); - color: #1d6e4f; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - inset 0 -1px 0 rgba(30, 109, 80, 0.12), - 0 2px 10px rgba(34, 145, 100, 0.18); -} - -.header-status-pill.is-active::after { - animation: header-pill-sheen 4.4s ease-in-out 1.2s infinite; -} - -.header-status-pill.is-active:hover { - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.95), - inset 0 -1px 0 rgba(30, 109, 80, 0.14), - 0 6px 18px rgba(34, 145, 100, 0.28); -} - -.header-status-pill.is-closed { - border-color: rgba(232, 175, 96, 0.78); - background: - radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.94) 0, rgba(255, 255, 255, 0) 38%), - linear-gradient(135deg, #fff7e9 0%, #fbe6bf 55%, #f8dca6 100%); - color: #8f520c; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - inset 0 -1px 0 rgba(143, 82, 12, 0.1), - 0 2px 10px rgba(214, 152, 56, 0.16); -} - -.header-status-pill.is-closed:hover { - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.95), - inset 0 -1px 0 rgba(143, 82, 12, 0.12), - 0 6px 18px rgba(214, 152, 56, 0.26); -} - -.header-status-pill.is-draft { - border-color: rgba(196, 209, 224, 0.96); - background: - radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.95) 0, rgba(255, 255, 255, 0) 38%), - linear-gradient(135deg, #fbfdff 0%, #eef3f9 55%, #e4ecf5 100%); - color: #4f667e; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.92), - inset 0 -1px 0 rgba(83, 106, 130, 0.08), - 0 2px 9px rgba(120, 138, 163, 0.14); -} - -.header-status-pill.is-draft:hover { - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.96), - inset 0 -1px 0 rgba(83, 106, 130, 0.1), - 0 6px 16px rgba(120, 138, 163, 0.2); -} - -.header-status-dot { - position: relative; - width: 8px; - height: 8px; - border-radius: 999px; - background: currentColor; - box-shadow: - 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent), - 0 1px 2px rgba(15, 23, 42, 0.14); -} - -/* 进行中状态点呼吸光环:周期性向外扩散并淡出 */ -.header-status-pill.is-active .header-status-dot::before { - content: ""; - position: absolute; - inset: 0; - border-radius: 999px; - background: currentColor; - animation: header-dot-pulse 2.4s ease-out infinite; -} - -@keyframes header-pill-sheen { - 0%, - 78% { - left: -60%; - opacity: 0; - } - 82% { - opacity: 0.9; - } - 100% { - left: 130%; - opacity: 0; - } -} - -@keyframes header-dot-pulse { - 0% { - transform: scale(1); - opacity: 0.55; - } - 70%, - 100% { - transform: scale(2.8); - opacity: 0; - } -} - -.header-meta-item { - position: relative; - display: inline-flex; - align-items: center; - gap: 5px; - min-width: 0; - height: 28px; - padding: 0 11px; - border: 1px solid rgba(213, 224, 237, 0.94); - border-radius: 10px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.78) 0%, rgba(238, 245, 252, 0.86) 100%); - font-size: 12px; - line-height: 1; - color: var(--ctms-text-secondary); - white-space: nowrap; - overflow: hidden; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.84), - inset 0 -1px 0 rgba(148, 163, 184, 0.08); - transition: - border-color 0.22s ease, - background 0.22s ease, - box-shadow 0.22s ease, - transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -/* 指标项左侧彩色强调条:随类型变色,悬浮时点亮 */ -.header-meta-item::before { - content: ""; - position: absolute; - left: 0; - top: 50%; - width: 3px; - height: 13px; - border-radius: 0 3px 3px 0; - transform: translateY(-50%) scaleY(0.55); - opacity: 0; - transition: opacity 0.22s ease, transform 0.22s ease; -} - -.header-meta-item.is-plannedSiteCount::before { - background: linear-gradient(180deg, #5fb4ec 0%, #3f8fd6 100%); -} - -.header-meta-item.is-plannedEnrollmentCount::before { - background: linear-gradient(180deg, #6cc79c 0%, #3fa777 100%); -} - -.header-meta-item.is-updatedAt::before { - background: linear-gradient(180deg, #b6a4ec 0%, #8b78d6 100%); -} - -.header-meta-item:hover { - border-color: rgba(160, 182, 210, 0.98); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(244, 249, 254, 0.95) 100%); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.92), - inset 0 -1px 0 rgba(148, 163, 184, 0.1), - 0 5px 16px rgba(38, 63, 102, 0.12); - transform: translateY(-1px); -} - -.header-meta-item:hover::before { - opacity: 1; - transform: translateY(-50%) scaleY(1); -} - -.header-meta-item.is-updatedAt { - padding: 0 12px; - font-variant-numeric: tabular-nums; - color: #52677f; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.82) 0%, rgba(241, 246, 252, 0.9) 100%); -} - -.header-meta-item .el-icon { - font-size: 13px; - color: #94a3b8; -} - -.header-meta-label { - color: #778aa3; - margin-right: 0; - font-weight: 560; -} - -.header-meta-value { - color: #506179; - font-weight: 720; - font-variant-numeric: tabular-nums; - letter-spacing: 0; - transition: color 0.22s ease; -} - -.header-meta-item.is-plannedSiteCount:hover .header-meta-value { - color: #2f7fc4; -} - -.header-meta-item.is-plannedEnrollmentCount:hover .header-meta-value { - color: #2f8f63; -} - -.header-meta-item.is-updatedAt:hover .header-meta-value { - color: #6a5bbf; -} .header-role { font-weight: 600; @@ -2308,26 +1900,6 @@ useDesktopShortcuts( font-weight: 600; } -.header-locked { - color: #b45309; -} - -.header-locked .el-icon { - color: #d97706; -} - -@media (max-width: 1280px) { - .header-metric { - display: none; - } -} - -@media (max-width: 1100px) { - .header-center { - display: none; - } -} - .collapse-btn { border: 1px solid transparent; color: var(--ctms-text-secondary); @@ -2411,6 +1983,7 @@ useDesktopShortcuts( } .layout-main { + min-height: 0; padding: 0; overflow-y: auto; background: #f7f8fb; @@ -2424,9 +1997,26 @@ useDesktopShortcuts( background: #f7f8fb; } +.layout-main.is-full-bleed { + overflow: hidden; +} + +.content-wrapper.is-full-bleed { + height: 100%; + min-height: 0; + padding: 0; + overflow: hidden; +} + +.content-wrapper.is-full-bleed .ctms-route-shell { + height: 100%; + min-height: 0; +} + .ctms-route-shell { width: 100%; - min-height: 100%; + height: 100%; + min-height: 0; transform-origin: top center; } @@ -2494,6 +2084,7 @@ useDesktopShortcuts( } .web-layout-container .content-wrapper { + height: auto; min-height: calc(100dvh - 52px); padding: 10px; } @@ -2592,8 +2183,7 @@ useDesktopShortcuts( } .web-layout-container .content-wrapper { - min-height: calc(100dvh - 56px); - padding: 18px 24px 24px; + padding: 0; } } @@ -2603,9 +2193,6 @@ useDesktopShortcuts( padding-left: 28px; } - .web-layout-container .content-wrapper { - padding: 22px 32px 32px; - } } @media (prefers-reduced-motion: reduce) { @@ -2631,20 +2218,6 @@ useDesktopShortcuts( transform: none; } - .header-status-pill, - .header-meta-item { - transition-duration: 1ms !important; - } - - .header-status-pill:hover, - .header-meta-item:hover { - transform: none; - } - - .header-status-pill.is-active::after, - .header-status-pill.is-active .header-status-dot::before { - animation: none; - } } .breadcrumb-dropdown-menu { min-width: 216px; diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue index 0706b3bd..829d487b 100644 --- a/frontend/src/components/attachments/AttachmentList.vue +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -163,6 +163,7 @@ + + diff --git a/frontend/src/views/documents/DocumentDetail.test.ts b/frontend/src/views/documents/DocumentDetail.test.ts index d29c0134..096eedfc 100644 --- a/frontend/src/views/documents/DocumentDetail.test.ts +++ b/frontend/src/views/documents/DocumentDetail.test.ts @@ -57,12 +57,13 @@ describe("DocumentDetail permissions", () => { expect(source).toContain("@click=\"previewVersion(row)\""); expect(source).toContain("TEXT.common.labels.preview"); - expect(source).toContain('return "image"'); - expect(source).toContain('return "pdf"'); + expect(source).toContain("detectFilePreviewKind"); expect(source).toContain("TEXT.common.messages.previewNotSupported"); expect(source).toContain("URL.createObjectURL(blob)"); expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)"); expect(source).toContain(" { expect(source).toContain("siteName: displaySite(detail.site_id)"); expect(source).toContain("pageTitle: detail.title || TEXT.modules.fileVersionManagement.title"); expect(source).toContain("objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined"); + expect(source.indexOf("syncBreadcrumbContext();", source.indexOf("Object.assign(detail, data);") + 1)).toBeGreaterThan( + source.indexOf("Object.assign(detail, data);"), + ); + expect(source).toContain("onActivated(() => {"); + expect(source).toContain("if (detail.id) syncBreadcrumbContext();"); }); }); diff --git a/frontend/src/views/documents/DocumentDetail.vue b/frontend/src/views/documents/DocumentDetail.vue index 1f845a42..1e3c4958 100644 --- a/frontend/src/views/documents/DocumentDetail.vue +++ b/frontend/src/views/documents/DocumentDetail.vue @@ -350,8 +350,8 @@