功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
This commit is contained in:
Cheng Zhou
2026-07-14 14:19:17 +08:00
parent 44db5db838
commit c68dddfc01
50 changed files with 2429 additions and 677 deletions
+6
View File
@@ -50,6 +50,12 @@
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `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`
+212
View File
@@ -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)},
)
+2 -1
View File
@@ -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"])
+31
View File
@@ -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()
+4 -1
View File
@@ -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
+14
View File
@@ -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]
+227
View File
@@ -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
+136
View File
@@ -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"
+194
View File
@@ -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
+8
View File
@@ -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:
+45
View File
@@ -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:
@@ -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 权限并创建订阅 |
| 系统通知拒绝 | 不适用 | 必测 | 开关回退,提示系统权限未开启 |
+45
View File
@@ -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) 为准。
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CTMS Office Preview</title>
<style>
html,
body,
#onlyoffice-editor {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: #fff;
}
</style>
</head>
<body>
<div id="onlyoffice-editor" aria-label="Office 文档预览"></div>
<script src="/onlyoffice-host.js" defer></script>
</body>
</html>
+133
View File
@@ -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);
})();
@@ -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) {
+24
View File
@@ -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",
+1 -1
View File
@@ -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": {
+23
View File
@@ -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,
});
});
});
+15
View File
@@ -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<OnlyOfficePreviewConfig>(`/api/v1/onlyoffice/attachments/${attachmentId}/config`, noStoreConfig);
export const fetchVersionOnlyOfficeConfig = (versionId: string) =>
apiGet<OnlyOfficePreviewConfig>(`/api/v1/onlyoffice/versions/${versionId}/config`, noStoreConfig);
@@ -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('<AccountConnectionStatus mode="indicator" />');
@@ -83,7 +83,6 @@
<dd>{{ lastCommunicationLabel }}</dd>
</div>
</dl>
<p class="connection-details-note">连接状态仅表示会话与后端通信不代表系统健康评分</p>
</div>
</template>
@@ -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;
}
+63 -6
View File
@@ -229,7 +229,7 @@
<kbd>K</kbd>
</button>
<button class="icon-button" type="button" :title="currentRouteFavorited ? '取消收藏当前模块' : '收藏当前模块'" @click="toggleCurrentFavorite">
<button v-if="canFavoriteCurrentRoute" class="icon-button" type="button" :title="currentRouteFavorited ? '取消收藏当前模块' : '收藏当前模块'" @click="toggleCurrentFavorite">
<el-icon><StarFilled v-if="currentRouteFavorited" /><Star v-else /></el-icon>
</button>
@@ -424,7 +424,7 @@
</el-dropdown>
</div>
<main class="desktop-content">
<main class="desktop-content" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<router-view v-slot="{ Component, route: currentRoute }">
<transition name="desktop-route" mode="out-in">
<KeepAlive :max="DESKTOP_WORKSPACE_TAB_CACHE_MAX">
@@ -481,7 +481,7 @@
@click.stop
>
<button type="button" @click="navigateWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">切换到标签</button>
<button type="button" @click="toggleWorkspaceTabFavorite(workspaceTabMenu.tab)">
<button v-if="canFavoriteWorkspaceTab(workspaceTabMenu.tab)" type="button" @click="toggleWorkspaceTabFavorite(workspaceTabMenu.tab)">
{{ isWorkspaceTabFavorited(workspaceTabMenu.tab) ? "取消收藏模块" : "收藏模块" }}
</button>
<button type="button" @click="closeWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">关闭标签</button>
@@ -495,7 +495,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowDown,
@@ -598,6 +598,7 @@ import {
resolveWorkspaceTabContextTitle,
type WorkspaceTabContext,
} from "./layout/workspaceTabTitle";
import { workspaceTaskControllerKey } from "./layout/workspaceTaskController";
const auth = useAuthStore();
const study = useStudyStore();
@@ -636,11 +637,13 @@ const desktopSidebarVisible = ref(readDesktopSidebarVisible());
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});
const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});
const workspaceTaskOrigins = ref<Record<string, string>>({});
const workspaceTabsOverflowOpen = ref(false);
const workspaceTabsSearchQuery = ref("");
const draggingWorkspaceTabPath = ref("");
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
const lastClosedWorkspaceTabHistory = ref<WorkspaceTabHistory | null>(null);
const lastClosedWorkspaceTaskOrigin = ref<string | null>(null);
const workspaceTabMenu = ref<{
visible: boolean;
x: number;
@@ -796,6 +799,13 @@ const currentWorkspaceContext = computed<WorkspaceTabContext | null>(() => {
});
const currentDesktopRoutePreference = computed(() => {
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activeMenu.value);
if (!item && route.meta.transientWorkspaceTask) {
return {
path: route.path,
title: currentWorkspaceContext.value?.pageTitle || String(route.meta.title || "文档预览"),
group: "文档预览",
};
}
if (!item) return null;
return {
path: item.path,
@@ -838,6 +848,7 @@ const currentRouteFavorited = computed(() => {
const current = currentDesktopRoutePreference.value;
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
});
const canFavoriteCurrentRoute = computed(() => !route.meta.transientWorkspaceTask && Boolean(currentDesktopRoutePreference.value));
const desktopToolbarTitle = computed(() => {
const current = currentDesktopRoutePreference.value;
if (!current) return String(route.meta?.title || TEXT.common.appName);
@@ -947,6 +958,13 @@ const removeWorkspaceTabContext = (tabPath: string) => {
workspaceTabContexts.value = next;
};
const removeWorkspaceTaskOrigin = (tabPath: string) => {
if (!workspaceTaskOrigins.value[tabPath]) return;
const next = { ...workspaceTaskOrigins.value };
delete next[tabPath];
workspaceTaskOrigins.value = next;
};
const recordWorkspaceTabRoute = (tabPath: string, fullPath: string) => {
setWorkspaceTabHistory(tabPath, recordWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath));
};
@@ -1009,6 +1027,9 @@ const navigateWorkspaceTabFromMenu = (path: string) => {
const isWorkspaceTabFavorited = (tab: DesktopRoutePreference) =>
desktopFavoriteRoutes.value.some((item) => item.path === tab.path);
const canFavoriteWorkspaceTab = (tab: DesktopRoutePreference) =>
desktopNavigationItems.value.some((item) => item.path === tab.path);
const toggleWorkspaceTabFavorite = (tab: DesktopRoutePreference) => {
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute({ path: tab.path, title: tab.title, group: tab.group });
refreshDesktopRoutePreferences();
@@ -1041,15 +1062,19 @@ const dropWorkspaceTab = (targetPath: string, event: DragEvent) => {
const closeWorkspaceTab = (path: string) => {
const index = workspaceTabs.value.findIndex((tab) => tab.path === path);
const originPath = workspaceTaskOrigins.value[path];
if (index >= 0) {
lastClosedWorkspaceTab.value = workspaceTabs.value[index];
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[path] || createWorkspaceTabHistory(path);
lastClosedWorkspaceTaskOrigin.value = originPath || null;
}
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
removeWorkspaceTabHistory(path);
removeWorkspaceTabContext(path);
removeWorkspaceTaskOrigin(path);
if (activeMenu.value !== path) return;
const next =
workspaceTabs.value.find((tab) => tab.path === originPath) ||
workspaceTabs.value[index] ||
workspaceTabs.value[index - 1] ||
desktopFavoriteRoutes.value.find((item) => item.path !== path);
@@ -1060,6 +1085,15 @@ const closeWorkspaceTab = (path: string) => {
}
};
const closeTransientWorkspaceTask = (path: string) => {
if (!route.meta.transientWorkspaceTask || activeMenu.value !== path) return false;
if (!workspaceTaskOrigins.value[path] && workspaceTabs.value.length <= 1) return false;
closeWorkspaceTab(path);
return true;
};
provide(workspaceTaskControllerKey, { closeTransientTask: closeTransientWorkspaceTask });
const closeWorkspaceTabFromMenu = (path: string) => {
closeWorkspaceTab(path);
closeWorkspaceTabMenu();
@@ -1073,6 +1107,8 @@ const closeOtherWorkspaceTabs = (path: string) => {
workspaceTabHistories.value = targetHistory ? { [path]: targetHistory } : {};
const targetContext = workspaceTabContexts.value[path];
workspaceTabContexts.value = targetContext ? { [path]: targetContext } : {};
const targetOrigin = workspaceTaskOrigins.value[path];
workspaceTaskOrigins.value = targetOrigin ? { [path]: targetOrigin } : {};
if (activeMenu.value !== path) void router.push(workspaceTabDestination(path));
closeWorkspaceTabMenu();
};
@@ -1082,10 +1118,12 @@ const closeAllWorkspaceTabs = () => {
if (activeTab) {
lastClosedWorkspaceTab.value = activeTab;
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[activeTab.path] || createWorkspaceTabHistory(activeTab.path);
lastClosedWorkspaceTaskOrigin.value = workspaceTaskOrigins.value[activeTab.path] || null;
}
workspaceTabs.value = [];
workspaceTabHistories.value = {};
workspaceTabContexts.value = {};
workspaceTaskOrigins.value = {};
workspaceTabsSearchQuery.value = "";
closeWorkspaceTabMenu();
@@ -1108,9 +1146,16 @@ const restoreLastClosedWorkspaceTab = () => {
if (lastClosedWorkspaceTabHistory.value) {
setWorkspaceTabHistory(tab.path, lastClosedWorkspaceTabHistory.value);
}
if (lastClosedWorkspaceTaskOrigin.value) {
workspaceTaskOrigins.value = {
...workspaceTaskOrigins.value,
[tab.path]: lastClosedWorkspaceTaskOrigin.value,
};
}
void router.push(workspaceTabDestination(tab.path));
lastClosedWorkspaceTab.value = null;
lastClosedWorkspaceTabHistory.value = null;
lastClosedWorkspaceTaskOrigin.value = null;
closeWorkspaceTabMenu();
};
@@ -1140,8 +1185,10 @@ const openDesktopProjectEntry = async () => {
workspaceTabs.value = [];
workspaceTabHistories.value = {};
workspaceTabContexts.value = {};
workspaceTaskOrigins.value = {};
lastClosedWorkspaceTab.value = null;
lastClosedWorkspaceTabHistory.value = null;
lastClosedWorkspaceTaskOrigin.value = null;
await router.push("/desktop/project-entry");
};
@@ -1266,7 +1313,7 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
id: "desktop:favorite-current",
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
group: "桌面",
visible: Boolean(currentDesktopRoutePreference.value),
visible: canFavoriteCurrentRoute.value,
run: toggleCurrentFavorite,
},
{
@@ -1393,8 +1440,14 @@ watch(
}
const current = currentDesktopRoutePreference.value;
if (current) {
addWorkspaceTab(current);
const previousTabPath = previousRoute ? getActiveLayoutPath(previousRoute.path) : "";
if (route.meta.transientWorkspaceTask && previousTabPath && previousTabPath !== current.path) {
workspaceTaskOrigins.value = {
...workspaceTaskOrigins.value,
[current.path]: previousTabPath,
};
}
addWorkspaceTab(current);
const replacesCurrentTabEntry =
previousTabPath === current.path && Boolean((window.history.state as { replaced?: boolean } | null)?.replaced);
if (replacesCurrentTabEntry) {
@@ -2680,6 +2733,10 @@ useDesktopShortcuts(
background: #eef2f6;
}
.desktop-content.is-full-bleed {
overflow: hidden;
}
.desktop-route-shell {
width: 100%;
max-width: 100%;
+24 -26
View File
@@ -1,5 +1,5 @@
<template>
<div class="faq-list-card" :class="{ 'faq-list-card--desktop': isDesktop }">
<div class="faq-list-card faq-list-card--workbench">
<el-table
:data="items"
v-loading="loading"
@@ -7,7 +7,7 @@
class="faq-table"
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
:row-class-name="rowClassName"
:height="isDesktop ? '100%' : undefined"
height="100%"
@row-click="onRowClick"
table-layout="fixed"
>
@@ -61,7 +61,6 @@ import { deleteFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs";
import { displayDateTime, displayEnum } from "../utils/display";
import { TEXT } from "../locales";
import { isTauriRuntime } from "../runtime";
const props = defineProps<{
items: FaqItem[];
@@ -73,7 +72,6 @@ const props = defineProps<{
const emit = defineEmits(["refresh"]);
const router = useRouter();
const isDesktop = isTauriRuntime();
const categoryMap = computed(() =>
props.categories.reduce<Record<string, string>>((acc, cur) => {
@@ -271,7 +269,7 @@ const remove = async (row: FaqItem) => {
cursor: pointer;
}
.faq-list-card--desktop {
.faq-list-card--workbench {
display: flex;
height: 100%;
min-height: 0;
@@ -279,17 +277,17 @@ const remove = async (row: FaqItem) => {
background: #ffffff;
}
.faq-list-card--desktop .faq-table {
.faq-list-card--workbench .faq-table {
height: 100%;
min-height: 0;
flex: 1 1 auto;
}
.faq-list-card--desktop .faq-table :deep(.el-table__inner-wrapper) {
.faq-list-card--workbench .faq-table :deep(.el-table__inner-wrapper) {
height: 100%;
}
.faq-list-card--desktop .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
.faq-list-card--workbench .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
height: 42px;
padding: 0 14px;
border-bottom: 1px solid rgba(121, 152, 188, 0.28) !important;
@@ -299,37 +297,37 @@ const remove = async (row: FaqItem) => {
font-weight: 850;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body) {
.faq-list-card--workbench .faq-table :deep(.el-table__body) {
border-collapse: separate;
border-spacing: 0 6px;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body-wrapper) {
.faq-list-card--workbench .faq-table :deep(.el-table__body-wrapper) {
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
scrollbar-width: none;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body-wrapper::-webkit-scrollbar),
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__wrap::-webkit-scrollbar) {
.faq-list-card--workbench .faq-table :deep(.el-table__body-wrapper::-webkit-scrollbar),
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__wrap::-webkit-scrollbar) {
display: none;
width: 0;
height: 0;
}
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__wrap) {
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__wrap) {
scrollbar-width: none;
}
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__bar.is-vertical) {
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__bar.is-vertical) {
display: none !important;
}
.faq-list-card--desktop .faq-table :deep(.el-table__empty-block) {
.faq-list-card--workbench .faq-table :deep(.el-table__empty-block) {
min-height: 100%;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell) {
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell) {
padding: 10px 14px;
border-top: 1px solid rgba(226, 235, 246, 0.92);
border-bottom: 1px solid rgba(226, 235, 246, 0.92);
@@ -337,27 +335,27 @@ const remove = async (row: FaqItem) => {
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell:first-child) {
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell:first-child) {
border-left: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 9px 0 0 9px;
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell:last-child) {
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell:last-child) {
border-right: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 0 9px 9px 0;
}
.faq-list-card--desktop .faq-table :deep(.faq-row:hover td.el-table__cell) {
.faq-list-card--workbench .faq-table :deep(.faq-row:hover td.el-table__cell) {
border-color: rgba(111, 159, 220, 0.34);
background: #f5faff;
box-shadow: 0 8px 18px rgba(45, 86, 143, 0.06);
}
.faq-list-card--desktop .question-cell {
.faq-list-card--workbench .question-cell {
gap: 10px;
}
.faq-list-card--desktop .question-icon {
.faq-list-card--workbench .question-icon {
width: 28px;
height: 28px;
border-radius: 8px;
@@ -367,13 +365,13 @@ const remove = async (row: FaqItem) => {
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.faq-list-card--desktop .question-link {
.faq-list-card--workbench .question-link {
color: #0f172a;
font-size: 13px;
font-weight: 700;
}
.faq-list-card--desktop .category-pill {
.faq-list-card--workbench .category-pill {
height: 24px;
padding: 0 9px;
border-radius: 7px;
@@ -381,17 +379,17 @@ const remove = async (row: FaqItem) => {
font-size: 12px;
}
.faq-list-card--desktop .time-text {
.faq-list-card--workbench .time-text {
gap: 0;
font-size: 11px;
}
:global([data-ctms-theme="dark"] .faq-list-card--desktop .faq-table .faq-row td.el-table__cell) {
:global([data-ctms-theme="dark"] .faq-list-card--workbench .faq-table .faq-row td.el-table__cell) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .faq-list-card--desktop .faq-table .faq-row:hover td.el-table__cell) {
:global([data-ctms-theme="dark"] .faq-list-card--workbench .faq-table .faq-row:hover td.el-table__cell) {
background: #1d2b42;
}
</style>
+44 -15
View File
@@ -27,6 +27,7 @@ const readMainStyleSource = () => readSource(resolve(__dirname, "../styles/main.
const readFaqSource = () => readSource(resolve(__dirname, "../views/Faq.vue"));
const readFaqListSource = () => readSource(resolve(__dirname, "./FaqList.vue"));
const readProjectOverviewSource = () => readSource(resolve(__dirname, "../views/ia/ProjectOverview.vue"));
const readProjectMilestonesSource = () => readSource(resolve(__dirname, "../views/ia/ProjectMilestones.vue"));
describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => {
@@ -373,6 +374,16 @@ describe("desktop layout shell", () => {
expect(source).toContain("const removeWorkspaceTabContext = (tabPath: string) => {");
});
it("closes transient preview tasks back to their originating workspace tab", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("const workspaceTaskOrigins = ref<Record<string, string>>({});");
expect(source).toContain("if (route.meta.transientWorkspaceTask && previousTabPath && previousTabPath !== current.path)");
expect(source).toContain("workspaceTabs.value.find((tab) => tab.path === originPath)");
expect(source).toContain("provide(workspaceTaskControllerKey, { closeTransientTask: closeTransientWorkspaceTask });");
expect(source).toContain("if (!workspaceTaskOrigins.value[path] && workspaceTabs.value.length <= 1) return false;");
});
it("allows desktop shortcuts to be customized from system preferences", () => {
const preferences = readDesktopPreferencesSource();
const tauriLib = readTauriLibSource();
@@ -625,32 +636,50 @@ describe("desktop layout shell", () => {
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog');
});
it("keeps first-pass desktop content pages workbench-oriented", () => {
it("shares the medical-consult workbench UI across web and desktop shells", () => {
const faq = readFaqSource();
const faqList = readFaqListSource();
const projectOverview = readProjectOverviewSource();
const projectMilestones = readProjectMilestonesSource();
const webLayout = readWebLayoutSource();
expect(faq).toContain("const isDesktop = isTauriRuntime();");
expect(faq).toContain(":class=\"{ 'medical-consult-page--desktop': isDesktop }\"");
expect(faq).toContain('v-if="!isDesktop" class="page-bg-dots"');
expect(faq).toContain('<h1 v-if="!isDesktop"');
expect(faq).toContain('v-else class="desktop-toolbar-meta"');
expect(faq).toContain('v-if="isDesktop" action="faq.create"');
expect(faq).toContain('<div v-if="!isDesktop" class="list-toolbar">');
expect(faq).toContain(".medical-consult-page--desktop .faq-workspace");
expect(faq).toContain("medical-consult-page--workbench");
expect(faq).toContain('class="workbench-toolbar-meta"');
expect(faq).toContain('<PermissionAction action="faq.create">');
expect(faq).not.toContain("const isDesktop = isTauriRuntime();");
expect(faq).not.toContain("page-bg-dots");
expect(faq).not.toContain("list-toolbar");
expect(faq).toContain(".medical-consult-page--workbench .faq-workspace");
expect(faq).toContain("grid-template-columns: 236px minmax(0, 1fr);");
expect(faqList).toContain(":class=\"{ 'faq-list-card--desktop': isDesktop }\"");
expect(faqList).toContain("const isDesktop = isTauriRuntime();");
expect(faqList).toContain(".faq-list-card--desktop .faq-table");
expect(faqList).toContain('class="faq-list-card faq-list-card--workbench"');
expect(faqList).toContain('height="100%"');
expect(faqList).not.toContain("const isDesktop = isTauriRuntime();");
expect(faqList).toContain(".faq-list-card--workbench .faq-table");
expect(projectOverview).toContain(":class=\"{ 'project-overview--desktop': isDesktop }\"");
expect(projectOverview).not.toContain("overview-summary-strip");
expect(projectOverview).not.toContain("const activeCenterCount");
expect(projectOverview).toContain("desktop-attention-section");
expect(projectOverview).not.toContain("项目关注");
expect(projectOverview).not.toContain("desktop-attention-head");
expect(projectOverview).toContain('class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
expect(projectOverview.indexOf("刷新")).toBeLessThan(projectOverview.indexOf('class="overview-updated-at"'));
expect(projectOverview.indexOf('class="overview-updated-at"')).toBeLessThan(projectOverview.indexOf('class="progress-legend"'));
expect(projectOverview).toContain('class="overview-live-clock">数据 {{ overviewClockText }}</span>');
expect(projectOverview).toContain('v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
expect(projectOverview.indexOf("刷新")).toBeLessThan(projectOverview.indexOf('class="overview-live-clock"'));
expect(projectOverview.indexOf('class="overview-live-clock"')).toBeLessThan(projectOverview.indexOf('class="progress-legend"'));
expect(projectOverview).toContain("overviewClockTimer = window.setInterval");
expect(webLayout).not.toContain("headerClockText");
expect(webLayout).not.toContain('key: "updatedAt"');
expect(webLayout).not.toContain('class="header-center"');
expect(webLayout).not.toContain("headerProjectInfo");
expect(webLayout).not.toContain("fetchProjectOverview");
expect(webLayout).not.toContain("loadHeaderOverviewStats");
expect(webLayout).not.toContain("header-status-pill");
expect(webLayout).not.toContain("header-meta-item");
expect(projectMilestones).toContain('class="unified-action-bar milestone-action-bar"');
expect(projectMilestones).not.toContain('class="unified-action-bar bar--flush"');
expect(projectMilestones).not.toContain("sourceLabel");
expect(projectMilestones).not.toContain("dataSourceLabel");
expect(projectMilestones).not.toContain("updatedAtLabel");
expect(projectMilestones).toContain(".milestone-action-bar {\n min-height: 56px;\n box-sizing: border-box;");
expect(projectOverview).toContain("overview-workbench");
expect(projectOverview).toContain("enrollment-snapshot");
expect(projectOverview).toContain("desktop-attention-board");
@@ -0,0 +1,111 @@
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isProxy, reactive } from "vue";
vi.mock("../runtime", () => ({
resolveOnlyOfficeHostUrl: () => new URL("https://ctms.example/onlyoffice-host.html"),
}));
import OnlyOfficeViewer from "./OnlyOfficeViewer.vue";
const dispatchHostMessage = (source: MessageEventSource | null, origin: string, data: Record<string, unknown>) => {
window.dispatchEvent(new MessageEvent("message", { source, origin, data }));
};
describe("OnlyOfficeViewer bridge", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("initializes when the host iframe finishes loading without waiting for host-ready", async () => {
const config = reactive({ token: "signed-config", document: { title: "原始文件名.xlsx" } });
expect(isProxy(config)).toBe(true);
const wrapper = mount(OnlyOfficeViewer, { props: { config } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
const iframe = wrapper.get("iframe");
Object.defineProperty(iframe.element, "contentWindow", { configurable: true, value: frameWindow });
await iframe.trigger("load");
expect(postMessage).toHaveBeenCalledOnce();
expect(postMessage.mock.calls[0][0]).toMatchObject({
type: "ctms.onlyoffice.init",
config: { token: "signed-config", document: { title: "原始文件名.xlsx" } },
});
expect(isProxy((postMessage.mock.calls[0][0] as { config: object }).config)).toBe(false);
expect(postMessage.mock.calls[0][1]).toBe("https://ctms.example");
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).toHaveBeenCalledOnce();
wrapper.unmount();
});
it("reports a host initialization error when the config cannot be posted", async () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: { token: "signed-config" } } });
const frameWindow = {
postMessage: vi.fn(() => {
throw new DOMException("could not be cloned", "DataCloneError");
}),
} as unknown as Window;
const iframe = wrapper.get("iframe");
Object.defineProperty(iframe.element, "contentWindow", { configurable: true, value: frameWindow });
await iframe.trigger("load");
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_INIT_FAILED" });
wrapper.unmount();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("accepts one initialization from the exact host window and validates the nonce", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: { token: "signed-config" } } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).toHaveBeenCalledOnce();
const initMessage = postMessage.mock.calls[0][0] as { type: string; nonce: string; config: Record<string, unknown> };
expect(initMessage.type).toBe("ctms.onlyoffice.init");
expect(initMessage.config).toEqual({ token: "signed-config" });
expect(postMessage.mock.calls[0][1]).toBe("https://ctms.example");
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.document-ready",
nonce: "wrong",
});
expect(wrapper.emitted("ready")).toBeUndefined();
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.document-ready",
nonce: initMessage.nonce,
});
expect(wrapper.emitted("ready")).toHaveLength(1);
wrapper.unmount();
});
it("rejects messages with the wrong origin or source", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://evil.example", { type: "ctms.onlyoffice.host-ready" });
dispatchHostMessage(window, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).not.toHaveBeenCalled();
wrapper.unmount();
});
it("reports host and document timeouts without falling back to a download", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
vi.advanceTimersByTime(15_000);
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_LOAD_TIMEOUT" });
wrapper.unmount();
});
});
@@ -0,0 +1,147 @@
<template>
<div class="onlyoffice-viewer">
<iframe
:key="nonce"
ref="frameRef"
class="onlyoffice-viewer__frame"
:src="hostUrl.href"
title="ONLYOFFICE 文档查看器"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
allow="clipboard-read 'none'; clipboard-write 'none'"
referrerpolicy="no-referrer"
@load="handleFrameLoad"
/>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, toRaw } from "vue";
import { resolveOnlyOfficeHostUrl } from "../runtime";
import type { OnlyOfficeHostMessage } from "../types/onlyoffice";
const HOST_READY = "ctms.onlyoffice.host-ready";
const INIT = "ctms.onlyoffice.init";
const DOCUMENT_READY = "ctms.onlyoffice.document-ready";
const WARNING = "ctms.onlyoffice.warning";
const ERROR = "ctms.onlyoffice.error";
const HOST_LOAD_TIMEOUT_MS = 15_000;
const DOCUMENT_LOAD_TIMEOUT_MS = 120_000;
const props = defineProps<{ config: Record<string, unknown> }>();
const emit = defineEmits<{
ready: [];
warning: [detail: Record<string, unknown>];
error: [detail: Record<string, unknown>];
}>();
const frameRef = ref<HTMLIFrameElement | null>(null);
const hostUrl = resolveOnlyOfficeHostUrl();
const createNonce = () => {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, "0")).join("");
};
const nonce = createNonce();
let initialized = false;
let hostTimer: number | undefined;
let documentTimer: number | undefined;
const clearTimers = () => {
if (hostTimer !== undefined) window.clearTimeout(hostTimer);
if (documentTimer !== undefined) window.clearTimeout(documentTimer);
hostTimer = undefined;
documentTimer = undefined;
};
const emitTimeout = (code: string, message: string) => {
clearTimers();
initialized = true;
emit("error", { code, message });
};
const initializeHost = () => {
if (initialized) return;
const frameWindow = frameRef.value?.contentWindow;
if (!frameWindow) return;
try {
// Vue 会将接口响应递归包装为 Proxy,而 Proxy 不能通过 structured clone
// 传递给 iframe。JSON 配置本身只包含 ONLYOFFICE 支持的 JSON 类型。
const config = JSON.parse(JSON.stringify(toRaw(props.config))) as Record<string, unknown>;
frameWindow.postMessage({ type: INIT, nonce, config }, hostUrl.origin);
} catch {
emitTimeout("HOST_INIT_FAILED", "预览配置初始化失败,请重新加载");
return;
}
initialized = true;
if (hostTimer !== undefined) window.clearTimeout(hostTimer);
hostTimer = undefined;
documentTimer = window.setTimeout(
() => emitTimeout("DOCUMENT_LOAD_TIMEOUT", "文档转换或加载超时,请稍后重试"),
DOCUMENT_LOAD_TIMEOUT_MS,
);
};
// iframe 的 load 事件发生在宿主页 defer 脚本完成执行之后,可以直接初始化。
// host-ready 仍作为慢加载和桌面 WebView 场景的兜底握手。
const handleFrameLoad = initializeHost;
const handleMessage = (event: MessageEvent<OnlyOfficeHostMessage>) => {
const frameWindow = frameRef.value?.contentWindow;
if (!frameWindow || event.source !== frameWindow || event.origin !== hostUrl.origin) return;
const message = event.data;
if (!message || typeof message !== "object" || typeof message.type !== "string") return;
if (message.type === HOST_READY && !initialized) {
initializeHost();
return;
}
if (message.nonce !== nonce) return;
if (message.type === DOCUMENT_READY) {
if (documentTimer !== undefined) window.clearTimeout(documentTimer);
documentTimer = undefined;
emit("ready");
} else if (message.type === WARNING) {
emit("warning", message.detail || {});
} else if (message.type === ERROR) {
clearTimers();
emit("error", message.detail || {});
}
};
onMounted(() => {
window.addEventListener("message", handleMessage);
hostTimer = window.setTimeout(
() => emitTimeout("HOST_LOAD_TIMEOUT", "预览服务加载超时,请检查服务状态后重试"),
HOST_LOAD_TIMEOUT_MS,
);
});
onBeforeUnmount(() => {
clearTimers();
window.removeEventListener("message", handleMessage);
if (frameRef.value) frameRef.value.src = "about:blank";
});
</script>
<style scoped>
.onlyoffice-viewer,
.onlyoffice-viewer__frame {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.onlyoffice-viewer {
overflow: hidden;
background: #f3f5f8;
}
.onlyoffice-viewer__frame {
display: block;
border: 0;
background: #fff;
}
</style>
+37 -464
View File
@@ -5,9 +5,10 @@
@keydown.esc="closeMobileNav"
>
<el-aside
:width="isMobileViewport ? '280px' : (isCollapsed ? '68px' : '200px')"
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
class="layout-aside"
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
:inert="!isMobileViewport && isCollapsed"
>
<div class="aside-logo">
<img class="logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
@@ -141,7 +142,7 @@
class="collapse-btn"
:aria-expanded="isMobileViewport ? isMobileNavOpen : undefined"
@click="toggleCollapse"
:aria-label="isMobileViewport ? '打开导航菜单' : TEXT.common.labels.collapseSidebar"
:aria-label="isMobileViewport ? '打开导航菜单' : (isCollapsed ? '显示导航栏' : TEXT.common.labels.collapseSidebar)"
>
<el-icon class="collapse-icon">
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -187,35 +188,6 @@
</div>
</div>
<!-- 顶栏中间区常驻上下文信息 -->
<div class="header-center">
<template v-if="headerProjectInfo">
<div class="header-context-rail">
<span
v-if="headerProjectInfo.statusLabel"
class="header-status-pill"
:class="`is-${headerProjectInfo.statusTone}`"
>
<span class="header-status-dot"></span>
{{ headerProjectInfo.statusLabel }}
</span>
<span v-if="headerProjectInfo.isLocked" class="header-meta-item header-locked">
<el-icon><Lock /></el-icon>
{{ TEXT.common.labels.locked }}
</span>
<span
v-for="headerMetric in headerProjectInfo.metrics"
:key="headerMetric.key"
class="header-meta-item header-metric"
:class="`is-${headerMetric.key}`"
>
<span class="header-meta-label">{{ headerMetric.label }}</span>
<span class="header-meta-value">{{ headerMetric.value }}</span>
</span>
</div>
</template>
</div>
<div class="header-right">
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
<el-icon><Suitcase /></el-icon>
@@ -357,8 +329,8 @@
</div>
</el-header>
<el-main class="layout-main">
<div class="content-wrapper">
<el-main class="layout-main" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<div class="content-wrapper" :class="{ 'is-full-bleed': route.meta.fullBleed }">
<router-view v-slot="{ Component, route: currentRoute }">
<transition name="fade-transform" mode="out-in">
<div :key="currentRoute.fullPath" class="ctms-route-shell">
@@ -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<DesktopRoutePreference[]>(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<string, string>)[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;
@@ -163,6 +163,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Document, UploadFilled } from "@element-plus/icons-vue";
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
@@ -177,6 +178,7 @@ import { TEXT } from "../../locales";
import { clientRuntime } from "../../runtime";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../../session/desktopActivityCenter";
import { detectFilePreviewKind } from "../../utils/officePreview";
type AttachmentEntityGroup = {
entityType: string;
@@ -223,6 +225,7 @@ const progressMap = reactive<Record<string, number>>({});
const pendingMap = reactive<Record<string, PendingUploadItem[]>>({});
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const previewVisible = ref(false);
const previewUrl = ref("");
const previewObjectUrl = ref("");
@@ -518,17 +521,8 @@ const openExternally = async (row: any) => {
}
};
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
const getMimeType = (row: any) => (row?.mime_type || row?.content_type || "").toLowerCase();
const detectPreviewType = (row: any) => {
const mime = getMimeType(row);
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
const name = getFileName(row);
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other";
return detectFilePreviewKind(row?.filename, row?.mime_type || row?.content_type);
};
const previewImage = async (row: any) => {
@@ -547,7 +541,12 @@ const previewImage = async (row: any) => {
const preview = async (row: any) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
const detectedType = detectPreviewType(row);
if (detectedType === "office") {
await router.push(`/office-preview/attachment/${row.id}`);
return;
}
previewType.value = detectedType;
previewTitle.value = row?.filename || TEXT.common.labels.preview;
if (previewType.value === "image" || previewType.value === "pdf") {
previewUrl.value = "";
@@ -0,0 +1,7 @@
import type { InjectionKey } from "vue";
export interface WorkspaceTaskController {
closeTransientTask(path: string): boolean;
}
export const workspaceTaskControllerKey: InjectionKey<WorkspaceTaskController> = Symbol("workspaceTaskController");
+12
View File
@@ -5,6 +5,18 @@ import { resolve } from "node:path";
const readRouter = () => readFileSync(resolve(__dirname, "./router/index.ts"), "utf8").replace(/\r\n/g, "\n");
describe("admin project route permissions", () => {
it("registers independent full-bleed routes for attachment and version Office previews", () => {
const source = readRouter();
expect(source).toContain('path: "office-preview/attachment/:id"');
expect(source).toContain('name: "OfficeAttachmentPreview"');
expect(source).toContain('path: "office-preview/version/:id"');
expect(source).toContain('name: "OfficeVersionPreview"');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(2);
expect(source.match(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
expect(source.match(/transientWorkspaceTask: true/g)?.length).toBeGreaterThanOrEqual(2);
});
it("lets any authorized project role open the project management detail page", () => {
const source = readRouter();
+13
View File
@@ -35,6 +35,7 @@ import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue";
import DocumentList from "../views/documents/DocumentList.vue";
import DocumentDetail from "../views/documents/DocumentDetail.vue";
import OfficePreviewWorkspace from "../views/OfficePreviewWorkspace.vue";
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
import SubjectManagement from "../views/ia/SubjectManagement.vue";
@@ -193,6 +194,18 @@ const routes: RouteRecordRaw[] = [
component: DocumentDetail,
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
},
{
path: "office-preview/attachment/:id",
name: "OfficeAttachmentPreview",
component: OfficePreviewWorkspace,
meta: { title: "Office 文件预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
},
{
path: "office-preview/version/:id",
name: "OfficeVersionPreview",
component: OfficePreviewWorkspace,
meta: { title: "Office 文件预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
},
{
path: "startup/feasibility-ethics",
name: "StartupFeasibilityEthics",
+1
View File
@@ -79,6 +79,7 @@ export {
type NotificationPermissionState,
} from "./notifications";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
export { ONLYOFFICE_HOST_PATH, resolveOnlyOfficeHostUrl } from "./onlyoffice";
export {
clearSessionToken,
getSessionToken,
+12
View File
@@ -0,0 +1,12 @@
import { resolveApiBaseUrl } from "./apiBaseUrl";
export const ONLYOFFICE_HOST_PATH = "/onlyoffice-host.html";
/**
* ONLYOFFICE 宿主页只能由受控服务器基址和固定路径组合,业务数据不得参与 frame 地址构造。
*/
export const resolveOnlyOfficeHostUrl = (): URL => {
const browserOrigin = typeof window === "undefined" ? "http://localhost" : window.location.origin;
const apiBase = new URL(resolveApiBaseUrl(), `${browserOrigin}/`);
return new URL(ONLYOFFICE_HOST_PATH, apiBase.origin);
};
+58
View File
@@ -994,6 +994,64 @@ body {
border-bottom-color: #26364a;
}
/* Web workbench edge alignment
* Keep page-level spacing consistent with the desktop shell: the route owns
* the full main-content canvas while cards and sections own their inner
* padding. This also replaces page-specific negative-margin workarounds.
*/
@media (min-width: 768px) {
.web-layout-container .ctms-route-shell {
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.web-layout-container .ctms-route-shell > * {
width: 100%;
min-width: 0;
min-height: 100%;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
box-sizing: border-box;
}
.web-layout-container .ctms-route-shell > .page,
.web-layout-container .ctms-route-shell > .ctms-page,
.web-layout-container .ctms-route-shell > .ctms-page-shell,
.web-layout-container .ctms-route-shell > .module-page,
.web-layout-container .ctms-route-shell > .medical-consult-page {
gap: 8px;
}
.web-layout-container .ctms-route-shell .page-inner,
.web-layout-container .ctms-route-shell .page-body,
.web-layout-container .ctms-route-shell .main-content,
.web-layout-container .ctms-route-shell .main-content-card,
.web-layout-container .ctms-route-shell .main-content-flat,
.web-layout-container .ctms-route-shell .study-home-container,
.web-layout-container .ctms-route-shell .overview,
.web-layout-container .ctms-route-shell .content-area {
min-width: 0;
max-width: none;
width: 100%;
margin-right: 0 !important;
margin-left: 0 !important;
padding-right: 0 !important;
padding-left: 0 !important;
}
.web-layout-container .ctms-route-shell > .page > .table-card,
.web-layout-container .ctms-route-shell > .page > .page-inner > .table-card,
.web-layout-container .ctms-route-shell > .page.page--flush > .unified-shell,
.web-layout-container .ctms-route-shell > .ctms-page-shell.page--flush > .unified-shell {
width: 100%;
margin: 0 !important;
border-radius: 0 !important;
}
}
/* ============================================================
* 全局弹窗定位修复:弹窗仅显示在主内容区域,不覆盖左侧菜单栏
* --ctms-sidebar-width 由 Layout.vue 在 body 上动态设置
+16
View File
@@ -0,0 +1,16 @@
export type OnlyOfficeResourceType = "attachment" | "version";
export interface OnlyOfficePreviewConfig {
resource_type: OnlyOfficeResourceType;
resource_id: string;
file_name: string;
host_path: string;
expires_at: string;
config: Record<string, unknown>;
}
export interface OnlyOfficeHostMessage {
type: string;
nonce?: string;
detail?: Record<string, unknown>;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { detectFilePreviewKind, onlyOfficeDocumentType } from "./officePreview";
describe("ONLYOFFICE format contract", () => {
it.each([
["doc", "word"], ["docx", "word"], ["docm", "word"], ["dot", "word"], ["dotx", "word"],
["dotm", "word"], ["odt", "word"], ["ott", "word"], ["rtf", "word"], ["txt", "word"],
["wps", "word"], ["wpt", "word"], ["xls", "cell"], ["xlsx", "cell"], ["xlsm", "cell"],
["xlsb", "cell"], ["xlt", "cell"], ["xltx", "cell"], ["xltm", "cell"], ["ods", "cell"],
["ots", "cell"], ["csv", "cell"], ["et", "cell"], ["ett", "cell"], ["ppt", "slide"],
["pptx", "slide"], ["pptm", "slide"], ["pps", "slide"], ["ppsx", "slide"], ["ppsm", "slide"],
["pot", "slide"], ["potx", "slide"], ["potm", "slide"], ["odp", "slide"], ["otp", "slide"],
["dps", "slide"], ["dpt", "slide"],
] as const)("maps .%s to %s", (extension, documentType) => {
expect(onlyOfficeDocumentType(`原始文件名.${extension.toUpperCase()}`)).toBe(documentType);
expect(detectFilePreviewKind(`原始文件名.${extension}`, "application/octet-stream")).toBe("office");
});
it("keeps PDF and images on their existing preview paths", () => {
expect(detectFilePreviewKind("方案.pdf", "application/pdf")).toBe("pdf");
expect(detectFilePreviewKind("扫描.PNG", "image/png")).toBe("image");
expect(onlyOfficeDocumentType("方案.pdf")).toBeNull();
});
it("uses the original filename extension before an unreliable MIME type", () => {
expect(detectFilePreviewKind("方案.docx", "application/pdf")).toBe("office");
expect(detectFilePreviewKind("扫描.pdf", "image/png")).toBe("pdf");
});
});
+46
View File
@@ -0,0 +1,46 @@
export type FilePreviewKind = "image" | "pdf" | "office" | "other";
export type OnlyOfficeDocumentType = "word" | "cell" | "slide";
export const ONLYOFFICE_WORD_EXTENSIONS = [
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt",
] as const;
export const ONLYOFFICE_CELL_EXTENSIONS = [
"xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm", "ods", "ots", "csv", "et", "ett",
] as const;
export const ONLYOFFICE_SLIDE_EXTENSIONS = [
"ppt", "pptx", "pptm", "pps", "ppsx", "ppsm", "pot", "potx", "potm", "odp", "otp", "dps", "dpt",
] as const;
export const ONLYOFFICE_FILE_EXTENSIONS = [
...ONLYOFFICE_WORD_EXTENSIONS,
...ONLYOFFICE_CELL_EXTENSIONS,
...ONLYOFFICE_SLIDE_EXTENSIONS,
] as const;
const WORD_EXTENSIONS = new Set<string>(ONLYOFFICE_WORD_EXTENSIONS);
const CELL_EXTENSIONS = new Set<string>(ONLYOFFICE_CELL_EXTENSIONS);
const SLIDE_EXTENSIONS = new Set<string>(ONLYOFFICE_SLIDE_EXTENSIONS);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"]);
export const fileExtension = (filename?: string | null): string => {
const normalized = String(filename || "").trim().toLocaleLowerCase();
const separator = normalized.lastIndexOf(".");
return separator >= 0 && separator < normalized.length - 1 ? normalized.slice(separator + 1) : "";
};
export const onlyOfficeDocumentType = (filename?: string | null): OnlyOfficeDocumentType | null => {
const extension = fileExtension(filename);
if (WORD_EXTENSIONS.has(extension)) return "word";
if (CELL_EXTENSIONS.has(extension)) return "cell";
if (SLIDE_EXTENSIONS.has(extension)) return "slide";
return null;
};
export const detectFilePreviewKind = (filename?: string | null, mimeType?: string | null): FilePreviewKind => {
const mime = String(mimeType || "").trim().toLocaleLowerCase();
const extension = fileExtension(filename);
if (IMAGE_EXTENSIONS.has(extension)) return "image";
if (extension === "pdf") return "pdf";
if (onlyOfficeDocumentType(filename)) return "office";
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
return "other";
};
+38 -96
View File
@@ -1,9 +1,7 @@
<template>
<div class="page ctms-page-shell page--flush medical-consult-page" :class="{ 'medical-consult-page--desktop': isDesktop }">
<div v-if="!isDesktop" class="page-bg-dots"></div>
<div class="page ctms-page-shell page--flush medical-consult-page medical-consult-page--workbench">
<section class="faq-hero">
<h1 v-if="!isDesktop"><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
<div v-else class="desktop-toolbar-meta">
<div class="workbench-toolbar-meta">
<span>知识条目</span>
<small>{{ activeCategoryName }} · {{ resultSummary }}</small>
</div>
@@ -23,13 +21,12 @@
<el-icon><Search /></el-icon>
</template>
</el-autocomplete>
<PermissionAction v-if="isDesktop" action="faq.create">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
<div v-else class="spacer" />
</div>
</section>
@@ -44,15 +41,6 @@
</el-col>
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell">
<div v-if="!isDesktop" class="list-toolbar">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
</div>
<FaqList
:items="faqs"
:categories="categories"
@@ -84,12 +72,11 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { Collection, Plus, Search } from "@element-plus/icons-vue";
import { Plus, Search } from "@element-plus/icons-vue";
import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue";
import FaqItemForm from "../components/FaqItemForm.vue";
import { isTauriRuntime } from "../runtime";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue";
@@ -98,7 +85,6 @@ import { TEXT } from "../locales";
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const categories = ref<any[]>([]);
const faqs = ref<any[]>([]);
@@ -230,15 +216,6 @@ onMounted(async () => {
background: linear-gradient(180deg, #f0f4ff 0%, #ffffff 30%, #f8fafc 100%);
}
.page-bg-dots {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: radial-gradient(#1e40af 1px, transparent 1px);
background-size: 24px 24px;
}
.faq-hero {
position: relative;
padding: 28px 24px 36px;
@@ -262,30 +239,6 @@ onMounted(async () => {
pointer-events: none;
}
.faq-hero h1 {
margin: 0 0 24px;
color: #0f172a;
font-size: 32px;
font-weight: 900;
letter-spacing: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.hero-title-icon {
font-size: 28px;
color: #3b82f6;
opacity: 0.85;
}
.hero-desc {
margin: 8px 0 28px;
color: #64748b;
font-size: 15px;
font-weight: 500;
}
.hero-tools {
display: flex;
align-items: center;
@@ -349,13 +302,6 @@ onMounted(async () => {
padding: 24px 28px 0;
overflow: hidden;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 0 16px;
}
.list-title strong {
min-width: 30px;
height: 24px;
@@ -377,11 +323,7 @@ onMounted(async () => {
justify-content: flex-end;
}
.spacer {
flex: 1;
}
.desktop-toolbar-meta {
.workbench-toolbar-meta {
display: flex;
position: relative;
min-width: 160px;
@@ -391,7 +333,7 @@ onMounted(async () => {
text-align: left;
}
.desktop-toolbar-meta::before {
.workbench-toolbar-meta::before {
content: "";
position: absolute;
top: 2px;
@@ -403,19 +345,19 @@ onMounted(async () => {
box-shadow: 0 0 16px rgba(47, 123, 232, 0.35);
}
.desktop-toolbar-meta span {
.workbench-toolbar-meta span {
color: #11335a;
font-size: 15px;
font-weight: 850;
}
.desktop-toolbar-meta small {
.workbench-toolbar-meta small {
color: #436785;
font-size: 12px;
font-weight: 600;
}
.medical-consult-page--desktop {
.medical-consult-page--workbench {
display: flex;
height: 100%;
min-height: 0;
@@ -425,7 +367,7 @@ onMounted(async () => {
background: linear-gradient(180deg, #eef5ff 0%, #f7fbff 100%);
}
.medical-consult-page--desktop .faq-hero {
.medical-consult-page--workbench .faq-hero {
display: flex;
align-items: center;
justify-content: space-between;
@@ -443,22 +385,22 @@ onMounted(async () => {
border-radius: 0 !important;
}
.medical-consult-page--desktop .faq-hero::before {
.medical-consult-page--workbench .faq-hero::before {
display: none;
}
.medical-consult-page--desktop .hero-tools {
.medical-consult-page--workbench .hero-tools {
width: auto;
max-width: none;
margin: 0;
justify-content: flex-end;
}
.medical-consult-page--desktop .hero-search {
.medical-consult-page--workbench .hero-search {
width: min(460px, 44vw);
}
.medical-consult-page--desktop .hero-search :deep(.el-input__wrapper) {
.medical-consult-page--workbench .hero-search :deep(.el-input__wrapper) {
height: 34px;
padding: 0 12px;
border: 0;
@@ -470,17 +412,17 @@ onMounted(async () => {
transition: border-color 0.2s, box-shadow 0.2s;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__inner) {
.medical-consult-page--workbench .hero-search :deep(.el-input__inner) {
font-size: 13px;
font-weight: 600;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__prefix) {
.medical-consult-page--workbench .hero-search :deep(.el-input__prefix) {
color: #6d93bc;
font-size: 15px;
}
.medical-consult-page--desktop .new-consult-btn {
.medical-consult-page--workbench .new-consult-btn {
height: 34px;
padding: 0 16px;
border-radius: 8px;
@@ -492,14 +434,14 @@ onMounted(async () => {
transition: all 0.2s;
}
.medical-consult-page--desktop .new-consult-btn:hover,
.medical-consult-page--desktop .new-consult-btn:focus {
.medical-consult-page--workbench .new-consult-btn:hover,
.medical-consult-page--workbench .new-consult-btn:focus {
background: linear-gradient(135deg, #3d6bbf 0%, #254d90 100%);
box-shadow: 0 4px 14px rgba(79, 126, 207, 0.4);
transform: translateY(-1px);
}
.medical-consult-page--desktop .faq-workspace {
.medical-consult-page--workbench .faq-workspace {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
flex: 1 1 auto;
@@ -512,14 +454,14 @@ onMounted(async () => {
overflow: hidden;
}
.medical-consult-page--desktop .faq-sidebar-col,
.medical-consult-page--desktop .faq-content-col {
.medical-consult-page--workbench .faq-sidebar-col,
.medical-consult-page--workbench .faq-content-col {
width: auto;
max-width: none !important;
flex: initial !important;
}
.medical-consult-page--desktop .faq-sidebar-col {
.medical-consult-page--workbench .faq-sidebar-col {
min-height: 0;
border-right: 1px solid rgba(134, 163, 194, 0.22);
background:
@@ -528,13 +470,13 @@ onMounted(async () => {
overflow: hidden;
}
.medical-consult-page--desktop .faq-content-col {
.medical-consult-page--workbench .faq-content-col {
display: flex;
min-height: 0;
overflow: hidden;
}
.medical-consult-page--desktop .faq-main {
.medical-consult-page--workbench .faq-main {
display: flex;
width: 100%;
height: 100%;
@@ -547,31 +489,31 @@ onMounted(async () => {
background: #ffffff;
}
.medical-consult-page--desktop .pagination-wrap {
.medical-consult-page--workbench .pagination-wrap {
flex: 0 0 auto;
padding: 10px 14px;
border-top: 1px solid #edf3fa;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.medical-consult-page--desktop :deep(.faq-category-panel) {
.medical-consult-page--workbench :deep(.faq-category-panel) {
height: 100%;
padding: 12px 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .header) {
.medical-consult-page--workbench :deep(.faq-category-panel .header) {
padding: 10px 10px;
font-size: 12px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu) {
.medical-consult-page--workbench :deep(.faq-category-panel .menu) {
flex: 1 1 auto;
max-height: none;
min-height: 0;
padding-top: 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu .el-menu-item) {
.medical-consult-page--workbench :deep(.faq-category-panel .menu .el-menu-item) {
height: 36px;
margin: 4px 0;
border-radius: 8px;
@@ -579,27 +521,27 @@ onMounted(async () => {
line-height: 36px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .cat-icon) {
.medical-consult-page--workbench :deep(.faq-category-panel .cat-icon) {
width: 22px;
height: 22px;
border-radius: 7px;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-workspace) {
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-workspace) {
border-color: #26364a;
background:
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 34%),
linear-gradient(135deg, #172033 0%, #111827 100%) !important;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-sidebar-col) {
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-sidebar-col) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .desktop-toolbar-meta span) {
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .workbench-toolbar-meta span) {
color: #e5edf7;
}
@@ -619,16 +561,16 @@ onMounted(async () => {
padding: 18px 16px 0;
}
.medical-consult-page--desktop .faq-workspace {
.medical-consult-page--workbench .faq-workspace {
grid-template-columns: 1fr;
}
.medical-consult-page--desktop .faq-sidebar-col {
.medical-consult-page--workbench .faq-sidebar-col {
border-right: 0;
border-bottom: 1px solid #d9e2ec;
}
.medical-consult-page--desktop .faq-main {
.medical-consult-page--workbench .faq-main {
padding: 0;
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./OfficePreviewWorkspace.vue"), "utf8");
const hostSource = readFileSync(resolve(__dirname, "../components/OnlyOfficeViewer.vue"), "utf8");
describe("OfficePreviewWorkspace contract", () => {
it("uses a full-page viewer without dialogs, cards, downloads, or browser iframe fallback", () => {
expect(source).toContain("OnlyOfficeViewer");
expect(source).not.toContain("el-dialog");
expect(source).not.toContain("downloadDocumentVersion");
expect(source).not.toContain("downloadAttachment");
expect(source).not.toContain("<iframe");
});
it("destroys signed configuration on deactivation, server changes, and unmount", () => {
expect(source).toContain("onDeactivated");
expect(source).toContain("DESKTOP_SERVER_URL_CHANGED_EVENT");
expect(source.match(/destroyViewer\(\)/g)?.length).toBeGreaterThanOrEqual(3);
expect(hostSource).toContain('frameRef.value.src = "about:blank"');
});
it("closes a desktop transient preview task before falling back to browser history", () => {
expect(source).toContain("inject(workspaceTaskControllerKey, null)");
expect(source).toContain("workspaceTaskController?.closeTransientTask(route.path)");
expect(source).toContain('workspaceTaskController ? "关闭预览并返回" : "返回"');
expect(source).toContain("router.back()");
});
});
@@ -0,0 +1,258 @@
<template>
<section class="office-preview-page">
<header class="office-preview-page__header">
<button
class="office-preview-page__back"
type="button"
:aria-label="backActionLabel"
:title="backActionLabel"
@click="goBack"
></button>
<div class="office-preview-page__title" :title="displayFileName">{{ displayFileName }}</div>
<div class="office-preview-page__status" :class="`is-${status}`">{{ statusLabel }}</div>
<el-button v-if="errorMessage" link type="primary" :loading="loading" @click="loadConfig">重试</el-button>
</header>
<main class="office-preview-page__content">
<OnlyOfficeViewer
v-if="previewConfig && !errorMessage"
:key="viewerKey"
:config="previewConfig.config"
@ready="handleDocumentReady"
@warning="handleWarning"
@error="handleViewerError"
/>
<div v-else class="office-preview-page__state">
<el-icon v-if="loading" class="is-loading" :size="28"><Loading /></el-icon>
<template v-else-if="errorMessage">
<div class="office-preview-page__error-title">无法预览此文件</div>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadConfig">重新加载</el-button>
</template>
</div>
</main>
</section>
</template>
<script setup lang="ts">
import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Loading } from "@element-plus/icons-vue";
import OnlyOfficeViewer from "../components/OnlyOfficeViewer.vue";
import { fetchAttachmentOnlyOfficeConfig, fetchVersionOnlyOfficeConfig } from "../api/onlyoffice";
import { useStudyStore } from "../store/study";
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
import { getApiErrorMessage } from "../utils/apiErrorMessage";
import { workspaceTaskControllerKey } from "../components/layout/workspaceTaskController";
type PreviewStatus = "loading" | "ready" | "warning" | "error";
const route = useRoute();
const router = useRouter();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const study = useStudyStore();
const previewConfig = ref<OnlyOfficePreviewConfig | null>(null);
const loading = ref(false);
const errorMessage = ref("");
const warningMessage = ref("");
const status = ref<PreviewStatus>("loading");
const requestSequence = ref(0);
const viewerSequence = ref(0);
let mounted = false;
const resourceType = computed<OnlyOfficeResourceType>(() =>
route.name === "OfficeAttachmentPreview" ? "attachment" : "version",
);
const resourceId = computed(() => String(route.params.id || ""));
const displayFileName = computed(() => previewConfig.value?.file_name || String(route.meta.title || "Office 文件预览"));
const viewerKey = computed(() => `${resourceType.value}:${resourceId.value}:${viewerSequence.value}`);
const backActionLabel = computed(() => workspaceTaskController ? "关闭预览并返回" : "返回");
const statusLabel = computed(() => {
if (status.value === "ready") return "只读";
if (status.value === "warning") return warningMessage.value || "预览警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const errorForResponse = async (error: unknown) => {
const statusCode = Number((error as any)?.response?.status || 0);
const code = String((error as any)?.response?.data?.code || "");
if (code === "ONLYOFFICE_DISABLED") return "Office 在线预览尚未启用";
if (code === "ONLYOFFICE_UNAVAILABLE") return "Office 预览服务暂时不可用";
if (code === "ONLYOFFICE_FORMAT_UNSUPPORTED" || statusCode === 415) return "该文件格式不支持 Office 在线预览";
if (statusCode === 401) return "登录状态已失效,请重新登录";
if (statusCode === 403) return "您没有预览此文件的权限";
if (statusCode === 404) return "文件不存在或已被删除";
return getApiErrorMessage(error, "Office 预览配置加载失败");
};
const destroyViewer = () => {
requestSequence.value += 1;
previewConfig.value = null;
viewerSequence.value += 1;
};
const loadConfig = async () => {
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
previewConfig.value = null;
errorMessage.value = "";
warningMessage.value = "";
loading.value = true;
status.value = "loading";
try {
const response = resourceType.value === "attachment"
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
: await fetchVersionOnlyOfficeConfig(resourceId.value);
if (sequence !== requestSequence.value) return;
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) {
throw new Error("Office 预览宿主页配置不合法");
}
previewConfig.value = response.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
} catch (error) {
if (sequence !== requestSequence.value) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (sequence === requestSequence.value) loading.value = false;
}
};
const handleDocumentReady = () => {
status.value = "ready";
};
const viewerDetailMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
const code = detail.errorCode ?? detail.warningCode ?? detail.code;
return code === undefined || code === null ? "" : `错误代码:${String(code)}`;
};
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = viewerDetailMessage(detail) || "预览服务返回警告";
status.value = "warning";
};
const handleViewerError = (detail: Record<string, unknown>) => {
errorMessage.value = viewerDetailMessage(detail) || "文件转换失败,请稍后重试";
status.value = "error";
previewConfig.value = null;
};
const handleServerChange = () => {
destroyViewer();
loading.value = false;
status.value = "error";
errorMessage.value = "服务器地址已切换,请重新加载预览";
};
const goBack = () => {
if (workspaceTaskController?.closeTransientTask(route.path)) return;
router.back();
};
onMounted(() => {
mounted = true;
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
void loadConfig();
});
onActivated(() => {
if (mounted && !previewConfig.value && !loading.value && !errorMessage.value) void loadConfig();
});
onDeactivated(() => {
destroyViewer();
loading.value = false;
errorMessage.value = "";
});
onBeforeUnmount(() => {
destroyViewer();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
});
</script>
<style scoped>
.office-preview-page {
display: grid;
grid-template-rows: 44px minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #26384c;
background: #f3f5f8;
}
.office-preview-page__header {
z-index: 1;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 0 14px 0 8px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.office-preview-page__back {
width: 32px;
height: 32px;
padding: 0 0 3px;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
font: 30px/1 system-ui, sans-serif;
cursor: pointer;
}
.office-preview-page__back:hover { background: #edf2f7; }
.office-preview-page__title {
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.office-preview-page__status {
font-size: 12px;
color: #738398;
white-space: nowrap;
}
.office-preview-page__status.is-ready { color: #25845b; }
.office-preview-page__status.is-warning,
.office-preview-page__status.is-error { color: #b65c29; }
.office-preview-page__content {
min-height: 0;
overflow: hidden;
}
.office-preview-page__state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
height: 100%;
color: #718096;
text-align: center;
}
.office-preview-page__state p { margin: 0; }
.office-preview-page__error-title { color: #2f4054; font-size: 17px; font-weight: 600; }
</style>
@@ -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("<PdfViewer");
expect(source).toContain("detectFilePreviewKind");
expect(source).toContain("/office-preview/version/${version.id}");
expect(source).not.toContain("<iframe");
});
@@ -117,6 +118,11 @@ describe("DocumentDetail breadcrumbs", () => {
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();");
});
});
+17 -11
View File
@@ -350,8 +350,8 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { computed, defineAsyncComponent, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Edit, Upload, Share } from "@element-plus/icons-vue";
import {
@@ -383,8 +383,10 @@ import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } fro
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { getContentDispositionFilename } from "../../utils/contentDisposition";
import { prepareSaveFile } from "../../runtime";
import { detectFilePreviewKind, ONLYOFFICE_FILE_EXTENSIONS } from "../../utils/officePreview";
const route = useRoute();
const router = useRouter();
const PdfViewer = defineAsyncComponent(() => import("../../components/PdfViewer.vue"));
const auth = useAuthStore();
const study = useStudyStore();
@@ -531,7 +533,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
const triggerFileInput = async () => {
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
accept: [...ONLYOFFICE_FILE_EXTENSIONS, "pdf", "png", "jpg", "jpeg"],
title: "选择文档版本",
});
if (file) uploadFile.value = file;
@@ -669,6 +671,7 @@ const loadDetail = async () => {
try {
const { data } = await fetchDocumentDetail(documentId.value);
Object.assign(detail, data);
syncBreadcrumbContext();
if (!users.value.length) await loadUsers();
if (!members.value.length) await loadMembers();
if (!sites.value.length) await loadSites();
@@ -771,13 +774,7 @@ const getVersionFileName = (version: DocumentVersion) => {
};
const detectPreviewType = (version: DocumentVersion) => {
const mime = (version.mime_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
const name = getVersionFileName(version).toLowerCase();
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other";
return detectFilePreviewKind(getVersionFileName(version), version.mime_type);
};
const clearPreviewObjectUrl = () => {
@@ -789,7 +786,12 @@ const clearPreviewObjectUrl = () => {
const previewVersion = async (version: DocumentVersion) => {
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
clearPreviewObjectUrl();
previewType.value = detectPreviewType(version);
const detectedType = detectPreviewType(version);
if (detectedType === "office") {
await router.push(`/office-preview/version/${version.id}`);
return;
}
previewType.value = detectedType;
previewTitle.value = getVersionFileName(version) || `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
previewUrl.value = "";
previewError.value = "";
@@ -924,6 +926,10 @@ onMounted(async () => {
await loadDetail();
});
onActivated(() => {
if (detail.id) syncBreadcrumbContext();
});
onBeforeUnmount(() => {
desktopRefreshCleanup?.();
});
+6 -29
View File
@@ -1,16 +1,9 @@
<template>
<div class="page ctms-page-shell page--flush">
<div v-if="study.currentStudy" class="unified-shell ctms-table-card">
<section class="unified-action-bar bar--flush">
<section class="unified-action-bar milestone-action-bar">
<div class="card-header ctms-page-header-row">
<div>
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
<div class="card-subtitle">
{{ TEXT.modules.projectMilestones.sourceLabel }}{{ dataSourceLabel }}
<span class="dot-sep">·</span>
{{ TEXT.common.labels.updatedAt }}{{ updatedAtLabel }}
</div>
</div>
<div class="header-actions">
<el-tag size="small" effect="plain">
{{ TEXT.modules.projectMilestones.statTotal }}{{ rows.length }}
@@ -200,7 +193,6 @@ import { useStudyStore } from "../../store/study";
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
import { TEXT } from "../../locales";
import StateEmpty from "../../components/StateEmpty.vue";
import { displayDateTime } from "../../utils/display";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
@@ -237,8 +229,6 @@ const study = useStudyStore();
const { can } = usePermission();
const rows = ref<MilestoneRow[]>([]);
const loading = ref(false);
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
const updatedAtLabel = ref<string>(TEXT.common.fallback);
const editorVisible = ref(false);
const editingRowId = ref("");
const editorForm = reactive<MilestoneLocalEdit>({
@@ -309,15 +299,8 @@ const loadMilestones = async () => {
const { data } = (await listProjectMilestones(studyId)) as any;
const list = Array.isArray(data) ? data : data?.items || [];
rows.value = list.map(normalizeRow);
const updatedAt = list
.map((item: any) => String(item?.updated_at || ""))
.filter(Boolean)
.sort()
.pop();
updatedAtLabel.value = updatedAt ? displayDateTime(updatedAt) : TEXT.common.fallback;
} catch (error: any) {
rows.value = [];
updatedAtLabel.value = TEXT.common.fallback;
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
@@ -452,7 +435,6 @@ watch(
() => study.currentStudy?.id,
() => {
rows.value = [];
updatedAtLabel.value = TEXT.common.fallback;
loadMilestones();
}
);
@@ -503,22 +485,17 @@ watch(
flex-wrap: wrap;
}
.milestone-action-bar {
min-height: 56px;
box-sizing: border-box;
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--unified-title-color);
}
.card-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.dot-sep {
margin: 0 8px;
}
.header-actions {
display: flex;
align-items: center;
+24 -2
View File
@@ -53,7 +53,8 @@
</template>
刷新
</el-button>
<span v-if="isDesktop" class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<span v-if="!isDesktop" class="overview-live-clock">数据 {{ overviewClockText }}</span>
<span v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
@@ -146,7 +147,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
@@ -164,6 +165,8 @@ const isDesktop = isTauriRuntime();
const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
const overviewClockNow = ref(new Date());
let overviewClockTimer: number | undefined;
const centers = computed(() => overview.value?.centers || []);
const enrollmentCompletionRate = computed(() => {
@@ -183,6 +186,16 @@ const overviewUpdatedAtLabel = computed(() => {
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
});
const overviewClockText = computed(() => {
const date = overviewClockNow.value;
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 statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
@@ -369,9 +382,17 @@ const reset = () => {
};
onMounted(() => {
overviewClockNow.value = new Date();
overviewClockTimer = window.setInterval(() => {
overviewClockNow.value = new Date();
}, 1000);
loadOverview();
});
onBeforeUnmount(() => {
if (overviewClockTimer) window.clearInterval(overviewClockTimer);
});
watch(
() => study.currentStudy?.id,
() => {
@@ -417,6 +438,7 @@ watch(
border-radius: 8px;
}
.overview-live-clock,
.overview-updated-at {
color: var(--ctms-text-secondary);
font-size: 12px;
+44
View File
@@ -28,6 +28,26 @@ http {
server backend:8000 resolve;
}
upstream onlyoffice {
zone onlyoffice 64k;
server onlyoffice:80 resolve;
}
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
map $http_x_forwarded_proto $onlyoffice_scheme {
default $http_x_forwarded_proto;
"" $scheme;
}
map $http_x_forwarded_host $onlyoffice_host {
default $http_x_forwarded_host;
"" $http_host;
}
server {
listen 80;
server_name _;
@@ -47,6 +67,17 @@ http {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
location = /onlyoffice-host.html {
try_files /onlyoffice-host.html =404;
add_header Cache-Control "no-store" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; frame-src 'self' blob:; frame-ancestors 'self' tauri: http://tauri.localhost https://tauri.localhost http://localhost:* http://127.0.0.1:*; object-src 'none'; base-uri 'none'; form-action 'none'" always;
}
location = /onlyoffice-host.js {
try_files /onlyoffice-host.js =404;
add_header Cache-Control "no-store" always;
}
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
@@ -66,6 +97,19 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /onlyoffice/ {
proxy_pass http://onlyoffice/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $onlyoffice_host/onlyoffice;
proxy_set_header X-Forwarded-Proto $onlyoffice_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /health {
proxy_pass http://backend;
proxy_set_header Host $host;
+41
View File
@@ -31,11 +31,26 @@ http {
server frontend-dev:5173 resolve;
}
upstream onlyoffice {
zone onlyoffice 64k;
server onlyoffice:80 resolve;
}
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
map $http_x_forwarded_proto $onlyoffice_scheme {
default $http_x_forwarded_proto;
"" $scheme;
}
map $http_x_forwarded_host $onlyoffice_host {
default $http_x_forwarded_host;
"" $http_host;
}
server {
listen 80;
server_name _;
@@ -56,6 +71,19 @@ http {
return 404;
}
location = /onlyoffice-host.html {
proxy_pass http://frontend_dev;
proxy_set_header Host $host;
add_header Cache-Control "no-store" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; frame-src 'self' blob:; frame-ancestors 'self' tauri: http://tauri.localhost https://tauri.localhost http://localhost:* http://127.0.0.1:*; object-src 'none'; base-uri 'none'; form-action 'none'" always;
}
location = /onlyoffice-host.js {
proxy_pass http://frontend_dev;
proxy_set_header Host $host;
add_header Cache-Control "no-store" always;
}
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
@@ -64,6 +92,19 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /onlyoffice/ {
proxy_pass http://onlyoffice/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $onlyoffice_host/onlyoffice;
proxy_set_header X-Forwarded-Proto $onlyoffice_scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /health {
proxy_pass http://backend;
proxy_set_header Host $host;
+10
View File
@@ -0,0 +1,10 @@
ARG ONLYOFFICE_IMAGE=onlyoffice/documentserver:9.4.0.1
FROM ${ONLYOFFICE_IMAGE}
USER root
# Use redistributable Noto CJK fonts for Chinese document preview. Proprietary
# Microsoft fonts must be supplied separately by an authorized deployment.
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
+1 -1
View File
@@ -525,7 +525,7 @@ ctms_upsert_env_value() {
local key="$1" value="$2"
local temp_file
temp_file="$(mktemp "$CTMS_ROOT_DIR/.env.tmp.XXXXXX")"
if [[ -f "$CTMS_ENV_FILE" && -n "$(ctms_read_env_value "$key" || true)" ]]; then
if ctms_env_has_key "$key"; then
awk -v key="$key" -v value="$value" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 { print key "=" value; next }
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh
source "$SCRIPT_DIR/common.sh"
ROTATE_SECRET=0
if [[ "${1:-}" == "--rotate-secret" ]]; then
ROTATE_SECRET=1
shift
fi
if [[ $# -gt 0 ]]; then
ctms_fail "未知参数: $1(支持的参数仅有 --rotate-secret"
fi
generate_random_hex() {
local bytes="${1:-32}"
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex "$bytes"
return
fi
od -An -N "$bytes" -tx1 /dev/urandom | tr -d ' \n'
}
prepare_onlyoffice_environment() {
local secret current_login_secret instance_id generated=0
secret="$(ctms_read_env_value ONLYOFFICE_JWT_SECRET || true)"
current_login_secret="$(ctms_read_env_value JWT_SECRET_KEY || true)"
instance_id="$(ctms_read_env_value ONLYOFFICE_INSTANCE_ID || true)"
if [[ "$ROTATE_SECRET" -eq 1 || ${#secret} -lt 32 || "$secret" == "$current_login_secret" ]]; then
secret="$(generate_random_hex 32)"
ctms_upsert_env_value ONLYOFFICE_JWT_SECRET "$secret"
generated=1
fi
if [[ -z "$instance_id" ]]; then
instance_id="ctms-dev-$(generate_random_hex 8)"
ctms_upsert_env_value ONLYOFFICE_INSTANCE_ID "$instance_id"
fi
ctms_upsert_env_value ONLYOFFICE_ENABLED true
ctms_upsert_env_value ONLYOFFICE_INTERNAL_URL http://onlyoffice
ctms_upsert_env_value ONLYOFFICE_STORAGE_BASE_URL http://backend:8000
ctms_upsert_env_value ONLYOFFICE_CONFIG_TTL_SECONDS 300
chmod 600 "$CTMS_ENV_FILE"
if [[ "$generated" -eq 1 ]]; then
ctms_ok "已生成新的 ONLYOFFICE 开发密钥并写入 .env(内容不会回显)"
else
ctms_ok "已复用 .env 中现有的 ONLYOFFICE 开发密钥"
fi
ctms_ok "ONLYOFFICE 开发实例标识已就绪: $instance_id"
}
wait_for_url() {
local label="$1" url="$2" attempts="${3:-90}" attempt=1
while [[ "$attempt" -le "$attempts" ]]; do
if curl -fsS "$url" >/dev/null 2>&1; then
ctms_ok "$label 已就绪"
return 0
fi
sleep 2
attempt=$(( attempt + 1 ))
done
ctms_fail "$label 未在预期时间内就绪: $url"
}
main() {
cd "$CTMS_ROOT_DIR"
ctms_require_docker_compose
ctms_require_command curl "macOS 通常已预装;Linux 可执行 apt-get install curl" \
|| ctms_fail "缺少 curl,无法执行启动健康检查"
ctms_step "准备 ONLYOFFICE 开发环境"
prepare_onlyoffice_environment
local -a compose=(
docker compose
-f "$CTMS_ROOT_DIR/docker-compose.dev.yaml"
-p ctms_dev
--profile office
)
ctms_step "校验开发 Compose 配置"
"${compose[@]}" config --quiet
ctms_ok "Compose 配置有效"
ctms_step "构建并启动 Office 预览服务"
"${compose[@]}" up -d --build --force-recreate backend onlyoffice nginx
ctms_step "验证运行状态"
wait_for_url "CTMS 后端" "http://127.0.0.1:8888/readyz" 60
wait_for_url "ONLYOFFICE Document Server" "http://127.0.0.1:8888/onlyoffice/healthcheck" 120
"${compose[@]}" exec -T backend python -c '
from app.core.config import settings
from app.main import app
paths = {route.path for route in app.routes}
required = {
"/api/v1/onlyoffice/attachments/{attachment_id}/config",
"/api/v1/onlyoffice/versions/{version_id}/config",
}
if not settings.ONLYOFFICE_ENABLED:
raise SystemExit("ONLYOFFICE_ENABLED 未生效")
if len(settings.ONLYOFFICE_JWT_SECRET or "") < 32:
raise SystemExit("ONLYOFFICE_JWT_SECRET 未生效")
if not settings.ONLYOFFICE_INSTANCE_ID:
raise SystemExit("ONLYOFFICE_INSTANCE_ID 未生效")
missing = required - paths
if missing:
raise SystemExit(f"后端缺少 ONLYOFFICE 路由: {sorted(missing)}")
print("ONLYOFFICE backend configuration and routes are ready")
'
ctms_ok "ONLYOFFICE 开发预览环境启动完成"
}
main