功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验
新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。 打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。 补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
This commit is contained in:
@@ -50,6 +50,12 @@
|
|||||||
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `npm run version:check` 检查漂移。
|
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `npm run version:check` 检查漂移。
|
||||||
- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`。
|
- 桌面端与 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`
|
- 分支治理规范:`docs/branch-governance.md`
|
||||||
- 分支维护中文 SOP:`docs/guides/branch-maintenance-sop-zh.md`
|
- 分支维护中文 SOP:`docs/guides/branch-maintenance-sop-zh.md`
|
||||||
|
|||||||
@@ -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)},
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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()
|
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(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.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(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(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(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
|
||||||
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
|
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
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)
|
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_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650)
|
||||||
USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600)
|
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
|
@lru_cache
|
||||||
@@ -60,6 +67,30 @@ def get_settings() -> Settings:
|
|||||||
settings = get_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]:
|
def get_cors_allowed_origins() -> list[str]:
|
||||||
return [
|
return [
|
||||||
origin.strip()
|
origin.strip()
|
||||||
|
|||||||
+4
-1
@@ -11,7 +11,8 @@ from fastapi.responses import JSONResponse
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.api.v1.router import api_router
|
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.exceptions import register_exception_handlers
|
||||||
from app.core.login_crypto import validate_login_crypto_configuration
|
from app.core.login_crypto import validate_login_crypto_configuration
|
||||||
from app.crud.user import ensure_admin_exists
|
from app.crud.user import ensure_admin_exists
|
||||||
@@ -111,6 +112,7 @@ async def _ensure_legacy_primary_keys(conn) -> None:
|
|||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
validate_login_crypto_configuration()
|
validate_login_crypto_configuration()
|
||||||
|
validate_onlyoffice_configuration()
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="CTMS 后端 API",
|
title="CTMS 后端 API",
|
||||||
description="临床试验项目管理系统后端接口文档",
|
description="临床试验项目管理系统后端接口文档",
|
||||||
@@ -297,6 +299,7 @@ def create_app() -> FastAPI:
|
|||||||
return {"totals": totals, "by_endpoint": summary}
|
return {"totals": totals, "by_endpoint": summary}
|
||||||
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
app.include_router(onlyoffice_internal_router)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -14,6 +14,11 @@ services:
|
|||||||
file: docker-compose.yaml
|
file: docker-compose.yaml
|
||||||
service: backend-init
|
service: backend-init
|
||||||
|
|
||||||
|
onlyoffice:
|
||||||
|
extends:
|
||||||
|
file: docker-compose.yaml
|
||||||
|
service: onlyoffice
|
||||||
|
|
||||||
frontend-dev:
|
frontend-dev:
|
||||||
image: node:22.13-alpine
|
image: node:22.13-alpine
|
||||||
container_name: ctms_frontend_dev
|
container_name: ctms_frontend_dev
|
||||||
@@ -77,6 +82,9 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
frontend_node_modules_node22:
|
frontend_node_modules_node22:
|
||||||
|
onlyoffice_data:
|
||||||
|
onlyoffice_lib:
|
||||||
|
onlyoffice_logs:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
ctms_net:
|
ctms_net:
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ services:
|
|||||||
MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS: ${MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS:-2.5}
|
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_CACHE_SECONDS: ${MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS:-604800}
|
||||||
MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS: ${MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS:-10}
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -65,6 +71,40 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- ctms_net
|
- 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:
|
backend-init:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
@@ -110,3 +150,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
ctms_net:
|
ctms_net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
onlyoffice_data:
|
||||||
|
onlyoffice_lib:
|
||||||
|
onlyoffice_logs:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
状态: `active`
|
状态: `active`
|
||||||
适用范围: Web 与 macOS Desktop 统一客户端发布
|
适用范围: Web 与 macOS Desktop 统一客户端发布
|
||||||
最后更新: `2026-07-08`
|
最后更新: `2026-07-14`
|
||||||
|
|
||||||
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。当前允许按 `docs/desktop-local-cache-plan.md` 引入在线辅助本地缓存,但不引入离线登录、离线写入、本地业务权威数据、内嵌后端服务或离线同步。
|
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。当前允许按 `docs/desktop-local-cache-plan.md` 引入在线辅助本地缓存,但不引入离线登录、离线写入、本地业务权威数据、内嵌后端服务或离线同步。
|
||||||
|
|
||||||
@@ -44,7 +44,11 @@ npm run desktop:build:app
|
|||||||
- [ ] Tauri bundle 启用 `app`、`dmg` 和 updater artifacts。
|
- [ ] Tauri bundle 启用 `app`、`dmg` 和 updater artifacts。
|
||||||
- [ ] updater public key 已配置。
|
- [ ] updater public key 已配置。
|
||||||
- [ ] CSP 禁止 wildcard source、`unsafe-eval`、宽泛 HTTP API 访问和 `object-src`。
|
- [ ] 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 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。
|
||||||
|
- [ ] Tauri capability 不包含 `remote` 授权,远程 ONLYOFFICE frame 无文件、通知、对话框或 opener 权限。
|
||||||
- [ ] 文件系统仅开放临时文件所需的 read/write/mkdir/remove 命令,且文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。
|
- [ ] 文件系统仅开放临时文件所需的 read/write/mkdir/remove 命令,且文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。
|
||||||
- [ ] 单实例插件先于其他桌面插件注册。
|
- [ ] 单实例插件先于其他桌面插件注册。
|
||||||
- [ ] macOS 首个顶层 submenu 为应用菜单,包含关于、设置、服务、隐藏和退出;文件菜单保持独立。
|
- [ ] macOS 首个顶层 submenu 为应用菜单,包含关于、设置、服务、隐藏和退出;文件菜单保持独立。
|
||||||
@@ -90,6 +94,8 @@ npm run desktop:build:app
|
|||||||
| mutation 缓存失效 | 必测 | 必测 | 新增、编辑、删除或审批成功后相关列表、详情、统计和图表缓存失效 |
|
| mutation 缓存失效 | 必测 | 必测 | 新增、编辑、删除或审批成功后相关列表、详情、统计和图表缓存失效 |
|
||||||
| 附件上传 | 必测 | 必测 | Web 使用浏览器文件选择,Desktop 使用原生选择 |
|
| 附件上传 | 必测 | 必测 | Web 使用浏览器文件选择,Desktop 使用原生选择 |
|
||||||
| 附件下载/保存/打开 | 必测 | 必测 | 使用 Authorization header;无 `?token=` |
|
| 附件下载/保存/打开 | 必测 | 必测 | 使用 Authorization header;无 `?token=` |
|
||||||
|
| Office 附件/版本只读预览 | 必测 | 必测 | 独立全幅页面;不下载到本地;无编辑、复制、打印、下载入口;标签失活和服务器切换立即销毁 |
|
||||||
|
| ONLYOFFICE 服务不可用 | 必测 | 必测 | 显示可重试错误,不回退 iframe 或自动下载;原保存/打开功能不受影响 |
|
||||||
| 临时文件清理 | 不适用 | 必测 | 启动时清理 `$TEMP/ctms-desktop/**` |
|
| 临时文件清理 | 不适用 | 必测 | 启动时清理 `$TEMP/ctms-desktop/**` |
|
||||||
| 系统通知开启 | 不适用 | 必测 | 用户主动开启后请求 OS 权限并创建订阅 |
|
| 系统通知开启 | 不适用 | 必测 | 用户主动开启后请求 OS 权限并创建订阅 |
|
||||||
| 系统通知拒绝 | 不适用 | 必测 | 开关回退,提示系统权限未开启 |
|
| 系统通知拒绝 | 不适用 | 必测 | 开关回退,提示系统权限未开启 |
|
||||||
|
|||||||
@@ -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) 为准。
|
||||||
@@ -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>
|
||||||
@@ -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) =>
|
const permissionIdentifier = (permission) =>
|
||||||
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
|
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
|
||||||
const toPosixPath = (path) => path.split("\\").join("/");
|
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 assertPathScope = (permission, expectedPrefix, description) => {
|
||||||
const allow = Array.isArray(permission.allow) ? permission.allow : [];
|
const allow = Array.isArray(permission.allow) ? permission.allow : [];
|
||||||
@@ -70,6 +76,17 @@ const verifyTauriConfig = async () => {
|
|||||||
"Tauri CSP must not use wildcard sources.",
|
"Tauri CSP must not use wildcard sources.",
|
||||||
);
|
);
|
||||||
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
|
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?.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?.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.");
|
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) {
|
for (const file of files) {
|
||||||
const capability = await readJson(resolve(capabilitiesDir, file));
|
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 permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
|
||||||
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
|
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 verifyRustBoundary = async () => {
|
||||||
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
|
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
|
||||||
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
|
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
|
||||||
@@ -445,6 +491,7 @@ await verifySourceSafety();
|
|||||||
await verifyNotificationBoundary();
|
await verifyNotificationBoundary();
|
||||||
await verifySessionBoundary();
|
await verifySessionBoundary();
|
||||||
await verifyUpdaterBoundary();
|
await verifyUpdaterBoundary();
|
||||||
|
await verifyOnlyOfficeBoundary();
|
||||||
await verifyWorkflowGates();
|
await verifyWorkflowGates();
|
||||||
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|||||||
|
|
||||||
const main = readFileSync("src/styles/main.css", "utf8");
|
const main = readFileSync("src/styles/main.css", "utf8");
|
||||||
const unified = readFileSync("src/styles/unified-page.css", "utf8");
|
const unified = readFileSync("src/styles/unified-page.css", "utf8");
|
||||||
|
const webLayout = readFileSync("src/components/WebLayout.vue", "utf8");
|
||||||
|
|
||||||
const requiredMainTokens = [
|
const requiredMainTokens = [
|
||||||
"--ctms-primary",
|
"--ctms-primary",
|
||||||
@@ -31,6 +32,29 @@ const missing = [
|
|||||||
...requiredUnifiedTokens.filter((t) => !unified.includes(t))
|
...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 = [
|
const pageContractChecks = [
|
||||||
{
|
{
|
||||||
file: "src/views/ia/ProjectMilestones.vue",
|
file: "src/views/ia/ProjectMilestones.vue",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"bundle": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes)");
|
||||||
expect(component).toContain("sessionClockTimer !== undefined");
|
expect(component).toContain("sessionClockTimer !== undefined");
|
||||||
expect(component).toContain("window.clearInterval(sessionClockTimer)");
|
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("ip_address");
|
||||||
expect(component).not.toContain("access_token");
|
expect(component).not.toContain("access_token");
|
||||||
expect(webLayout).toContain('<AccountConnectionStatus mode="indicator" />');
|
expect(webLayout).toContain('<AccountConnectionStatus mode="indicator" />');
|
||||||
|
|||||||
@@ -83,7 +83,6 @@
|
|||||||
<dd>{{ lastCommunicationLabel }}</dd>
|
<dd>{{ lastCommunicationLabel }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<p class="connection-details-note">连接状态仅表示会话与后端通信,不代表系统健康评分。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -491,13 +490,6 @@ onBeforeUnmount(() => {
|
|||||||
white-space: nowrap;
|
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 {
|
:global([data-ctms-theme="dark"]) .account-connection-details {
|
||||||
color: #dbe5f1;
|
color: #dbe5f1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,7 +229,7 @@
|
|||||||
<kbd>⌘K</kbd>
|
<kbd>⌘K</kbd>
|
||||||
</button>
|
</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>
|
<el-icon><StarFilled v-if="currentRouteFavorited" /><Star v-else /></el-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -424,7 +424,7 @@
|
|||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="desktop-content">
|
<main class="desktop-content" :class="{ 'is-full-bleed': route.meta.fullBleed }">
|
||||||
<router-view v-slot="{ Component, route: currentRoute }">
|
<router-view v-slot="{ Component, route: currentRoute }">
|
||||||
<transition name="desktop-route" mode="out-in">
|
<transition name="desktop-route" mode="out-in">
|
||||||
<KeepAlive :max="DESKTOP_WORKSPACE_TAB_CACHE_MAX">
|
<KeepAlive :max="DESKTOP_WORKSPACE_TAB_CACHE_MAX">
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<button type="button" @click="navigateWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">切换到标签</button>
|
<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) ? "取消收藏模块" : "收藏模块" }}
|
{{ isWorkspaceTabFavorited(workspaceTabMenu.tab) ? "取消收藏模块" : "收藏模块" }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="closeWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">关闭标签</button>
|
<button type="button" @click="closeWorkspaceTabFromMenu(workspaceTabMenu.tab.path)">关闭标签</button>
|
||||||
@@ -495,7 +495,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -598,6 +598,7 @@ import {
|
|||||||
resolveWorkspaceTabContextTitle,
|
resolveWorkspaceTabContextTitle,
|
||||||
type WorkspaceTabContext,
|
type WorkspaceTabContext,
|
||||||
} from "./layout/workspaceTabTitle";
|
} from "./layout/workspaceTabTitle";
|
||||||
|
import { workspaceTaskControllerKey } from "./layout/workspaceTaskController";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -636,11 +637,13 @@ const desktopSidebarVisible = ref(readDesktopSidebarVisible());
|
|||||||
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
|
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
|
||||||
const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});
|
const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});
|
||||||
const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});
|
const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});
|
||||||
|
const workspaceTaskOrigins = ref<Record<string, string>>({});
|
||||||
const workspaceTabsOverflowOpen = ref(false);
|
const workspaceTabsOverflowOpen = ref(false);
|
||||||
const workspaceTabsSearchQuery = ref("");
|
const workspaceTabsSearchQuery = ref("");
|
||||||
const draggingWorkspaceTabPath = ref("");
|
const draggingWorkspaceTabPath = ref("");
|
||||||
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
|
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
|
||||||
const lastClosedWorkspaceTabHistory = ref<WorkspaceTabHistory | null>(null);
|
const lastClosedWorkspaceTabHistory = ref<WorkspaceTabHistory | null>(null);
|
||||||
|
const lastClosedWorkspaceTaskOrigin = ref<string | null>(null);
|
||||||
const workspaceTabMenu = ref<{
|
const workspaceTabMenu = ref<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -796,6 +799,13 @@ const currentWorkspaceContext = computed<WorkspaceTabContext | null>(() => {
|
|||||||
});
|
});
|
||||||
const currentDesktopRoutePreference = computed(() => {
|
const currentDesktopRoutePreference = computed(() => {
|
||||||
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activeMenu.value);
|
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;
|
if (!item) return null;
|
||||||
return {
|
return {
|
||||||
path: item.path,
|
path: item.path,
|
||||||
@@ -838,6 +848,7 @@ const currentRouteFavorited = computed(() => {
|
|||||||
const current = currentDesktopRoutePreference.value;
|
const current = currentDesktopRoutePreference.value;
|
||||||
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
|
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
|
||||||
});
|
});
|
||||||
|
const canFavoriteCurrentRoute = computed(() => !route.meta.transientWorkspaceTask && Boolean(currentDesktopRoutePreference.value));
|
||||||
const desktopToolbarTitle = computed(() => {
|
const desktopToolbarTitle = computed(() => {
|
||||||
const current = currentDesktopRoutePreference.value;
|
const current = currentDesktopRoutePreference.value;
|
||||||
if (!current) return String(route.meta?.title || TEXT.common.appName);
|
if (!current) return String(route.meta?.title || TEXT.common.appName);
|
||||||
@@ -947,6 +958,13 @@ const removeWorkspaceTabContext = (tabPath: string) => {
|
|||||||
workspaceTabContexts.value = next;
|
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) => {
|
const recordWorkspaceTabRoute = (tabPath: string, fullPath: string) => {
|
||||||
setWorkspaceTabHistory(tabPath, recordWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath));
|
setWorkspaceTabHistory(tabPath, recordWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath));
|
||||||
};
|
};
|
||||||
@@ -1009,6 +1027,9 @@ const navigateWorkspaceTabFromMenu = (path: string) => {
|
|||||||
const isWorkspaceTabFavorited = (tab: DesktopRoutePreference) =>
|
const isWorkspaceTabFavorited = (tab: DesktopRoutePreference) =>
|
||||||
desktopFavoriteRoutes.value.some((item) => item.path === tab.path);
|
desktopFavoriteRoutes.value.some((item) => item.path === tab.path);
|
||||||
|
|
||||||
|
const canFavoriteWorkspaceTab = (tab: DesktopRoutePreference) =>
|
||||||
|
desktopNavigationItems.value.some((item) => item.path === tab.path);
|
||||||
|
|
||||||
const toggleWorkspaceTabFavorite = (tab: DesktopRoutePreference) => {
|
const toggleWorkspaceTabFavorite = (tab: DesktopRoutePreference) => {
|
||||||
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute({ path: tab.path, title: tab.title, group: tab.group });
|
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute({ path: tab.path, title: tab.title, group: tab.group });
|
||||||
refreshDesktopRoutePreferences();
|
refreshDesktopRoutePreferences();
|
||||||
@@ -1041,15 +1062,19 @@ const dropWorkspaceTab = (targetPath: string, event: DragEvent) => {
|
|||||||
|
|
||||||
const closeWorkspaceTab = (path: string) => {
|
const closeWorkspaceTab = (path: string) => {
|
||||||
const index = workspaceTabs.value.findIndex((tab) => tab.path === path);
|
const index = workspaceTabs.value.findIndex((tab) => tab.path === path);
|
||||||
|
const originPath = workspaceTaskOrigins.value[path];
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
lastClosedWorkspaceTab.value = workspaceTabs.value[index];
|
lastClosedWorkspaceTab.value = workspaceTabs.value[index];
|
||||||
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[path] || createWorkspaceTabHistory(path);
|
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[path] || createWorkspaceTabHistory(path);
|
||||||
|
lastClosedWorkspaceTaskOrigin.value = originPath || null;
|
||||||
}
|
}
|
||||||
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
|
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
|
||||||
removeWorkspaceTabHistory(path);
|
removeWorkspaceTabHistory(path);
|
||||||
removeWorkspaceTabContext(path);
|
removeWorkspaceTabContext(path);
|
||||||
|
removeWorkspaceTaskOrigin(path);
|
||||||
if (activeMenu.value !== path) return;
|
if (activeMenu.value !== path) return;
|
||||||
const next =
|
const next =
|
||||||
|
workspaceTabs.value.find((tab) => tab.path === originPath) ||
|
||||||
workspaceTabs.value[index] ||
|
workspaceTabs.value[index] ||
|
||||||
workspaceTabs.value[index - 1] ||
|
workspaceTabs.value[index - 1] ||
|
||||||
desktopFavoriteRoutes.value.find((item) => item.path !== path);
|
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) => {
|
const closeWorkspaceTabFromMenu = (path: string) => {
|
||||||
closeWorkspaceTab(path);
|
closeWorkspaceTab(path);
|
||||||
closeWorkspaceTabMenu();
|
closeWorkspaceTabMenu();
|
||||||
@@ -1073,6 +1107,8 @@ const closeOtherWorkspaceTabs = (path: string) => {
|
|||||||
workspaceTabHistories.value = targetHistory ? { [path]: targetHistory } : {};
|
workspaceTabHistories.value = targetHistory ? { [path]: targetHistory } : {};
|
||||||
const targetContext = workspaceTabContexts.value[path];
|
const targetContext = workspaceTabContexts.value[path];
|
||||||
workspaceTabContexts.value = targetContext ? { [path]: targetContext } : {};
|
workspaceTabContexts.value = targetContext ? { [path]: targetContext } : {};
|
||||||
|
const targetOrigin = workspaceTaskOrigins.value[path];
|
||||||
|
workspaceTaskOrigins.value = targetOrigin ? { [path]: targetOrigin } : {};
|
||||||
if (activeMenu.value !== path) void router.push(workspaceTabDestination(path));
|
if (activeMenu.value !== path) void router.push(workspaceTabDestination(path));
|
||||||
closeWorkspaceTabMenu();
|
closeWorkspaceTabMenu();
|
||||||
};
|
};
|
||||||
@@ -1082,10 +1118,12 @@ const closeAllWorkspaceTabs = () => {
|
|||||||
if (activeTab) {
|
if (activeTab) {
|
||||||
lastClosedWorkspaceTab.value = activeTab;
|
lastClosedWorkspaceTab.value = activeTab;
|
||||||
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[activeTab.path] || createWorkspaceTabHistory(activeTab.path);
|
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[activeTab.path] || createWorkspaceTabHistory(activeTab.path);
|
||||||
|
lastClosedWorkspaceTaskOrigin.value = workspaceTaskOrigins.value[activeTab.path] || null;
|
||||||
}
|
}
|
||||||
workspaceTabs.value = [];
|
workspaceTabs.value = [];
|
||||||
workspaceTabHistories.value = {};
|
workspaceTabHistories.value = {};
|
||||||
workspaceTabContexts.value = {};
|
workspaceTabContexts.value = {};
|
||||||
|
workspaceTaskOrigins.value = {};
|
||||||
workspaceTabsSearchQuery.value = "";
|
workspaceTabsSearchQuery.value = "";
|
||||||
closeWorkspaceTabMenu();
|
closeWorkspaceTabMenu();
|
||||||
|
|
||||||
@@ -1108,9 +1146,16 @@ const restoreLastClosedWorkspaceTab = () => {
|
|||||||
if (lastClosedWorkspaceTabHistory.value) {
|
if (lastClosedWorkspaceTabHistory.value) {
|
||||||
setWorkspaceTabHistory(tab.path, lastClosedWorkspaceTabHistory.value);
|
setWorkspaceTabHistory(tab.path, lastClosedWorkspaceTabHistory.value);
|
||||||
}
|
}
|
||||||
|
if (lastClosedWorkspaceTaskOrigin.value) {
|
||||||
|
workspaceTaskOrigins.value = {
|
||||||
|
...workspaceTaskOrigins.value,
|
||||||
|
[tab.path]: lastClosedWorkspaceTaskOrigin.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
void router.push(workspaceTabDestination(tab.path));
|
void router.push(workspaceTabDestination(tab.path));
|
||||||
lastClosedWorkspaceTab.value = null;
|
lastClosedWorkspaceTab.value = null;
|
||||||
lastClosedWorkspaceTabHistory.value = null;
|
lastClosedWorkspaceTabHistory.value = null;
|
||||||
|
lastClosedWorkspaceTaskOrigin.value = null;
|
||||||
closeWorkspaceTabMenu();
|
closeWorkspaceTabMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1140,8 +1185,10 @@ const openDesktopProjectEntry = async () => {
|
|||||||
workspaceTabs.value = [];
|
workspaceTabs.value = [];
|
||||||
workspaceTabHistories.value = {};
|
workspaceTabHistories.value = {};
|
||||||
workspaceTabContexts.value = {};
|
workspaceTabContexts.value = {};
|
||||||
|
workspaceTaskOrigins.value = {};
|
||||||
lastClosedWorkspaceTab.value = null;
|
lastClosedWorkspaceTab.value = null;
|
||||||
lastClosedWorkspaceTabHistory.value = null;
|
lastClosedWorkspaceTabHistory.value = null;
|
||||||
|
lastClosedWorkspaceTaskOrigin.value = null;
|
||||||
await router.push("/desktop/project-entry");
|
await router.push("/desktop/project-entry");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1266,7 +1313,7 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
|
|||||||
id: "desktop:favorite-current",
|
id: "desktop:favorite-current",
|
||||||
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
|
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
|
||||||
group: "桌面",
|
group: "桌面",
|
||||||
visible: Boolean(currentDesktopRoutePreference.value),
|
visible: canFavoriteCurrentRoute.value,
|
||||||
run: toggleCurrentFavorite,
|
run: toggleCurrentFavorite,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1393,8 +1440,14 @@ watch(
|
|||||||
}
|
}
|
||||||
const current = currentDesktopRoutePreference.value;
|
const current = currentDesktopRoutePreference.value;
|
||||||
if (current) {
|
if (current) {
|
||||||
addWorkspaceTab(current);
|
|
||||||
const previousTabPath = previousRoute ? getActiveLayoutPath(previousRoute.path) : "";
|
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 =
|
const replacesCurrentTabEntry =
|
||||||
previousTabPath === current.path && Boolean((window.history.state as { replaced?: boolean } | null)?.replaced);
|
previousTabPath === current.path && Boolean((window.history.state as { replaced?: boolean } | null)?.replaced);
|
||||||
if (replacesCurrentTabEntry) {
|
if (replacesCurrentTabEntry) {
|
||||||
@@ -2680,6 +2733,10 @@ useDesktopShortcuts(
|
|||||||
background: #eef2f6;
|
background: #eef2f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-content.is-full-bleed {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-route-shell {
|
.desktop-route-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="faq-list-card" :class="{ 'faq-list-card--desktop': isDesktop }">
|
<div class="faq-list-card faq-list-card--workbench">
|
||||||
<el-table
|
<el-table
|
||||||
:data="items"
|
:data="items"
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
class="faq-table"
|
class="faq-table"
|
||||||
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
|
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
|
||||||
:row-class-name="rowClassName"
|
:row-class-name="rowClassName"
|
||||||
:height="isDesktop ? '100%' : undefined"
|
height="100%"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
table-layout="fixed"
|
table-layout="fixed"
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,6 @@ import { deleteFaqItem } from "../api/faqs";
|
|||||||
import type { FaqItem, FaqCategory } from "../api/faqs";
|
import type { FaqItem, FaqCategory } from "../api/faqs";
|
||||||
import { displayDateTime, displayEnum } from "../utils/display";
|
import { displayDateTime, displayEnum } from "../utils/display";
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
import { isTauriRuntime } from "../runtime";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: FaqItem[];
|
items: FaqItem[];
|
||||||
@@ -73,7 +72,6 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits(["refresh"]);
|
const emit = defineEmits(["refresh"]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isDesktop = isTauriRuntime();
|
|
||||||
|
|
||||||
const categoryMap = computed(() =>
|
const categoryMap = computed(() =>
|
||||||
props.categories.reduce<Record<string, string>>((acc, cur) => {
|
props.categories.reduce<Record<string, string>>((acc, cur) => {
|
||||||
@@ -271,7 +269,7 @@ const remove = async (row: FaqItem) => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-list-card--desktop {
|
.faq-list-card--workbench {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -279,17 +277,17 @@ const remove = async (row: FaqItem) => {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-list-card--desktop .faq-table {
|
.faq-list-card--workbench .faq-table {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
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%;
|
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;
|
height: 42px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
border-bottom: 1px solid rgba(121, 152, 188, 0.28) !important;
|
border-bottom: 1px solid rgba(121, 152, 188, 0.28) !important;
|
||||||
@@ -299,37 +297,37 @@ const remove = async (row: FaqItem) => {
|
|||||||
font-weight: 850;
|
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-collapse: separate;
|
||||||
border-spacing: 0 6px;
|
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%);
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-list-card--desktop .faq-table :deep(.el-table__body-wrapper::-webkit-scrollbar),
|
.faq-list-card--workbench .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-scrollbar__wrap::-webkit-scrollbar) {
|
||||||
display: none;
|
display: none;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 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;
|
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;
|
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%;
|
min-height: 100%;
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 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;
|
padding: 10px 14px;
|
||||||
border-top: 1px solid rgba(226, 235, 246, 0.92);
|
border-top: 1px solid rgba(226, 235, 246, 0.92);
|
||||||
border-bottom: 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;
|
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-left: 1px solid rgba(226, 235, 246, 0.92);
|
||||||
border-radius: 9px 0 0 9px;
|
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-right: 1px solid rgba(226, 235, 246, 0.92);
|
||||||
border-radius: 0 9px 9px 0;
|
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);
|
border-color: rgba(111, 159, 220, 0.34);
|
||||||
background: #f5faff;
|
background: #f5faff;
|
||||||
box-shadow: 0 8px 18px rgba(45, 86, 143, 0.06);
|
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;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-list-card--desktop .question-icon {
|
.faq-list-card--workbench .question-icon {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -367,13 +365,13 @@ const remove = async (row: FaqItem) => {
|
|||||||
0 1px 0 rgba(255, 255, 255, 0.45) inset;
|
0 1px 0 rgba(255, 255, 255, 0.45) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-list-card--desktop .question-link {
|
.faq-list-card--workbench .question-link {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-list-card--desktop .category-pill {
|
.faq-list-card--workbench .category-pill {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -381,17 +379,17 @@ const remove = async (row: FaqItem) => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-list-card--desktop .time-text {
|
.faq-list-card--workbench .time-text {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
font-size: 11px;
|
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;
|
border-color: #26364a;
|
||||||
background: #172033;
|
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;
|
background: #1d2b42;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const readMainStyleSource = () => readSource(resolve(__dirname, "../styles/main.
|
|||||||
const readFaqSource = () => readSource(resolve(__dirname, "../views/Faq.vue"));
|
const readFaqSource = () => readSource(resolve(__dirname, "../views/Faq.vue"));
|
||||||
const readFaqListSource = () => readSource(resolve(__dirname, "./FaqList.vue"));
|
const readFaqListSource = () => readSource(resolve(__dirname, "./FaqList.vue"));
|
||||||
const readProjectOverviewSource = () => readSource(resolve(__dirname, "../views/ia/ProjectOverview.vue"));
|
const readProjectOverviewSource = () => readSource(resolve(__dirname, "../views/ia/ProjectOverview.vue"));
|
||||||
|
const readProjectMilestonesSource = () => readSource(resolve(__dirname, "../views/ia/ProjectMilestones.vue"));
|
||||||
|
|
||||||
describe("desktop layout shell", () => {
|
describe("desktop layout shell", () => {
|
||||||
it("routes desktop and web shells through the runtime flag", () => {
|
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) => {");
|
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", () => {
|
it("allows desktop shortcuts to be customized from system preferences", () => {
|
||||||
const preferences = readDesktopPreferencesSource();
|
const preferences = readDesktopPreferencesSource();
|
||||||
const tauriLib = readTauriLibSource();
|
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');
|
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 faq = readFaqSource();
|
||||||
const faqList = readFaqListSource();
|
const faqList = readFaqListSource();
|
||||||
const projectOverview = readProjectOverviewSource();
|
const projectOverview = readProjectOverviewSource();
|
||||||
|
const projectMilestones = readProjectMilestonesSource();
|
||||||
|
const webLayout = readWebLayoutSource();
|
||||||
|
|
||||||
expect(faq).toContain("const isDesktop = isTauriRuntime();");
|
expect(faq).toContain("medical-consult-page--workbench");
|
||||||
expect(faq).toContain(":class=\"{ 'medical-consult-page--desktop': isDesktop }\"");
|
expect(faq).toContain('class="workbench-toolbar-meta"');
|
||||||
expect(faq).toContain('v-if="!isDesktop" class="page-bg-dots"');
|
expect(faq).toContain('<PermissionAction action="faq.create">');
|
||||||
expect(faq).toContain('<h1 v-if="!isDesktop"');
|
expect(faq).not.toContain("const isDesktop = isTauriRuntime();");
|
||||||
expect(faq).toContain('v-else class="desktop-toolbar-meta"');
|
expect(faq).not.toContain("page-bg-dots");
|
||||||
expect(faq).toContain('v-if="isDesktop" action="faq.create"');
|
expect(faq).not.toContain("list-toolbar");
|
||||||
expect(faq).toContain('<div v-if="!isDesktop" class="list-toolbar">');
|
expect(faq).toContain(".medical-consult-page--workbench .faq-workspace");
|
||||||
expect(faq).toContain(".medical-consult-page--desktop .faq-workspace");
|
|
||||||
expect(faq).toContain("grid-template-columns: 236px minmax(0, 1fr);");
|
expect(faq).toContain("grid-template-columns: 236px minmax(0, 1fr);");
|
||||||
expect(faqList).toContain(":class=\"{ 'faq-list-card--desktop': isDesktop }\"");
|
expect(faqList).toContain('class="faq-list-card faq-list-card--workbench"');
|
||||||
expect(faqList).toContain("const isDesktop = isTauriRuntime();");
|
expect(faqList).toContain('height="100%"');
|
||||||
expect(faqList).toContain(".faq-list-card--desktop .faq-table");
|
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).toContain(":class=\"{ 'project-overview--desktop': isDesktop }\"");
|
||||||
expect(projectOverview).not.toContain("overview-summary-strip");
|
expect(projectOverview).not.toContain("overview-summary-strip");
|
||||||
expect(projectOverview).not.toContain("const activeCenterCount");
|
expect(projectOverview).not.toContain("const activeCenterCount");
|
||||||
expect(projectOverview).toContain("desktop-attention-section");
|
expect(projectOverview).toContain("desktop-attention-section");
|
||||||
expect(projectOverview).not.toContain("项目关注");
|
expect(projectOverview).not.toContain("项目关注");
|
||||||
expect(projectOverview).not.toContain("desktop-attention-head");
|
expect(projectOverview).not.toContain("desktop-attention-head");
|
||||||
expect(projectOverview).toContain('class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
|
expect(projectOverview).toContain('class="overview-live-clock">数据 {{ overviewClockText }}</span>');
|
||||||
expect(projectOverview.indexOf("刷新")).toBeLessThan(projectOverview.indexOf('class="overview-updated-at"'));
|
expect(projectOverview).toContain('v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
|
||||||
expect(projectOverview.indexOf('class="overview-updated-at"')).toBeLessThan(projectOverview.indexOf('class="progress-legend"'));
|
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("overview-workbench");
|
||||||
expect(projectOverview).toContain("enrollment-snapshot");
|
expect(projectOverview).toContain("enrollment-snapshot");
|
||||||
expect(projectOverview).toContain("desktop-attention-board");
|
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>
|
||||||
@@ -5,9 +5,10 @@
|
|||||||
@keydown.esc="closeMobileNav"
|
@keydown.esc="closeMobileNav"
|
||||||
>
|
>
|
||||||
<el-aside
|
<el-aside
|
||||||
:width="isMobileViewport ? '280px' : (isCollapsed ? '68px' : '200px')"
|
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
|
||||||
class="layout-aside"
|
class="layout-aside"
|
||||||
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
|
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
|
||||||
|
:inert="!isMobileViewport && isCollapsed"
|
||||||
>
|
>
|
||||||
<div class="aside-logo">
|
<div class="aside-logo">
|
||||||
<img class="logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
<img class="logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||||
@@ -141,7 +142,7 @@
|
|||||||
class="collapse-btn"
|
class="collapse-btn"
|
||||||
:aria-expanded="isMobileViewport ? isMobileNavOpen : undefined"
|
:aria-expanded="isMobileViewport ? isMobileNavOpen : undefined"
|
||||||
@click="toggleCollapse"
|
@click="toggleCollapse"
|
||||||
:aria-label="isMobileViewport ? '打开导航菜单' : TEXT.common.labels.collapseSidebar"
|
:aria-label="isMobileViewport ? '打开导航菜单' : (isCollapsed ? '显示导航栏' : TEXT.common.labels.collapseSidebar)"
|
||||||
>
|
>
|
||||||
<el-icon class="collapse-icon">
|
<el-icon class="collapse-icon">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -187,35 +188,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="header-right">
|
||||||
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
|
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
|
||||||
<el-icon><Suitcase /></el-icon>
|
<el-icon><Suitcase /></el-icon>
|
||||||
@@ -357,8 +329,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
|
|
||||||
<el-main class="layout-main">
|
<el-main class="layout-main" :class="{ 'is-full-bleed': route.meta.fullBleed }">
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper" :class="{ 'is-full-bleed': route.meta.fullBleed }">
|
||||||
<router-view v-slot="{ Component, route: currentRoute }">
|
<router-view v-slot="{ Component, route: currentRoute }">
|
||||||
<transition name="fade-transform" mode="out-in">
|
<transition name="fade-transform" mode="out-in">
|
||||||
<div :key="currentRoute.fullPath" class="ctms-route-shell">
|
<div :key="currentRoute.fullPath" class="ctms-route-shell">
|
||||||
@@ -424,13 +396,12 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { fetchStudies } from "../api/studies";
|
import { fetchStudies } from "../api/studies";
|
||||||
import { fetchProjectOverview } from "../api/overview";
|
|
||||||
import { fetchOverdueAesCount } from "../api/dashboard";
|
import { fetchOverdueAesCount } from "../api/dashboard";
|
||||||
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
|
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
import {
|
import {
|
||||||
User, Suitcase, House, Calendar, Flag,
|
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
|
Search, Star, StarFilled, Clock, Monitor
|
||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
import { ElMessageBox } from "element-plus";
|
import { ElMessageBox } from "element-plus";
|
||||||
@@ -490,19 +461,11 @@ const commandPaletteVisible = ref(false);
|
|||||||
const desktopPreferencesVisible = ref(false);
|
const desktopPreferencesVisible = ref(false);
|
||||||
const desktopServerUrl = ref(getDesktopServerUrl());
|
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||||
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
|
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
|
||||||
const headerOverviewStats = ref<{
|
|
||||||
centerActual: number;
|
|
||||||
enrollmentActual: number;
|
|
||||||
enrollmentTarget: number;
|
|
||||||
updatedAt: string;
|
|
||||||
} | null>(null);
|
|
||||||
const headerRemindersLoading = ref(false);
|
const headerRemindersLoading = ref(false);
|
||||||
const headerReminderStats = ref({
|
const headerReminderStats = ref({
|
||||||
overdueAes: 0,
|
overdueAes: 0,
|
||||||
overdueMonitoringIssues: 0,
|
overdueMonitoringIssues: 0,
|
||||||
});
|
});
|
||||||
const headerClockNow = ref(new Date());
|
|
||||||
let headerClockTimer: number | undefined;
|
|
||||||
let desktopMenuUnlisten: (() => void) | undefined;
|
let desktopMenuUnlisten: (() => void) | undefined;
|
||||||
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
|
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
|
||||||
const isAdminContext = computed(() => route.path.startsWith("/admin"));
|
const isAdminContext = computed(() => route.path.startsWith("/admin"));
|
||||||
@@ -511,7 +474,7 @@ const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdmin
|
|||||||
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||||||
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
||||||
watchEffect(() => {
|
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);
|
document.body.style.setProperty('--ctms-sidebar-width', width);
|
||||||
// 在 body 上添加/移除标记类,以便全局 CSS :has() 降级使用
|
// 在 body 上添加/移除标记类,以便全局 CSS :has() 降级使用
|
||||||
if (hasSidebar.value) {
|
if (hasSidebar.value) {
|
||||||
@@ -538,45 +501,6 @@ const toHeaderNumber = (value: unknown) => {
|
|||||||
return Number.isFinite(num) && num > 0 ? num : 0;
|
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 loadHeaderReminders = async () => {
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
|
if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
|
||||||
@@ -638,45 +562,6 @@ const headerReminderBadgeValue = computed(() =>
|
|||||||
headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value
|
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(() => {
|
const accountProjectRoleLabel = computed(() => {
|
||||||
if (!hasProjectContext.value || !study.currentStudy) return "";
|
if (!hasProjectContext.value || !study.currentStudy) return "";
|
||||||
const roleCode = (projectRole.value || "").toUpperCase();
|
const roleCode = (projectRole.value || "").toUpperCase();
|
||||||
@@ -1088,7 +973,6 @@ watch(() => route.path, () => {
|
|||||||
watch(
|
watch(
|
||||||
() => [study.currentStudy?.id, route.path] as const,
|
() => [study.currentStudy?.id, route.path] as const,
|
||||||
() => {
|
() => {
|
||||||
loadHeaderOverviewStats();
|
|
||||||
loadHeaderReminders();
|
loadHeaderReminders();
|
||||||
refreshDesktopRoutePreferences();
|
refreshDesktopRoutePreferences();
|
||||||
},
|
},
|
||||||
@@ -1158,10 +1042,6 @@ onMounted(async () => {
|
|||||||
mobileViewportChangeHandler = (event) => syncMobileViewport(event.matches);
|
mobileViewportChangeHandler = (event) => syncMobileViewport(event.matches);
|
||||||
mobileViewportMediaQuery.addEventListener("change", mobileViewportChangeHandler);
|
mobileViewportMediaQuery.addEventListener("change", mobileViewportChangeHandler);
|
||||||
}
|
}
|
||||||
headerClockNow.value = new Date();
|
|
||||||
headerClockTimer = window.setInterval(() => {
|
|
||||||
headerClockNow.value = new Date();
|
|
||||||
}, 1000);
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||||
clearLegacyDesktopRouteHistoryPreference();
|
clearLegacyDesktopRouteHistoryPreference();
|
||||||
@@ -1178,14 +1058,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadStudies();
|
loadStudies();
|
||||||
loadHeaderOverviewStats();
|
|
||||||
loadHeaderReminders();
|
loadHeaderReminders();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (headerClockTimer) {
|
|
||||||
window.clearInterval(headerClockTimer);
|
|
||||||
}
|
|
||||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||||
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
|
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
|
||||||
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
|
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
|
||||||
@@ -1273,6 +1149,10 @@ useDesktopShortcuts(
|
|||||||
border-right: 1px solid rgba(15, 23, 42, 0.2);
|
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 {
|
.web-layout-container .aside-logo {
|
||||||
height: 60px;
|
height: 60px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
@@ -1298,9 +1178,9 @@ useDesktopShortcuts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.web-layout-container .content-wrapper {
|
.web-layout-container .content-wrapper {
|
||||||
min-height: calc(100vh - 52px);
|
height: 100%;
|
||||||
min-height: calc(100dvh - 52px);
|
min-height: 0;
|
||||||
padding: 12px 14px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-aside {
|
.layout-aside {
|
||||||
@@ -1549,6 +1429,8 @@ useDesktopShortcuts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
.main-container {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
background: #f7f8fb;
|
background: #f7f8fb;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -1826,296 +1708,6 @@ useDesktopShortcuts(
|
|||||||
flex-shrink: 0;
|
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 {
|
.header-role {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -2308,26 +1900,6 @@ useDesktopShortcuts(
|
|||||||
font-weight: 600;
|
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 {
|
.collapse-btn {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
color: var(--ctms-text-secondary);
|
color: var(--ctms-text-secondary);
|
||||||
@@ -2411,6 +1983,7 @@ useDesktopShortcuts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layout-main {
|
.layout-main {
|
||||||
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #f7f8fb;
|
background: #f7f8fb;
|
||||||
@@ -2424,9 +1997,26 @@ useDesktopShortcuts(
|
|||||||
background: #f7f8fb;
|
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 {
|
.ctms-route-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2494,6 +2084,7 @@ useDesktopShortcuts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.web-layout-container .content-wrapper {
|
.web-layout-container .content-wrapper {
|
||||||
|
height: auto;
|
||||||
min-height: calc(100dvh - 52px);
|
min-height: calc(100dvh - 52px);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
@@ -2592,8 +2183,7 @@ useDesktopShortcuts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.web-layout-container .content-wrapper {
|
.web-layout-container .content-wrapper {
|
||||||
min-height: calc(100dvh - 56px);
|
padding: 0;
|
||||||
padding: 18px 24px 24px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2603,9 +2193,6 @@ useDesktopShortcuts(
|
|||||||
padding-left: 28px;
|
padding-left: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.web-layout-container .content-wrapper {
|
|
||||||
padding: 22px 32px 32px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -2631,20 +2218,6 @@ useDesktopShortcuts(
|
|||||||
transform: none;
|
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 {
|
.breadcrumb-dropdown-menu {
|
||||||
min-width: 216px;
|
min-width: 216px;
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from "vue";
|
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Document, UploadFilled } from "@element-plus/icons-vue";
|
import { Document, UploadFilled } from "@element-plus/icons-vue";
|
||||||
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
|
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
|
||||||
@@ -177,6 +178,7 @@ import { TEXT } from "../../locales";
|
|||||||
import { clientRuntime } from "../../runtime";
|
import { clientRuntime } from "../../runtime";
|
||||||
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
||||||
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../../session/desktopActivityCenter";
|
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../../session/desktopActivityCenter";
|
||||||
|
import { detectFilePreviewKind } from "../../utils/officePreview";
|
||||||
|
|
||||||
type AttachmentEntityGroup = {
|
type AttachmentEntityGroup = {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@@ -223,6 +225,7 @@ const progressMap = reactive<Record<string, number>>({});
|
|||||||
const pendingMap = reactive<Record<string, PendingUploadItem[]>>({});
|
const pendingMap = reactive<Record<string, PendingUploadItem[]>>({});
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const router = useRouter();
|
||||||
const previewVisible = ref(false);
|
const previewVisible = ref(false);
|
||||||
const previewUrl = ref("");
|
const previewUrl = ref("");
|
||||||
const previewObjectUrl = 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 detectPreviewType = (row: any) => {
|
||||||
const mime = getMimeType(row);
|
return detectFilePreviewKind(row?.filename, row?.mime_type || row?.content_type);
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const previewImage = async (row: any) => {
|
const previewImage = async (row: any) => {
|
||||||
@@ -547,7 +541,12 @@ const previewImage = async (row: any) => {
|
|||||||
|
|
||||||
const preview = async (row: any) => {
|
const preview = async (row: any) => {
|
||||||
previewError.value = "";
|
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;
|
previewTitle.value = row?.filename || TEXT.common.labels.preview;
|
||||||
if (previewType.value === "image" || previewType.value === "pdf") {
|
if (previewType.value === "image" || previewType.value === "pdf") {
|
||||||
previewUrl.value = "";
|
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");
|
||||||
@@ -5,6 +5,18 @@ import { resolve } from "node:path";
|
|||||||
const readRouter = () => readFileSync(resolve(__dirname, "./router/index.ts"), "utf8").replace(/\r\n/g, "\n");
|
const readRouter = () => readFileSync(resolve(__dirname, "./router/index.ts"), "utf8").replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
describe("admin project route permissions", () => {
|
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", () => {
|
it("lets any authorized project role open the project management detail page", () => {
|
||||||
const source = readRouter();
|
const source = readRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import MaterialEquipment from "../views/ia/MaterialEquipment.vue";
|
|||||||
import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue";
|
import MaterialEquipmentDetail from "../views/materials/MaterialEquipmentDetail.vue";
|
||||||
import DocumentList from "../views/documents/DocumentList.vue";
|
import DocumentList from "../views/documents/DocumentList.vue";
|
||||||
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
import DocumentDetail from "../views/documents/DocumentDetail.vue";
|
||||||
|
import OfficePreviewWorkspace from "../views/OfficePreviewWorkspace.vue";
|
||||||
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
|
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
|
||||||
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
|
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
|
||||||
import SubjectManagement from "../views/ia/SubjectManagement.vue";
|
import SubjectManagement from "../views/ia/SubjectManagement.vue";
|
||||||
@@ -193,6 +194,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: DocumentDetail,
|
component: DocumentDetail,
|
||||||
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
|
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",
|
path: "startup/feasibility-ethics",
|
||||||
name: "StartupFeasibilityEthics",
|
name: "StartupFeasibilityEthics",
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export {
|
|||||||
type NotificationPermissionState,
|
type NotificationPermissionState,
|
||||||
} from "./notifications";
|
} from "./notifications";
|
||||||
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
||||||
|
export { ONLYOFFICE_HOST_PATH, resolveOnlyOfficeHostUrl } from "./onlyoffice";
|
||||||
export {
|
export {
|
||||||
clearSessionToken,
|
clearSessionToken,
|
||||||
getSessionToken,
|
getSessionToken,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -994,6 +994,64 @@ body {
|
|||||||
border-bottom-color: #26364a;
|
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 上动态设置
|
* --ctms-sidebar-width 由 Layout.vue 在 body 上动态设置
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -1,9 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page ctms-page-shell page--flush medical-consult-page" :class="{ 'medical-consult-page--desktop': isDesktop }">
|
<div class="page ctms-page-shell page--flush medical-consult-page medical-consult-page--workbench">
|
||||||
<div v-if="!isDesktop" class="page-bg-dots"></div>
|
|
||||||
<section class="faq-hero">
|
<section class="faq-hero">
|
||||||
<h1 v-if="!isDesktop"><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
|
<div class="workbench-toolbar-meta">
|
||||||
<div v-else class="desktop-toolbar-meta">
|
|
||||||
<span>知识条目</span>
|
<span>知识条目</span>
|
||||||
<small>{{ activeCategoryName }} · {{ resultSummary }}</small>
|
<small>{{ activeCategoryName }} · {{ resultSummary }}</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,13 +21,12 @@
|
|||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
</template>
|
</template>
|
||||||
</el-autocomplete>
|
</el-autocomplete>
|
||||||
<PermissionAction v-if="isDesktop" action="faq.create">
|
<PermissionAction action="faq.create">
|
||||||
<el-button type="primary" class="new-consult-btn" @click="openForm()">
|
<el-button type="primary" class="new-consult-btn" @click="openForm()">
|
||||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||||
新建
|
新建
|
||||||
</el-button>
|
</el-button>
|
||||||
</PermissionAction>
|
</PermissionAction>
|
||||||
<div v-else class="spacer" />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -44,15 +41,6 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
|
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
|
||||||
<div class="faq-main unified-shell">
|
<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
|
<FaqList
|
||||||
:items="faqs"
|
:items="faqs"
|
||||||
:categories="categories"
|
:categories="categories"
|
||||||
@@ -84,12 +72,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
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 { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
|
||||||
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
|
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
|
||||||
import FaqList from "../components/FaqList.vue";
|
import FaqList from "../components/FaqList.vue";
|
||||||
import FaqItemForm from "../components/FaqItemForm.vue";
|
import FaqItemForm from "../components/FaqItemForm.vue";
|
||||||
import { isTauriRuntime } from "../runtime";
|
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import PermissionAction from "../components/PermissionAction.vue";
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
@@ -98,7 +85,6 @@ import { TEXT } from "../locales";
|
|||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const isDesktop = isTauriRuntime();
|
|
||||||
|
|
||||||
const categories = ref<any[]>([]);
|
const categories = ref<any[]>([]);
|
||||||
const faqs = ref<any[]>([]);
|
const faqs = ref<any[]>([]);
|
||||||
@@ -230,15 +216,6 @@ onMounted(async () => {
|
|||||||
background: linear-gradient(180deg, #f0f4ff 0%, #ffffff 30%, #f8fafc 100%);
|
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 {
|
.faq-hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 28px 24px 36px;
|
padding: 28px 24px 36px;
|
||||||
@@ -262,30 +239,6 @@ onMounted(async () => {
|
|||||||
pointer-events: none;
|
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 {
|
.hero-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -349,13 +302,6 @@ onMounted(async () => {
|
|||||||
padding: 24px 28px 0;
|
padding: 24px 28px 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.list-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 0 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-title strong {
|
.list-title strong {
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -377,11 +323,7 @@ onMounted(async () => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.workbench-toolbar-meta {
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop-toolbar-meta {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
@@ -391,7 +333,7 @@ onMounted(async () => {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-toolbar-meta::before {
|
.workbench-toolbar-meta::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
@@ -403,19 +345,19 @@ onMounted(async () => {
|
|||||||
box-shadow: 0 0 16px rgba(47, 123, 232, 0.35);
|
box-shadow: 0 0 16px rgba(47, 123, 232, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-toolbar-meta span {
|
.workbench-toolbar-meta span {
|
||||||
color: #11335a;
|
color: #11335a;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-toolbar-meta small {
|
.workbench-toolbar-meta small {
|
||||||
color: #436785;
|
color: #436785;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop {
|
.medical-consult-page--workbench {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -425,7 +367,7 @@ onMounted(async () => {
|
|||||||
background: linear-gradient(180deg, #eef5ff 0%, #f7fbff 100%);
|
background: linear-gradient(180deg, #eef5ff 0%, #f7fbff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-hero {
|
.medical-consult-page--workbench .faq-hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -443,22 +385,22 @@ onMounted(async () => {
|
|||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-hero::before {
|
.medical-consult-page--workbench .faq-hero::before {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .hero-tools {
|
.medical-consult-page--workbench .hero-tools {
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .hero-search {
|
.medical-consult-page--workbench .hero-search {
|
||||||
width: min(460px, 44vw);
|
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;
|
height: 34px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -470,17 +412,17 @@ onMounted(async () => {
|
|||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
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-size: 13px;
|
||||||
font-weight: 600;
|
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;
|
color: #6d93bc;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .new-consult-btn {
|
.medical-consult-page--workbench .new-consult-btn {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -492,14 +434,14 @@ onMounted(async () => {
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .new-consult-btn:hover,
|
.medical-consult-page--workbench .new-consult-btn:hover,
|
||||||
.medical-consult-page--desktop .new-consult-btn:focus {
|
.medical-consult-page--workbench .new-consult-btn:focus {
|
||||||
background: linear-gradient(135deg, #3d6bbf 0%, #254d90 100%);
|
background: linear-gradient(135deg, #3d6bbf 0%, #254d90 100%);
|
||||||
box-shadow: 0 4px 14px rgba(79, 126, 207, 0.4);
|
box-shadow: 0 4px 14px rgba(79, 126, 207, 0.4);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-workspace {
|
.medical-consult-page--workbench .faq-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 236px minmax(0, 1fr);
|
grid-template-columns: 236px minmax(0, 1fr);
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -512,14 +454,14 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-sidebar-col,
|
.medical-consult-page--workbench .faq-sidebar-col,
|
||||||
.medical-consult-page--desktop .faq-content-col {
|
.medical-consult-page--workbench .faq-content-col {
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
flex: initial !important;
|
flex: initial !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-sidebar-col {
|
.medical-consult-page--workbench .faq-sidebar-col {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-right: 1px solid rgba(134, 163, 194, 0.22);
|
border-right: 1px solid rgba(134, 163, 194, 0.22);
|
||||||
background:
|
background:
|
||||||
@@ -528,13 +470,13 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-content-col {
|
.medical-consult-page--workbench .faq-content-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-main {
|
.medical-consult-page--workbench .faq-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -547,31 +489,31 @@ onMounted(async () => {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .pagination-wrap {
|
.medical-consult-page--workbench .pagination-wrap {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-top: 1px solid #edf3fa;
|
border-top: 1px solid #edf3fa;
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
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%;
|
height: 100%;
|
||||||
padding: 12px 10px;
|
padding: 12px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop :deep(.faq-category-panel .header) {
|
.medical-consult-page--workbench :deep(.faq-category-panel .header) {
|
||||||
padding: 10px 10px;
|
padding: 10px 10px;
|
||||||
font-size: 12px;
|
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;
|
flex: 1 1 auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-top: 10px;
|
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;
|
height: 36px;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -579,27 +521,27 @@ onMounted(async () => {
|
|||||||
line-height: 36px;
|
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;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-hero),
|
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-hero),
|
||||||
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-workspace) {
|
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-workspace) {
|
||||||
border-color: #26364a;
|
border-color: #26364a;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 34%),
|
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 34%),
|
||||||
linear-gradient(135deg, #172033 0%, #111827 100%) !important;
|
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;
|
border-color: #26364a;
|
||||||
background: #111827;
|
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;
|
color: #e5edf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,16 +561,16 @@ onMounted(async () => {
|
|||||||
padding: 18px 16px 0;
|
padding: 18px 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-workspace {
|
.medical-consult-page--workbench .faq-workspace {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-sidebar-col {
|
.medical-consult-page--workbench .faq-sidebar-col {
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid #d9e2ec;
|
border-bottom: 1px solid #d9e2ec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medical-consult-page--desktop .faq-main {
|
.medical-consult-page--workbench .faq-main {
|
||||||
padding: 0;
|
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("@click=\"previewVersion(row)\"");
|
||||||
expect(source).toContain("TEXT.common.labels.preview");
|
expect(source).toContain("TEXT.common.labels.preview");
|
||||||
expect(source).toContain('return "image"');
|
expect(source).toContain("detectFilePreviewKind");
|
||||||
expect(source).toContain('return "pdf"');
|
|
||||||
expect(source).toContain("TEXT.common.messages.previewNotSupported");
|
expect(source).toContain("TEXT.common.messages.previewNotSupported");
|
||||||
expect(source).toContain("URL.createObjectURL(blob)");
|
expect(source).toContain("URL.createObjectURL(blob)");
|
||||||
expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)");
|
expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)");
|
||||||
expect(source).toContain("<PdfViewer");
|
expect(source).toContain("<PdfViewer");
|
||||||
|
expect(source).toContain("detectFilePreviewKind");
|
||||||
|
expect(source).toContain("/office-preview/version/${version.id}");
|
||||||
expect(source).not.toContain("<iframe");
|
expect(source).not.toContain("<iframe");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,6 +118,11 @@ describe("DocumentDetail breadcrumbs", () => {
|
|||||||
expect(source).toContain("siteName: displaySite(detail.site_id)");
|
expect(source).toContain("siteName: displaySite(detail.site_id)");
|
||||||
expect(source).toContain("pageTitle: detail.title || TEXT.modules.fileVersionManagement.title");
|
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).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();");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -350,8 +350,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
import { computed, defineAsyncComponent, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||||
import { Edit, Upload, Share } from "@element-plus/icons-vue";
|
import { Edit, Upload, Share } from "@element-plus/icons-vue";
|
||||||
import {
|
import {
|
||||||
@@ -383,8 +383,10 @@ import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } fro
|
|||||||
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
|
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
|
||||||
import { getContentDispositionFilename } from "../../utils/contentDisposition";
|
import { getContentDispositionFilename } from "../../utils/contentDisposition";
|
||||||
import { prepareSaveFile } from "../../runtime";
|
import { prepareSaveFile } from "../../runtime";
|
||||||
|
import { detectFilePreviewKind, ONLYOFFICE_FILE_EXTENSIONS } from "../../utils/officePreview";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const PdfViewer = defineAsyncComponent(() => import("../../components/PdfViewer.vue"));
|
const PdfViewer = defineAsyncComponent(() => import("../../components/PdfViewer.vue"));
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -531,7 +533,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
|
|||||||
const triggerFileInput = async () => {
|
const triggerFileInput = async () => {
|
||||||
const [file] = await pickFilesWithFeedback({
|
const [file] = await pickFilesWithFeedback({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
|
accept: [...ONLYOFFICE_FILE_EXTENSIONS, "pdf", "png", "jpg", "jpeg"],
|
||||||
title: "选择文档版本",
|
title: "选择文档版本",
|
||||||
});
|
});
|
||||||
if (file) uploadFile.value = file;
|
if (file) uploadFile.value = file;
|
||||||
@@ -669,6 +671,7 @@ const loadDetail = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await fetchDocumentDetail(documentId.value);
|
const { data } = await fetchDocumentDetail(documentId.value);
|
||||||
Object.assign(detail, data);
|
Object.assign(detail, data);
|
||||||
|
syncBreadcrumbContext();
|
||||||
if (!users.value.length) await loadUsers();
|
if (!users.value.length) await loadUsers();
|
||||||
if (!members.value.length) await loadMembers();
|
if (!members.value.length) await loadMembers();
|
||||||
if (!sites.value.length) await loadSites();
|
if (!sites.value.length) await loadSites();
|
||||||
@@ -771,13 +774,7 @@ const getVersionFileName = (version: DocumentVersion) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const detectPreviewType = (version: DocumentVersion) => {
|
const detectPreviewType = (version: DocumentVersion) => {
|
||||||
const mime = (version.mime_type || "").toLowerCase();
|
return detectFilePreviewKind(getVersionFileName(version), version.mime_type);
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearPreviewObjectUrl = () => {
|
const clearPreviewObjectUrl = () => {
|
||||||
@@ -789,7 +786,12 @@ const clearPreviewObjectUrl = () => {
|
|||||||
const previewVersion = async (version: DocumentVersion) => {
|
const previewVersion = async (version: DocumentVersion) => {
|
||||||
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
|
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
|
||||||
clearPreviewObjectUrl();
|
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}`;
|
previewTitle.value = getVersionFileName(version) || `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
|
||||||
previewUrl.value = "";
|
previewUrl.value = "";
|
||||||
previewError.value = "";
|
previewError.value = "";
|
||||||
@@ -924,6 +926,10 @@ onMounted(async () => {
|
|||||||
await loadDetail();
|
await loadDetail();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
if (detail.id) syncBreadcrumbContext();
|
||||||
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
desktopRefreshCleanup?.();
|
desktopRefreshCleanup?.();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page ctms-page-shell page--flush">
|
<div class="page ctms-page-shell page--flush">
|
||||||
<div v-if="study.currentStudy" class="unified-shell ctms-table-card">
|
<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 class="card-header ctms-page-header-row">
|
||||||
<div>
|
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</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">
|
<div class="header-actions">
|
||||||
<el-tag size="small" effect="plain">
|
<el-tag size="small" effect="plain">
|
||||||
{{ TEXT.modules.projectMilestones.statTotal }}{{ rows.length }}
|
{{ TEXT.modules.projectMilestones.statTotal }}{{ rows.length }}
|
||||||
@@ -200,7 +193,6 @@ import { useStudyStore } from "../../store/study";
|
|||||||
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
|
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
import StateEmpty from "../../components/StateEmpty.vue";
|
import StateEmpty from "../../components/StateEmpty.vue";
|
||||||
import { displayDateTime } from "../../utils/display";
|
|
||||||
import { usePermission } from "../../utils/permission";
|
import { usePermission } from "../../utils/permission";
|
||||||
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||||
|
|
||||||
@@ -237,8 +229,6 @@ const study = useStudyStore();
|
|||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const rows = ref<MilestoneRow[]>([]);
|
const rows = ref<MilestoneRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
|
|
||||||
const updatedAtLabel = ref<string>(TEXT.common.fallback);
|
|
||||||
const editorVisible = ref(false);
|
const editorVisible = ref(false);
|
||||||
const editingRowId = ref("");
|
const editingRowId = ref("");
|
||||||
const editorForm = reactive<MilestoneLocalEdit>({
|
const editorForm = reactive<MilestoneLocalEdit>({
|
||||||
@@ -309,15 +299,8 @@ const loadMilestones = async () => {
|
|||||||
const { data } = (await listProjectMilestones(studyId)) as any;
|
const { data } = (await listProjectMilestones(studyId)) as any;
|
||||||
const list = Array.isArray(data) ? data : data?.items || [];
|
const list = Array.isArray(data) ? data : data?.items || [];
|
||||||
rows.value = list.map(normalizeRow);
|
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) {
|
} catch (error: any) {
|
||||||
rows.value = [];
|
rows.value = [];
|
||||||
updatedAtLabel.value = TEXT.common.fallback;
|
|
||||||
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.loadFailed);
|
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.loadFailed);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -452,7 +435,6 @@ watch(
|
|||||||
() => study.currentStudy?.id,
|
() => study.currentStudy?.id,
|
||||||
() => {
|
() => {
|
||||||
rows.value = [];
|
rows.value = [];
|
||||||
updatedAtLabel.value = TEXT.common.fallback;
|
|
||||||
loadMilestones();
|
loadMilestones();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -503,22 +485,17 @@ watch(
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.milestone-action-bar {
|
||||||
|
min-height: 56px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--unified-title-color);
|
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 {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -53,7 +53,8 @@
|
|||||||
</template>
|
</template>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</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">
|
<div class="progress-legend">
|
||||||
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
|
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
|
||||||
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
|
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
|
||||||
@@ -146,7 +147,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Refresh } from "@element-plus/icons-vue";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { TEXT } from "../../locales";
|
import { TEXT } from "../../locales";
|
||||||
@@ -164,6 +165,8 @@ const isDesktop = isTauriRuntime();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const overview = ref<ProjectOverviewViewModel | null>(null);
|
const overview = ref<ProjectOverviewViewModel | null>(null);
|
||||||
const chartMode = ref<"center" | "month">("center");
|
const chartMode = ref<"center" | "month">("center");
|
||||||
|
const overviewClockNow = ref(new Date());
|
||||||
|
let overviewClockTimer: number | undefined;
|
||||||
|
|
||||||
const centers = computed(() => overview.value?.centers || []);
|
const centers = computed(() => overview.value?.centers || []);
|
||||||
const enrollmentCompletionRate = computed(() => {
|
const enrollmentCompletionRate = computed(() => {
|
||||||
@@ -183,6 +186,16 @@ const overviewUpdatedAtLabel = computed(() => {
|
|||||||
if (Number.isNaN(date.getTime())) return "-";
|
if (Number.isNaN(date.getTime())) return "-";
|
||||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
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> = {
|
const statusLabelMap: Record<StageStatus, string> = {
|
||||||
COMPLETED: "已完成",
|
COMPLETED: "已完成",
|
||||||
@@ -369,9 +382,17 @@ const reset = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
overviewClockNow.value = new Date();
|
||||||
|
overviewClockTimer = window.setInterval(() => {
|
||||||
|
overviewClockNow.value = new Date();
|
||||||
|
}, 1000);
|
||||||
loadOverview();
|
loadOverview();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (overviewClockTimer) window.clearInterval(overviewClockTimer);
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => study.currentStudy?.id,
|
() => study.currentStudy?.id,
|
||||||
() => {
|
() => {
|
||||||
@@ -417,6 +438,7 @@ watch(
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-live-clock,
|
||||||
.overview-updated-at {
|
.overview-updated-at {
|
||||||
color: var(--ctms-text-secondary);
|
color: var(--ctms-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ http {
|
|||||||
server backend:8000 resolve;
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -47,6 +67,17 @@ http {
|
|||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
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/ {
|
location /assets/ {
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
@@ -66,6 +97,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 {
|
location /health {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -31,11 +31,26 @@ http {
|
|||||||
server frontend-dev:5173 resolve;
|
server frontend-dev:5173 resolve;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upstream onlyoffice {
|
||||||
|
zone onlyoffice 64k;
|
||||||
|
server onlyoffice:80 resolve;
|
||||||
|
}
|
||||||
|
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
default upgrade;
|
default upgrade;
|
||||||
"" close;
|
"" 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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
@@ -56,6 +71,19 @@ http {
|
|||||||
return 404;
|
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/ {
|
location /api/ {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -64,6 +92,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 {
|
location /health {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -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
@@ -525,7 +525,7 @@ ctms_upsert_env_value() {
|
|||||||
local key="$1" value="$2"
|
local key="$1" value="$2"
|
||||||
local temp_file
|
local temp_file
|
||||||
temp_file="$(mktemp "$CTMS_ROOT_DIR/.env.tmp.XXXXXX")"
|
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" '
|
awk -v key="$key" -v value="$value" '
|
||||||
BEGIN { prefix = key "=" }
|
BEGIN { prefix = key "=" }
|
||||||
index($0, prefix) == 1 { print key "=" value; next }
|
index($0, prefix) == 1 { print key "=" value; next }
|
||||||
|
|||||||
Executable
+119
@@ -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
|
||||||
Reference in New Issue
Block a user