6 Commits

Author SHA1 Message Date
Cheng Zhou 90573f050f fix(onlyoffice): 修复云端台账保存与备份重开 2026-09-04 11:18:35 +08:00
Cheng Zhou 684b8b51bb build(onlyoffice): 移除未授权字体注入入口
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-09-04 09:50:08 +08:00
Cheng Zhou 49b32dc20f fix(台账): 更新合同模板并恢复文档服务器备份 2026-09-04 09:49:51 +08:00
Cheng Zhou 5eb50c704f 修复桌面凭据测试对构建环境的依赖
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
为桌面会话测试显式隔离默认服务器地址,避免持续集成环境影响未配置服务器场景的断言。
补充默认服务器地址下的凭据恢复验证,并确认注入默认地址时全部前端测试通过。
2026-09-03 17:00:39 +08:00
Cheng Zhou c1dd8def2c 新增全局协作台账并统一工作台管理界面
新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。
复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。
补充在线文档字体构建支持、数据库表结构升级和回归测试。
不包含本地台账内容、账号授权记录或历史文件迁移数据。
2026-09-03 16:55:08 +08:00
Cheng Zhou 46a6dda753 fix(monitoring): 修复飞线图白色渲染残影 2026-07-20 11:10:08 +08:00
49 changed files with 4720 additions and 1841 deletions
@@ -0,0 +1,38 @@
"""Add global ledgers sharing the collaboration engine.
Revision ID: 20260903_01
Revises: 20260716_05
"""
import sqlalchemy as sa
from alembic import op
revision = "20260903_01"
down_revision = "20260716_05"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("collaboration_files", "study_id", nullable=True)
op.add_column("collaboration_files", sa.Column("scope", sa.String(16), nullable=False, server_default="PROJECT"))
op.add_column("collaboration_files", sa.Column("ledger_key", sa.String(64), nullable=True))
op.add_column("collaboration_files", sa.Column("description", sa.String(500), nullable=False, server_default=""))
op.create_unique_constraint("uq_collaboration_file_ledger_key", "collaboration_files", ["ledger_key"])
op.create_check_constraint(
"ck_collaboration_file_scope", "collaboration_files",
"(scope = 'PROJECT' AND study_id IS NOT NULL AND ledger_key IS NULL) OR "
"(scope = 'LEDGER' AND study_id IS NULL AND folder_id IS NULL AND ledger_key IS NOT NULL "
"AND file_type = 'cell' AND extension = 'xlsx' AND deleted_at IS NULL AND status IN ('ACTIVE', 'ARCHIVED'))",
)
def downgrade() -> None:
# Never discard retained ledger files, grants or history during rollback.
if op.get_bind().scalar(sa.text("SELECT count(*) FROM collaboration_files WHERE scope = 'LEDGER'")):
raise RuntimeError("已有全局台账,不能回滚台账迁移;请保留数据并使用前向修复")
op.drop_constraint("ck_collaboration_file_scope", "collaboration_files", type_="check")
op.drop_constraint("uq_collaboration_file_ledger_key", "collaboration_files", type_="unique")
op.drop_column("collaboration_files", "description")
op.drop_column("collaboration_files", "ledger_key")
op.drop_column("collaboration_files", "scope")
op.alter_column("collaboration_files", "study_id", nullable=False)
+201
View File
@@ -0,0 +1,201 @@
import uuid
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles
from app.schemas.collaboration import (
CollaborationEditorConfigRead, CollaborationExportRecord, CollaborationRevisionRead,
CollaborationEditRequestRead, CollaborationEditRequestResolve, CollaborationOwnershipTransferRequest,
CollaborationShareLinkRead, CollaborationShareLinkUpdate, CollaborationRevisionUpdate,
)
from app.schemas.ledger import (
LedgerAccessSettings, LedgerCandidateRead, LedgerMemberRead, LedgerMemberUpsert, LedgerRead, LedgerRestore, LedgerSettings,
)
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import collaboration_service, collaboration_share_service, ledger_service, onlyoffice_collaboration_service, onlyoffice_service
def no_store(response: Response):
response.headers["Cache-Control"] = "no-store"
router = APIRouter(dependencies=[Depends(no_store)])
@router.get("", response_model=list[LedgerRead])
async def list_ledgers(db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
return await ledger_service.list_ledgers(db, user)
@router.post("/initialize", response_model=list[LedgerRead])
async def initialize(db: AsyncSession = Depends(get_db_session), user=Depends(require_roles(["ADMIN"]))):
return await ledger_service.initialize_ledgers(db, user)
@router.get("/{ledger_id}", response_model=LedgerRead)
async def read(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user)
return await ledger_service.read_ledger(db, item, user)
@router.patch("/{ledger_id}", response_model=LedgerRead)
async def update(ledger_id: uuid.UUID, payload: LedgerSettings, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.update_settings(db, item, payload, user)
@router.post("/{ledger_id}/import", response_model=LedgerRead)
async def import_template(
ledger_id: uuid.UUID, file: UploadFile = File(...), generation: int = Form(..., ge=1),
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.import_content(db, item, file, generation, user)
@router.get("/{ledger_id}/members", response_model=list[LedgerMemberRead])
async def members(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True)
return await ledger_service.list_members(db, item)
@router.get("/{ledger_id}/candidates", response_model=list[LedgerCandidateRead])
async def candidates(
ledger_id: uuid.UUID, keyword: str = Query(default="", max_length=100),
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
await ledger_service.get_ledger(db, ledger_id, user, manage=True)
return await ledger_service.candidates(db, keyword)
@router.put("/{ledger_id}/members", status_code=204)
async def grant(
ledger_id: uuid.UUID, payload: LedgerMemberUpsert,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
await ledger_service.set_member(db, item, payload, user)
@router.delete("/{ledger_id}/members/{user_id}", status_code=204)
async def revoke(
ledger_id: uuid.UUID, user_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
await ledger_service.remove_member(db, item, user_id, user)
@router.get("/{ledger_id}/editor-config", response_model=CollaborationEditorConfigRead)
async def editor_config(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
return await onlyoffice_collaboration_service.build_editor_config(db, item, user)
@router.patch("/{ledger_id}/access-settings", response_model=LedgerRead)
async def access_settings(ledger_id: uuid.UUID, payload: LedgerAccessSettings,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.update_access_settings(db, item, payload, user)
@router.get("/{ledger_id}/share-link", response_model=CollaborationShareLinkRead)
async def share_link(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await collaboration_share_service.get_share_link(db, item, user)
@router.put("/{ledger_id}/share-link", response_model=CollaborationShareLinkRead)
async def update_share_link(ledger_id: uuid.UUID, payload: CollaborationShareLinkUpdate,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await collaboration_share_service.update_share_link(db, item, payload, user)
@router.post("/{ledger_id}/edit-requests", response_model=CollaborationEditRequestRead)
async def request_edit(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
return await collaboration_service.create_edit_request(db, item, user)
@router.get("/{ledger_id}/edit-requests", response_model=list[CollaborationEditRequestRead])
async def edit_requests(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True)
return await collaboration_service.list_edit_requests(db, item, user)
@router.post("/{ledger_id}/edit-requests/{request_id}/resolve", response_model=CollaborationEditRequestRead)
async def resolve_request(ledger_id: uuid.UUID, request_id: uuid.UUID, payload: CollaborationEditRequestResolve,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, user)
@router.post("/{ledger_id}/transfer-ownership", response_model=LedgerRead)
async def transfer_owner(ledger_id: uuid.UUID, payload: CollaborationOwnershipTransferRequest,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
item = await collaboration_service.transfer_ownership(db, item, payload, user)
return await ledger_service.read_ledger(db, item, user)
@router.post("/{ledger_id}/downloads", status_code=204)
async def download_check(
ledger_id: uuid.UUID, payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user)
await collaboration_service.record_download(db, item, user, payload.file_type)
@router.get("/{ledger_id}/revisions", response_model=list[CollaborationRevisionRead])
async def revisions(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
item = await ledger_service.get_ledger(db, ledger_id, user)
return await collaboration_service.list_revisions(db, item)
@router.post("/{ledger_id}/revisions/{revision_id}/restore", response_model=CollaborationRevisionRead)
async def restore(
ledger_id: uuid.UUID, revision_id: uuid.UUID, payload: LedgerRestore,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
return await ledger_service.restore_revision(db, item, revision_id, payload.generation, user)
@router.patch("/{ledger_id}/revisions/{revision_id}", response_model=CollaborationRevisionRead)
async def name_revision(
ledger_id: uuid.UUID, revision_id: uuid.UUID, payload: CollaborationRevisionUpdate,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
return await collaboration_service.update_revision(db, item, revision_id, payload, user)
@router.get("/{ledger_id}/revisions/{revision_id}/download")
async def download_revision(
ledger_id: uuid.UUID, revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
await collaboration_service.require_file_exporter(db, item, user)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, user)
await collaboration_service.record_download(db, item, user, "xlsx")
return FileResponse(revision.file_uri, filename=item.title, media_type=revision.mime_type,
headers={"Cache-Control": "no-store"})
@router.get("/{ledger_id}/revisions/{revision_id}/preview-config", response_model=OnlyOfficePreviewConfigRead)
async def preview(
ledger_id: uuid.UUID, revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
):
item = await ledger_service.get_ledger(db, ledger_id, user)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, user)
await onlyoffice_service.ensure_onlyoffice_available()
return onlyoffice_service.build_preview_config(
resource_type="collaboration_revision", resource_id=revision.id, file_name=item.title,
file_hash=revision.file_hash, user_id=user.id, user_name=user.full_name,
)
+2
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter
from app.api.v1 import ledgers
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, collaboration
@@ -32,6 +33,7 @@ api_router.include_router(startup.router, prefix="/studies/{study_id}/startup",
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
api_router.include_router(collaboration.router, prefix="/studies/{study_id}/collaboration", tags=["collaboration"])
api_router.include_router(collaboration.public_router, prefix="/collaboration/shares", tags=["collaboration-shares"])
api_router.include_router(ledgers.router, prefix="/ledgers", tags=["ledgers"])
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
+12 -2
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
from sqlalchemy import BigInteger, Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -35,12 +35,22 @@ class CollaborationFolder(Base):
class CollaborationFile(Base):
__tablename__ = "collaboration_files"
__table_args__ = (
CheckConstraint(
"(scope = 'PROJECT' AND study_id IS NOT NULL AND ledger_key IS NULL) OR "
"(scope = 'LEDGER' AND study_id IS NULL AND folder_id IS NULL AND ledger_key IS NOT NULL "
"AND file_type = 'cell' AND extension = 'xlsx' AND deleted_at IS NULL AND status IN ('ACTIVE', 'ARCHIVED'))",
name="ck_collaboration_file_scope",
),
UniqueConstraint("ledger_key", name="uq_collaboration_file_ledger_key"),
Index("ix_collaboration_files_study_folder", "study_id", "folder_id"),
Index("ix_collaboration_files_study_status", "study_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="PROJECT", server_default="PROJECT")
ledger_key: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
description: Mapped[str] = mapped_column(String(500), nullable=False, default="", server_default="")
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
)
+88
View File
@@ -0,0 +1,88 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
LedgerRole = Literal["VIEWER", "EDITOR", "MANAGER"]
class LedgerRead(BaseModel):
id: uuid.UUID
title: str
description: str
status: Literal["ACTIVE", "ARCHIVED"]
owner_id: uuid.UUID
owner_name: str
generation: int
updated_at: datetime
current_revision_id: uuid.UUID | None
current_revision_no: int | None
current_revision_file_size: int | None
current_revision_mime_type: str | None
current_revision_created_at: datetime | None
role: LedgerRole
can_edit: bool
can_manage: bool
can_export: bool
allow_export: bool
allow_edit_request: bool
allow_sheet_structure_edit: bool
file_type: Literal["cell"] = "cell"
extension: Literal["xlsx"] = "xlsx"
can_request_edit: bool
edit_request_status: Literal["PENDING", "APPROVED", "REJECTED"] | None = None
can_transfer_ownership: bool
class LedgerSettings(BaseModel):
title: str = Field(min_length=1, max_length=240)
description: str = Field(default="", max_length=500)
status: Literal["ACTIVE", "ARCHIVED"]
owner_id: uuid.UUID
allow_export: bool = False
allow_edit_request: bool | None = None
allow_sheet_structure_edit: bool = False
generation: int = Field(ge=1)
@field_validator("title")
@classmethod
def trim_title(cls, value):
value = value.strip()
if not value:
raise ValueError("台账名称不能为空")
return value
class LedgerMemberUpsert(BaseModel):
user_id: uuid.UUID
role: LedgerRole
class LedgerMemberRead(BaseModel):
user_id: uuid.UUID
full_name: str
email: str
role: LedgerRole
is_active: bool
protected_label: str | None = None
class LedgerCandidateRead(BaseModel):
user_id: uuid.UUID
full_name: str
email: str
role_in_study: str
can_be_editor: bool = True
can_be_manager: bool = True
class LedgerAccessSettings(BaseModel):
generation: int = Field(ge=1)
allow_export: bool | None = None
allow_edit_request: bool | None = None
allow_sheet_structure_edit: bool | None = None
class LedgerRestore(BaseModel):
generation: int = Field(ge=1)
+121 -69
View File
@@ -46,6 +46,7 @@ from app.schemas.collaboration import (
CollaborationRevisionUpdate,
)
from app.services import notification_service
from app.services import ledger_access
COLLABORATION_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "collaboration"
FILE_TYPE_EXTENSION = {"word": "docx", "cell": "xlsx", "slide": "pptx"}
@@ -332,6 +333,8 @@ async def _require_role_assignable(
async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_edit(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -342,6 +345,8 @@ async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool
async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_manage(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -351,6 +356,8 @@ async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bo
async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return await ledger_access.can_export(db, item, user)
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -362,6 +369,8 @@ async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bo
async def can_create_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return False
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -387,6 +396,12 @@ async def edit_request_status(
async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return (
item.status == "ACTIVE" and item.allow_edit_request
and await ledger_access.role_for(db, item, user) == "VIEWER"
and await edit_request_status(db, item, user.id) != "PENDING"
)
if not item.allow_edit_request or await can_edit_file(db, item, user):
return False
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -398,6 +413,8 @@ async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user)
async def can_transfer_ownership(db: AsyncSession, item: CollaborationFile, user) -> bool:
if ledger_access.is_ledger(item):
return bool(user and user.is_active and (is_system_admin(user) or item.owner_id == user.id))
if is_system_admin(user):
return True
membership = await member_crud.get_member(db, item.study_id, user.id)
@@ -535,7 +552,7 @@ async def _persist_revision_bytes(
select(func.max(CollaborationRevision.revision_no)).where(CollaborationRevision.file_id == locked.id)
)
revision_no = int(last_no or 0) + 1
directory = COLLABORATION_ROOT / str(locked.study_id) / str(locked.id)
directory = COLLABORATION_ROOT / ("ledgers" if ledger_access.is_ledger(locked) else str(locked.study_id)) / str(locked.id)
directory.mkdir(parents=True, exist_ok=True)
destination = directory / f"{uuid.uuid4()}.{locked.extension}"
async with aiofiles.open(destination, "wb") as stream:
@@ -841,6 +858,8 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None:
if ledger_access.is_ledger(item):
raise HTTPException(403, "台账禁止删除,请使用归档")
await require_file_manager(db, item, user)
item.status = "DELETED"
item.deleted_at = datetime.now(timezone.utc)
@@ -899,6 +918,8 @@ async def delete_revision(
revision_id: uuid.UUID,
user,
) -> None:
if ledger_access.is_ledger(item):
raise HTTPException(403, "台账历史版本禁止删除")
await require_file_manager(db, item, user)
revision = await get_revision_or_404(db, item, revision_id)
if item.current_revision_id == revision.id:
@@ -965,10 +986,19 @@ async def copy_revision(
async def restore_revision(
db: AsyncSession, item: CollaborationFile, revision_id: uuid.UUID, user, change_summary: str | None
) -> CollaborationRevision:
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user, manage=True)
await require_file_editor(db, item, user)
revision = await get_revision_or_404(db, item, revision_id)
async with aiofiles.open(revision.file_uri, "rb") as stream:
content = await stream.read()
if ledger_access.is_ledger(item):
content, _ = apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
# A historical revision may still carry CTMS protection after the
# current ledger was unlocked. Apply today's policy on restoration.
protection_backup=item.sheet_structure_protection_backup or "",
)
restored, created = await append_revision(
db,
item,
@@ -1092,9 +1122,14 @@ async def create_edit_request(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="您已拥有此文件的编辑权限")
if not item.allow_edit_request:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="此文件未开放编辑权限申请")
membership = await member_crud.get_member(db, item.study_id, user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前项目成员可以申请编辑权限")
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user)
if item.status != "ACTIVE":
raise HTTPException(409, "已归档台账不能申请编辑权限")
else:
membership = await member_crud.get_member(db, item.study_id, user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前项目成员可以申请编辑权限")
pending = await db.scalar(
select(CollaborationEditRequest).where(
CollaborationEditRequest.file_id == item.id,
@@ -1107,40 +1142,41 @@ async def create_edit_request(
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
db.add(request)
await db.flush()
manager_ids = set((await db.scalars(
select(StudyMember.user_id)
.outerjoin(
CollaborationMember,
and_(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == StudyMember.user_id,
),
if not ledger_access.is_ledger(item):
manager_ids = set((await db.scalars(
select(StudyMember.user_id)
.outerjoin(
CollaborationMember,
and_(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == StudyMember.user_id,
),
)
.where(
StudyMember.study_id == item.study_id,
StudyMember.is_active.is_(True),
or_(
StudyMember.user_id == item.owner_id,
CollaborationMember.role == "MANAGER",
),
)
)).all())
manager_ids.add(item.owner_id)
requester_name = str(user.full_name or user.email or "项目成员")
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=manager_ids,
category="COLLABORATION_EDIT_REQUEST",
priority="NORMAL",
title="新的编辑权限申请",
message=f"{requester_name} 申请编辑“{item.title}”",
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request:{request.id}",
source_version="PENDING",
)
.where(
StudyMember.study_id == item.study_id,
StudyMember.is_active.is_(True),
or_(
StudyMember.user_id == item.owner_id,
CollaborationMember.role == "MANAGER",
),
)
)).all())
manager_ids.add(item.owner_id)
requester_name = str(user.full_name or user.email or "项目成员")
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=manager_ids,
category="COLLABORATION_EDIT_REQUEST",
priority="NORMAL",
title="新的编辑权限申请",
message=f"{requester_name} 申请编辑“{item.title}”",
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request:{request.id}",
source_version="PENDING",
)
await db.commit()
await db.refresh(request)
return _edit_request_read(request, user)
@@ -1179,11 +1215,18 @@ async def resolve_edit_request(
if request.status != "PENDING":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="编辑权限申请已处理")
requester = await db.get(User, request.requester_id)
membership = await member_crud.get_member(db, item.study_id, request.requester_id)
if not requester or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="申请人已不是当前项目的有效成员")
if ledger_access.is_ledger(item):
if item.status != "ACTIVE" or not await ledger_access.role_for(db, item, requester):
raise HTTPException(422, "申请人已无台账访问权限或台账已归档")
else:
membership = await member_crud.get_member(db, item.study_id, request.requester_id)
if not requester or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="申请人已不是当前项目的有效成员")
if payload.status == "APPROVED":
await _require_role_assignable(db, item.study_id, requester, membership, "EDITOR")
if ledger_access.is_ledger(item):
item.generation += 1
else:
await _require_role_assignable(db, item.study_id, requester, membership, "EDITOR")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id,
CollaborationMember.user_id == request.requester_id,
@@ -1201,30 +1244,31 @@ async def resolve_edit_request(
request.status = payload.status
request.resolved_by = user.id
request.resolved_at = datetime.now(timezone.utc)
approved = payload.status == "APPROVED"
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=[request.requester_id],
category="COLLABORATION_EDIT_REQUEST_RESULT",
priority="NORMAL",
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
action_path=(
f"/knowledge/collaboration/{item.id}"
if approved
else "/knowledge/collaboration"
),
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request-result:{request.id}",
requires_action=False,
)
await notification_service.resolve_source_notifications(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
if not ledger_access.is_ledger(item):
approved = payload.status == "APPROVED"
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=[request.requester_id],
category="COLLABORATION_EDIT_REQUEST_RESULT",
priority="NORMAL",
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
action_path=(
f"/knowledge/collaboration/{item.id}"
if approved
else "/knowledge/collaboration"
),
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request-result:{request.id}",
requires_action=False,
)
await notification_service.resolve_source_notifications(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
await db.commit()
await db.refresh(request)
return _edit_request_read(request, requester)
@@ -1242,11 +1286,16 @@ async def transfer_ownership(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
if payload.new_owner_id == locked.owner_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="所选联系人已经是文档所有者")
membership = await member_crud.get_member(db, locked.study_id, payload.new_owner_id)
target = await db.get(User, payload.new_owner_id)
if not target or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能转让给当前项目的有效成员")
await _require_role_assignable(db, locked.study_id, target, membership, "MANAGER")
if ledger_access.is_ledger(locked):
if not target or not target.is_active:
raise HTTPException(422, "只能转让给有效的系统账号")
locked.generation += 1
else:
membership = await member_crud.get_member(db, locked.study_id, payload.new_owner_id)
if not target or not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能转让给当前项目的有效成员")
await _require_role_assignable(db, locked.study_id, target, membership, "MANAGER")
previous_owner_id = locked.owner_id
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == locked.id,
@@ -1268,6 +1317,9 @@ async def transfer_ownership(
))
if previous_owner_member:
previous_owner_member.role = "MANAGER"
elif ledger_access.is_ledger(locked):
db.add(CollaborationMember(file_id=locked.id, user_id=previous_owner_id,
role="MANAGER", invited_by=user.id))
locked.owner_id = payload.new_owner_id
locked.updated_at = datetime.now(timezone.utc)
await db.commit()
@@ -22,7 +22,7 @@ from app.schemas.collaboration import (
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
)
from app.services import collaboration_service
from app.services import collaboration_service, ledger_access
SHARE_PATH = "/collaboration/share"
@@ -177,6 +177,11 @@ async def update_share_link(
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
if ledger_access.is_ledger(item):
# Ledger routes hold the same file lock as callbacks before changing a link.
item.generation += 1
item.updated_at = now
link.token_version += 1
await db.commit()
await db.refresh(link)
return share_link_read(link)
+41
View File
@@ -0,0 +1,41 @@
"""Account-level ledger permissions, independent of study membership."""
from sqlalchemy import select
from fastapi import HTTPException
from app.core.deps import is_system_admin
from app.models.collaboration import CollaborationMember
def is_ledger(item) -> bool:
return getattr(item, "scope", "PROJECT") == "LEDGER"
async def role_for(db, item, user) -> str | None:
if not user or not user.is_active:
return None
if is_system_admin(user) or item.owner_id == user.id:
return "MANAGER"
return await db.scalar(select(CollaborationMember.role).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user.id,
))
async def can_edit(db, item, user) -> bool:
return item.status == "ACTIVE" and await role_for(db, item, user) in {"EDITOR", "MANAGER"}
async def can_manage(db, item, user) -> bool:
return await role_for(db, item, user) == "MANAGER"
async def can_export(db, item, user) -> bool:
role = await role_for(db, item, user)
return role == "MANAGER" or (role in {"EDITOR", "VIEWER"} and item.allow_export)
async def require_access(db, item, user, *, manage=False):
role = await role_for(db, item, user)
if role not in {"VIEWER", "EDITOR", "MANAGER"}:
raise HTTPException(404, "台账不存在或未获授权")
if manage and role != "MANAGER":
raise HTTPException(403, "仅台账管理人员可以执行此操作")
+259
View File
@@ -0,0 +1,259 @@
"""Global ledger metadata and grants; reuse collaboration revisions and sessions."""
import io
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
import aiofiles
from fastapi import HTTPException
from sqlalchemy import or_, select, text
from app.core.config import settings
from app.core.deps import is_system_admin
from app.models.collaboration import CollaborationFile, CollaborationMember, CollaborationRevision
from app.models.user import User, UserStatus
from app.schemas.ledger import LedgerRead, LedgerSettings
from app.services import collaboration_service as collaboration
from app.services import ledger_access
LEDGER_TEMPLATES = (
("ledger-1", "医学事务部合同台账明细表.xlsx", "contract-ledger.xlsx", "记录合同编号、项目代码与合同内容"),
("ledger-2", "医学事务部临床运营项目编号.xlsx", "clinical-project-register.xlsx", "记录项目编号、产品信息与生效日期"),
)
TEMPLATE_ROOT = Path(__file__).resolve().parents[1] / "templates" / "ledgers"
async def get_ledger(db, ledger_id, user, *, manage=False, lock=False):
stmt = select(CollaborationFile).where(
CollaborationFile.id == ledger_id, CollaborationFile.scope == "LEDGER",
).execution_options(populate_existing=True)
if lock:
stmt = stmt.with_for_update()
item = await db.scalar(stmt)
if not item:
raise HTTPException(404, "台账不存在或未获授权")
await ledger_access.require_access(db, item, user, manage=manage)
return item
async def read_ledger(db, item, user):
role = await ledger_access.role_for(db, item, user)
await ledger_access.require_access(db, item, user)
owner = await db.get(User, item.owner_id)
revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None
return LedgerRead(
id=item.id, title=item.title, description=item.description, status=item.status,
owner_id=item.owner_id, owner_name=owner.full_name if owner else "",
generation=item.generation, updated_at=item.updated_at,
current_revision_id=item.current_revision_id,
current_revision_no=revision.revision_no if revision else None,
current_revision_file_size=revision.file_size if revision else None,
current_revision_mime_type=revision.mime_type if revision else None,
current_revision_created_at=revision.created_at if revision else None,
role=role, can_edit=await ledger_access.can_edit(db, item, user),
can_manage=role == "MANAGER", can_export=await ledger_access.can_export(db, item, user),
allow_export=item.allow_export, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
allow_edit_request=item.allow_edit_request,
can_request_edit=await collaboration.can_request_edit_file(db, item, user),
edit_request_status=await collaboration.edit_request_status(db, item, user.id),
can_transfer_ownership=await collaboration.can_transfer_ownership(db, item, user),
)
async def list_ledgers(db, user):
stmt = select(CollaborationFile).where(CollaborationFile.scope == "LEDGER")
if not is_system_admin(user):
granted = select(CollaborationMember.file_id).where(
CollaborationMember.user_id == user.id,
CollaborationMember.role.in_(["VIEWER", "EDITOR", "MANAGER"]),
)
stmt = stmt.where(or_(CollaborationFile.owner_id == user.id, CollaborationFile.id.in_(granted)))
rows = (await db.scalars(stmt.order_by(CollaborationFile.ledger_key))).all()
return [await read_ledger(db, item, user) for item in rows]
async def initialize_ledgers(db, user):
if not is_system_admin(user):
raise HTTPException(403, "仅系统管理员可以初始化台账")
# Serialize initialization across administrators; unique keys also prevent duplicates.
if db.get_bind().dialect.name == "postgresql":
await db.execute(text("SELECT pg_advisory_xact_lock(2026090301)"))
for key, title, template_name, description in LEDGER_TEMPLATES:
if await db.scalar(select(CollaborationFile.id).where(CollaborationFile.ledger_key == key)):
continue
item = CollaborationFile(
id=uuid.uuid4(), scope="LEDGER", ledger_key=key, study_id=None, folder_id=None,
title=title, description=description, file_type="cell", extension="xlsx", owner_id=user.id,
status="ACTIVE", generation=1, allow_sheet_structure_edit=False, allow_export=False,
)
db.add(item)
await db.flush()
content, backup = collaboration.apply_workbook_structure_policy(
(TEMPLATE_ROOT / template_name).read_bytes(), file_id=item.id,
allow_sheet_structure_edit=False, protection_backup=None,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="CREATE", created_by=user.id)
await db.commit()
return await list_ledgers(db, user)
def check_generation(item, generation):
if item.generation != generation:
raise HTTPException(409, "台账已发生变化,请刷新后重试")
def advance_generation(item):
item.generation += 1
item.updated_at = datetime.now(timezone.utc)
async def active_user(db, user_id):
user = await db.get(User, user_id)
if not user or not user.is_active:
raise HTTPException(422, "请选择有效的系统账号")
return user
async def update_settings(db, item, payload, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, payload.generation)
if payload.owner_id != item.owner_id:
if not is_system_admin(user):
raise HTTPException(403, "仅系统管理员可以变更台账负责人")
await active_user(db, payload.owner_id)
if payload.allow_sheet_structure_edit != item.allow_sheet_structure_edit:
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).is_file():
raise HTTPException(404, "台账内容不存在")
async with aiofiles.open(revision.file_uri, "rb") as stream:
content = await stream.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
content, backup = collaboration.apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=payload.allow_sheet_structure_edit,
protection_backup=item.sheet_structure_protection_backup,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="PERMISSION_CHANGE", created_by=user.id)
item.title = collaboration._safe_title(payload.title, "xlsx")
item.description = payload.description.strip()
item.status = payload.status
item.owner_id = payload.owner_id
item.allow_export = payload.allow_export
if payload.allow_edit_request is not None:
item.allow_edit_request = payload.allow_edit_request
item.allow_sheet_structure_edit = payload.allow_sheet_structure_edit
advance_generation(item)
await db.commit()
return await read_ledger(db, item, user)
async def update_access_settings(db, item, payload, user):
values = dict(title=item.title, description=item.description, status=item.status,
owner_id=item.owner_id, allow_export=item.allow_export,
allow_edit_request=item.allow_edit_request,
allow_sheet_structure_edit=item.allow_sheet_structure_edit)
values.update(payload.model_dump(exclude_none=True))
return await update_settings(db, item, LedgerSettings(**values), user)
async def list_members(db, item):
rows = (await db.execute(select(CollaborationMember, User).join(
User, User.id == CollaborationMember.user_id,
).where(CollaborationMember.file_id == item.id).order_by(User.full_name))).all()
grants = {user.id: dict(user_id=user.id, full_name=user.full_name, email=user.email,
role=member.role, is_active=user.is_active) for member, user in rows}
implicit = (await db.scalars(select(User).where(or_(
User.id == item.owner_id,
(User.is_admin.is_(True)) & (User.status == UserStatus.ACTIVE),
)).order_by(User.full_name))).all()
for user in implicit:
grants[user.id] = dict(user_id=user.id, full_name=user.full_name, email=user.email,
role="MANAGER", is_active=user.is_active,
protected_label="所有者" if user.id == item.owner_id else "系统管理员")
return sorted(grants.values(), key=lambda member: (
member["user_id"] != item.owner_id, member["role"] != "MANAGER", member["full_name"],
))
async def set_member(db, item, payload, user):
await ledger_access.require_access(db, item, user, manage=True)
target = await active_user(db, payload.user_id)
if target.id == item.owner_id or is_system_admin(target):
raise HTTPException(422, "负责人和系统管理员已拥有管理权限")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == target.id,
))
if member and member.role == payload.role:
return
if member:
member.role = payload.role
else:
db.add(CollaborationMember(file_id=item.id, user_id=target.id, role=payload.role, invited_by=user.id))
advance_generation(item)
await db.commit()
async def remove_member(db, item, user_id, user):
await ledger_access.require_access(db, item, user, manage=True)
if user_id == item.owner_id:
raise HTTPException(422, "不能移除台账负责人")
target = await db.get(User, user_id)
if target and is_system_admin(target):
raise HTTPException(422, "系统管理员始终拥有管理权限")
member = await db.scalar(select(CollaborationMember).where(
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user_id,
))
if member:
await db.delete(member)
advance_generation(item)
await db.commit()
async def candidates(db, keyword):
stmt = select(User).where(User.status == UserStatus.ACTIVE)
if keyword:
pattern = f"%{keyword}%"
stmt = stmt.where(or_(User.full_name.ilike(pattern), User.email.ilike(pattern)))
rows = (await db.scalars(stmt.order_by(User.full_name, User.id))).all()
return [dict(user_id=user.id, full_name=user.full_name, email=user.email,
role_in_study="系统管理员" if user.is_admin else "系统账号") for user in rows]
async def import_content(db, item, upload, generation, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, generation)
if item.status != "ACTIVE":
raise HTTPException(409, "请先启用台账再导入")
if Path(upload.filename or "").suffix.lower() != ".xlsx":
raise HTTPException(415, "台账仅支持 XLSX 文件")
content = await upload.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
raise HTTPException(413, "台账文件为空或超出大小限制")
try:
with zipfile.ZipFile(io.BytesIO(content)) as package:
if "xl/workbook.xml" not in package.namelist():
raise ValueError()
if sum(info.file_size for info in package.infolist()) > settings.COLLABORATION_MAX_FILE_BYTES * 20:
raise ValueError()
if any("vbaproject" in name.lower() for name in package.namelist()):
raise ValueError()
except (zipfile.BadZipFile, ValueError):
raise HTTPException(422, "XLSX 文件无效、包含宏或解压后过大")
content, backup = collaboration.apply_workbook_structure_policy(
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
protection_backup=None,
)
item.sheet_structure_protection_backup = backup
await collaboration.append_revision(db, item, content, source="IMPORT", created_by=user.id)
advance_generation(item)
await db.commit()
return await read_ledger(db, item, user)
async def restore_revision(db, item, revision_id, generation, user):
await ledger_access.require_access(db, item, user, manage=True)
check_generation(item, generation)
if item.status != "ACTIVE":
raise HTTPException(409, "请先启用台账再恢复版本")
return await collaboration.restore_revision(db, item, revision_id, user, None)
@@ -2,8 +2,10 @@ from __future__ import annotations
import hashlib
import hmac
import io
import json
import uuid
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
@@ -25,7 +27,7 @@ from app.models.collaboration import (
)
from app.models.user import User
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
from app.services import collaboration_service, onlyoffice_service
from app.services import collaboration_service, ledger_access, onlyoffice_service
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
@@ -57,12 +59,55 @@ async def _active_session(
).order_by(CollaborationSession.created_at.desc())
)
if session:
if session.status != "ACTIVE":
session.status = "ACTIVE"
session.closed_at = None
await db.commit()
await db.refresh(session)
return session
should_recover = session.status == "ERROR"
if session.status == "ACTIVE":
created_at = session.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
if (
session.last_callback_at is None
and datetime.now(timezone.utc) - created_at < timedelta(seconds=15)
):
# The editor config can be requested twice before the first
# browser has connected and emitted status 1. Keep a short
# connection grace period so the second request does not retire
# the freshly issued key as a false stale session.
return session
live_users = await _document_server_users(session.document_key)
if live_users:
return session
# A service restart or rejected final callback can leave the row
# ACTIVE after Document Server has already retired the editing
# process. Reusing that key opens its forgotten copy as an
# unbound server backup, so retire it exactly like a final callback.
should_recover = True
# A final callback ends the editing lifecycle for this key. Never
# reactivate it: Document Server can retain a cached or forgotten copy
# for the old key, especially across a container restart.
recovered = (
await _recover_forgotten_content(session.document_key, item.file_type)
if should_recover
else None
)
if recovered is not None:
revision, _ = await collaboration_service.append_revision(
db,
item,
recovered,
source="SERVER_RECOVERY",
created_by=user_id,
change_summary="自动恢复在线文档服务器备份",
)
session.base_revision_id = revision.id
# The retired key can still point to Document Server's cache. A new
# generation must use a new key or a later open can fall back to the
# same server-side copy again.
item.generation += 1
session.status = "RECOVERED" if recovered is not None else "CLOSED"
session.closed_at = datetime.now(timezone.utc)
await db.commit()
session = None
if not item.current_revision_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
session = CollaborationSession(
@@ -81,6 +126,8 @@ async def _active_session(
async def build_editor_config(
db: AsyncSession, item: CollaborationFile, user
) -> CollaborationEditorConfigRead:
if ledger_access.is_ledger(item):
await ledger_access.require_access(db, item, user)
await onlyoffice_service.ensure_onlyoffice_available()
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).exists():
@@ -158,6 +205,15 @@ async def build_shared_editor_config(
client_id: str,
display_name: str,
) -> CollaborationEditorConfigRead:
if ledger_access.is_ledger(item):
version = link.token_version
item = await db.scalar(select(CollaborationFile).where(
CollaborationFile.id == item.id,
).with_for_update().execution_options(populate_existing=True))
await db.refresh(link)
if (not item or item.status != "ACTIVE" or not link.enabled or link.token_version != version
or (link.expires_at and link.expires_at <= datetime.now(timezone.utc))):
raise HTTPException(404, "共享链接不存在或已失效")
await onlyoffice_service.ensure_onlyoffice_available()
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).exists():
@@ -169,6 +225,10 @@ async def build_shared_editor_config(
if link.expires_at and link.expires_at < expires_at:
expires_at = link.expires_at
external_user_id = f"share-{link.id.hex[:12]}-{client_id[:32]}"
if ledger_access.is_ledger(item):
known_users = json.loads(session.active_users or "[]")
session.active_users = json.dumps(list(dict.fromkeys([*known_users, external_user_id])))
await db.commit()
config: dict[str, Any] = {
"type": "desktop",
"documentType": item.file_type,
@@ -234,6 +294,8 @@ async def get_session_content(
item = await db.get(CollaborationFile, session.file_id)
if not revision or not item or not Path(revision.file_uri).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
if ledger_access.is_ledger(item) and session.generation != item.generation:
raise HTTPException(403, "台账会话已失效,请重新打开")
return revision, item
@@ -322,11 +384,123 @@ async def _download_result(url: str) -> bytes:
return bytes(content)
def _validate_recovered_content(content: bytes, file_type: str) -> None:
required_part = {
"word": "word/document.xml",
"cell": "xl/workbook.xml",
"slide": "ppt/presentation.xml",
}.get(file_type)
if not required_part or not zipfile.is_zipfile(io.BytesIO(content)):
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份格式无效")
try:
with zipfile.ZipFile(io.BytesIO(content)) as package:
if required_part not in package.namelist() or package.testzip() is not None:
raise ValueError
except (zipfile.BadZipFile, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份已损坏") from exc
async def _recover_forgotten_content(document_key: str, file_type: str) -> bytes | None:
"""Download a Document Server backup left behind by a failed final save."""
command = {"c": "getForgotten", "key": document_key}
token = jwt.encode(command, settings.ONLYOFFICE_JWT_SECRET or "", algorithm="HS256")
command_url = f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/command"
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
response = await client.post(
command_url,
params={"shardkey": document_key},
json={**command, "token": token},
)
if response.status_code != status.HTTP_200_OK:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用")
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用") from exc
error = payload.get("error") if isinstance(payload, dict) else None
if error == 1:
# Document Server no longer has a forgotten copy. Starting from the
# last confirmed CTMS revision is then the only recoverable state.
return None
if (
error != 0
or payload.get("key") != document_key
or not isinstance(payload.get("url"), str)
or not payload["url"]
):
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器返回的备份信息无效")
content = await _download_result(payload["url"])
_validate_recovered_content(content, file_type)
return content
async def _document_server_users(document_key: str) -> list[str]:
"""Return live editor ids for a key without trusting stale database state."""
command = {"c": "info", "key": document_key}
token = jwt.encode(command, settings.ONLYOFFICE_JWT_SECRET or "", algorithm="HS256")
command_url = f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/command"
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
response = await client.post(
command_url,
params={"shardkey": document_key},
json={**command, "token": token},
)
if response.status_code != status.HTTP_200_OK:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="在线文档会话检查服务暂不可用",
)
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="在线文档会话检查服务暂不可用",
) from exc
error = payload.get("error") if isinstance(payload, dict) else None
if error == 1:
# The database can retain an ACTIVE row after an interrupted callback,
# while Document Server no longer has a live editing process for it.
return []
users = payload.get("users") if isinstance(payload, dict) else None
if (
error != 0
or payload.get("key") != document_key
or not isinstance(users, list)
or any(not isinstance(user_id, str) or not user_id for user_id in users)
):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="在线文档服务器返回的会话信息无效",
)
return list(dict.fromkeys(users))
async def list_live_editing_sessions(db: AsyncSession) -> list[tuple[str, int]]:
"""List file titles and live editor counts for deployment safety checks."""
rows = (
await db.execute(
select(CollaborationSession.document_key, CollaborationFile.title)
.join(CollaborationFile, CollaborationFile.id == CollaborationSession.file_id)
.where(CollaborationSession.status == "ACTIVE")
.order_by(CollaborationFile.title)
)
).all()
active: list[tuple[str, int]] = []
for document_key, title in rows:
users = await _document_server_users(document_key)
if users:
active.append((title, len(users)))
return active
async def _callback_user(
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
) -> User | None:
has_public_share_user = False
for value in payload.users:
for value in payload.users or json.loads(session.active_users or "[]"):
if value.startswith("share-"):
has_public_share_user = True
continue
@@ -344,13 +518,40 @@ async def _callback_user(
return user
async def _ledger_callback_can_edit(db, item, payload, session):
users = payload.users or json.loads(session.active_users or "[]")
if not users:
return await ledger_access.can_edit(db, item, await _callback_user(db, payload, session))
for value in users:
try:
actor = await db.get(User, uuid.UUID(value))
except (ValueError, TypeError):
continue
if await ledger_access.can_edit(db, item, actor):
return True
link = await db.scalar(select(CollaborationShareLink).where(
CollaborationShareLink.file_id == item.id,
CollaborationShareLink.enabled.is_(True),
CollaborationShareLink.access_mode == "EDIT",
))
if not link or (link.expires_at and link.expires_at <= datetime.now(timezone.utc)):
return False
prefix = f"share-{link.id.hex[:12]}-"
return any(value.startswith(prefix) for value in users)
async def process_callback(
db: AsyncSession,
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
) -> dict[str, int]:
# Lock the file before writing sessions, matching editor initialization and
# ledger permission changes. This also serializes callbacks across generations.
session = await db.scalar(
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
select(CollaborationSession)
.join(CollaborationFile, CollaborationFile.id == CollaborationSession.file_id)
.where(CollaborationSession.id == session_id)
.with_for_update(of=CollaborationFile)
)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
@@ -367,8 +568,12 @@ async def process_callback(
item = await db.get(CollaborationFile, session.file_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
if ledger_access.is_ledger(item):
# Same lock as grant/settings changes: a stale callback cannot race a revocation.
item = await db.scalar(select(CollaborationFile).where(
CollaborationFile.id == item.id,
).with_for_update().execution_options(populate_existing=True))
session.last_callback_at = datetime.now(timezone.utc)
session.active_users = json.dumps(payload.users, ensure_ascii=True)
result = "ACKNOWLEDGED"
saved_revision_id = None
@@ -377,6 +582,10 @@ async def process_callback(
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
if session.generation != item.generation:
result = "STALE"
elif ledger_access.is_ledger(item) and (
item.status != "ACTIVE" or not await _ledger_callback_can_edit(db, item, payload, session)
):
result = "ACCESS_REVOKED"
else:
content = await _download_result(payload.url)
actor = await _callback_user(db, payload, session)
@@ -400,10 +609,16 @@ async def process_callback(
session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc)
result = "UNCHANGED"
elif payload.status in {3, 7}:
elif payload.status == 3:
session.status = "ERROR"
result = "ERROR"
elif payload.status == 7:
# A force-save error does not close the live co-editing session. The
# final status 2 callback can still persist the document normally.
result = "ERROR"
if payload.users or not ledger_access.is_ledger(item):
session.active_users = json.dumps(payload.users, ensure_ascii=True)
db.add(CollaborationCallbackReceipt(
session_id=session.id,
fingerprint=fingerprint,
Binary file not shown.
@@ -0,0 +1,44 @@
"""Abort a deployment when ONLYOFFICE still has live collaborative editors."""
import asyncio
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from fastapi import HTTPException # noqa: E402
from app.core.config import settings # noqa: E402
from app.db.session import SessionLocal # noqa: E402
from app.services.onlyoffice_collaboration_service import list_live_editing_sessions # noqa: E402
async def async_main() -> int:
if not settings.ONLYOFFICE_ENABLED:
print("ONLYOFFICE 未启用,跳过在线编辑会话检查")
return 0
try:
async with SessionLocal() as db:
active = await list_live_editing_sessions(db)
except HTTPException as exc:
print(f"无法确认 ONLYOFFICE 在线编辑状态:{exc.detail}", file=sys.stderr)
return 1
if not active:
print("未检测到 ONLYOFFICE 在线编辑者")
return 0
print("检测到仍在进行的 ONLYOFFICE 在线编辑,会中止本次部署:", file=sys.stderr)
for title, count in active:
print(f"- {title}:{count} 人在线", file=sys.stderr)
print("请通知用户退出编辑器,等待最终保存完成后重新执行部署。", file=sys.stderr)
return 2
def main() -> None:
raise SystemExit(asyncio.run(async_main()))
if __name__ == "__main__":
main()
+189 -1
View File
@@ -4,7 +4,7 @@ import uuid
import zipfile
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock
from unittest.mock import ANY, AsyncMock, call
import pytest
from fastapi import HTTPException
@@ -304,6 +304,18 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m
assert onlyoffice_collaboration_service._validate_result_url(
"http://localhost:8888/onlyoffice/cache/result.docx?token=signed"
) == "http://onlyoffice/cache/result.docx?token=signed"
with pytest.raises(HTTPException) as mismatched_origin:
onlyoffice_collaboration_service._validate_result_url(
"https://ctms.example.com/onlyoffice/cache/result.xlsx?token=signed"
)
assert mismatched_origin.value.status_code == 422
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "https://ctms.example.com")
assert onlyoffice_collaboration_service._validate_result_url(
"https://ctms.example.com/onlyoffice/cache/result.xlsx?token=signed"
) == "http://onlyoffice/cache/result.xlsx?token=signed"
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "http://localhost:8888")
for value in (
"http://backend:8000/internal/file",
"http://onlyoffice.evil.example/cache/result.docx",
@@ -316,6 +328,157 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m
onlyoffice_collaboration_service._validate_result_url(value)
@pytest.mark.asyncio
async def test_forgotten_document_command_downloads_and_validates_server_backup(monkeypatch):
key = "ctms-collab-forgotten-key"
recovered = collaboration_service.blank_file_bytes("cell")
request = {}
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"error": 0, "key": key, "url": "http://onlyoffice/cache/forgotten.xlsx"}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, url, *, params, json):
request.update(url=url, params=params, body=json)
return FakeResponse()
monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient())
download = AsyncMock(return_value=recovered)
monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", download)
result = await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell")
assert result == recovered
assert request["url"] == "http://onlyoffice/command"
assert request["params"] == {"shardkey": key}
assert jwt.decode(
request["body"]["token"], settings.ONLYOFFICE_JWT_SECRET, algorithms=["HS256"]
) == {"c": "getForgotten", "key": key}
download.assert_awaited_once_with("http://onlyoffice/cache/forgotten.xlsx")
@pytest.mark.asyncio
async def test_forgotten_document_command_rejects_a_damaged_backup(monkeypatch):
key = "ctms-collab-damaged-key"
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"error": 0, "key": key, "url": "http://onlyoffice/cache/damaged.xlsx"}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, *_args, **_kwargs):
return FakeResponse()
monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient())
monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", AsyncMock(return_value=b"broken"))
with pytest.raises(HTTPException) as error:
await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell")
assert error.value.status_code == 502
@pytest.mark.asyncio
async def test_document_server_info_command_returns_unique_live_users(monkeypatch):
key = "ctms-collab-live-key"
request = {}
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"error": 0, "key": key, "users": ["user-1", "user-1", "user-2"]}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, url, *, params, json):
request.update(url=url, params=params, body=json)
return FakeResponse()
monkeypatch.setattr(
onlyoffice_collaboration_service.httpx,
"AsyncClient",
lambda **_kwargs: FakeClient(),
)
users = await onlyoffice_collaboration_service._document_server_users(key)
assert users == ["user-1", "user-2"]
assert request["url"] == "http://onlyoffice/command"
assert request["params"] == {"shardkey": key}
assert jwt.decode(
request["body"]["token"], settings.ONLYOFFICE_JWT_SECRET, algorithms=["HS256"]
) == {"c": "info", "key": key}
@pytest.mark.asyncio
async def test_document_server_info_command_treats_unknown_key_as_no_live_users(monkeypatch):
class FakeResponse:
status_code = 200
@staticmethod
def json():
return {"error": 1}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def post(self, *_args, **_kwargs):
return FakeResponse()
monkeypatch.setattr(
onlyoffice_collaboration_service.httpx,
"AsyncClient",
lambda **_kwargs: FakeClient(),
)
assert await onlyoffice_collaboration_service._document_server_users("retired-key") == []
@pytest.mark.asyncio
async def test_live_editing_session_check_filters_stale_active_rows(monkeypatch):
rows = SimpleNamespace(all=lambda: [
("live-key", "正在编辑.xlsx"),
("stale-key", "陈旧记录.xlsx"),
])
db = SimpleNamespace(execute=AsyncMock(return_value=rows))
lookup = AsyncMock(side_effect=[["user-1", "user-2"], []])
monkeypatch.setattr(onlyoffice_collaboration_service, "_document_server_users", lookup)
active = await onlyoffice_collaboration_service.list_live_editing_sessions(db)
assert active == [("正在编辑.xlsx", 2)]
assert lookup.await_args_list == [call("live-key"), call("stale-key")]
@pytest.mark.asyncio
async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path):
user_id = uuid.uuid4()
@@ -1016,6 +1179,31 @@ async def test_callback_error_status_is_recorded_and_acknowledged():
assert db.added[0].result == "ERROR"
@pytest.mark.asyncio
async def test_force_save_error_keeps_the_live_session_active():
session = SimpleNamespace(
id=uuid.uuid4(),
file_id=uuid.uuid4(),
document_key="ctms-collab-force-save-error-key",
generation=1,
status="ACTIVE",
active_users=None,
last_callback_at=None,
)
item = SimpleNamespace(id=session.file_id, generation=1)
db = _CallbackDb(session, item)
result = await onlyoffice_collaboration_service.process_callback(
db,
session.id,
CollaborationCallbackPayload(key=session.document_key, status=7),
)
assert result == {"error": 0}
assert session.status == "ACTIVE"
assert db.added[0].result == "ERROR"
@pytest.mark.asyncio
async def test_force_save_updates_session_recovery_revision(monkeypatch):
session = SimpleNamespace(
+569
View File
@@ -0,0 +1,569 @@
import io
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import DateTime, TypeDecorator
from conftest import _create_test_engine
from app.api.v1.ledgers import router
from app.core.config import settings
from app.core.deps import get_current_user, get_db_session
from app.models.collaboration import CollaborationCallbackReceipt, CollaborationFile, CollaborationRevision, CollaborationSession, CollaborationShareLink
from app.models.user import User, UserStatus
from app.schemas.collaboration import CollaborationCallbackPayload
from app.schemas.ledger import LedgerMemberUpsert, LedgerSettings
from app.services import collaboration_service as collaboration
from app.services import ledger_service as ledgers, onlyoffice_collaboration_service as office, onlyoffice_service
class SQLiteUTCDateTime(TypeDecorator):
"""Preserve the PostgreSQL UTC timestamp contract in the SQLite fixture."""
impl = DateTime(timezone=True)
cache_ok = True
def process_result_value(self, value, dialect):
return value.replace(tzinfo=timezone.utc) if value and value.tzinfo is None else value
@pytest_asyncio.fixture
async def env(monkeypatch, tmp_path):
for column in CollaborationShareLink.__table__.columns:
if isinstance(column.type, DateTime) and column.type.timezone:
monkeypatch.setattr(column, "type", SQLiteUTCDateTime())
engine = await _create_test_engine()
monkeypatch.setattr(collaboration, "COLLABORATION_ROOT", tmp_path)
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "ledger-test-secret-long-enough-for-tests")
monkeypatch.setattr(onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(office, "_document_server_users", AsyncMock(return_value=["live-user"]))
async with AsyncSession(engine, expire_on_commit=False) as db:
users = [User(id=uuid.uuid4(), email=f"ledger-{i}@example.com", password_hash="hash",
full_name=f"Ledger user {i}", clinical_department="test", is_admin=i == 0,
status=UserStatus.ACTIVE) for i in range(4)]
db.add_all(users)
await db.commit()
initial = await ledgers.initialize_ledgers(db, users[0])
app = FastAPI()
app.include_router(router, prefix="/api/v1/ledgers")
identity = SimpleNamespace(user=users[0])
app.dependency_overrides[get_current_user] = lambda: identity.user
app.dependency_overrides[get_db_session] = lambda: db
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
yield SimpleNamespace(db=db, admin=users[0], editor=users[1], viewer=users[2], outsider=users[3],
initial=initial, identity=identity, client=client, app=app)
await engine.dispose()
async def grant(env, user, role, index=0):
item = await ledgers.get_ledger(env.db, env.initial[index].id, env.admin, manage=True, lock=True)
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=user.id, role=role), env.admin)
return item
@pytest.mark.asyncio
async def test_initialization_preserves_both_uploaded_templates_and_is_idempotent(env):
repeated = await ledgers.initialize_ledgers(env.db, env.admin)
assert [r.id for r in repeated] == [r.id for r in env.initial]
for result, (_, title, filename, _) in zip(repeated, ledgers.LEDGER_TEMPLATES):
item = await env.db.get(CollaborationFile, result.id)
assert item.study_id is None and item.folder_id is None and item.scope == "LEDGER"
assert item.title == title
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(ledgers.TEMPLATE_ROOT / filename) as original, zipfile.ZipFile(revision.file_uri) as saved:
assert original.namelist() == saved.namelist()
for part in original.namelist():
if part != "xl/workbook.xml":
assert original.read(part) == saved.read(part)
assert b'lockStructure="1"' in saved.read("xl/workbook.xml")
assert "ledgers" in Path(revision.file_uri).parts
@pytest.mark.asyncio
async def test_failed_ledger_session_recovers_server_backup_under_a_new_document_key(env, monkeypatch):
item = await env.db.get(CollaborationFile, env.initial[0].id)
first = await office.build_editor_config(env.db, item, env.admin)
failed = await env.db.scalar(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
CollaborationSession.generation == item.generation,
))
failed.status = "ERROR"
await env.db.commit()
current = await env.db.get(CollaborationRevision, item.current_revision_id)
recovered_buffer = io.BytesIO(Path(current.file_uri).read_bytes())
with zipfile.ZipFile(recovered_buffer, "a") as package:
package.comment = b"document-server-recovery"
recovery = AsyncMock(return_value=recovered_buffer.getvalue())
monkeypatch.setattr(office, "_recover_forgotten_content", recovery)
reopened = await office.build_editor_config(env.db, item, env.admin)
await env.db.refresh(item)
await env.db.refresh(failed)
sessions = (await env.db.scalars(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
).order_by(CollaborationSession.generation))).all()
recovered_revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert first.config["document"]["key"] != reopened.config["document"]["key"]
assert item.generation == 2
assert failed.status == "RECOVERED"
assert [session.generation for session in sessions] == [1, 2]
assert recovered_revision.source == "SERVER_RECOVERY"
assert recovered_revision.change_summary == "自动恢复在线文档服务器备份"
recovery.assert_awaited_once_with(failed.document_key, "cell")
@pytest.mark.asyncio
async def test_closed_ledger_session_starts_a_new_key_instead_of_reopening_server_cache(env, monkeypatch):
item = await env.db.get(CollaborationFile, env.initial[0].id)
first = await office.build_editor_config(env.db, item, env.admin)
closed = await env.db.scalar(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
CollaborationSession.generation == item.generation,
))
await office.process_callback(env.db, closed.id, CollaborationCallbackPayload(
key=closed.document_key,
status=4,
))
recovery = AsyncMock(return_value=None)
monkeypatch.setattr(office, "_recover_forgotten_content", recovery)
reopened = await office.build_editor_config(env.db, item, env.admin)
await env.db.refresh(item)
await env.db.refresh(closed)
sessions = (await env.db.scalars(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
).order_by(CollaborationSession.generation))).all()
assert first.config["document"]["key"] != reopened.config["document"]["key"]
assert item.generation == 2
assert closed.status == "CLOSED"
assert [session.generation for session in sessions] == [1, 2]
recovery.assert_not_awaited()
@pytest.mark.asyncio
async def test_stale_active_ledger_session_starts_a_new_key_when_document_server_has_no_users(
env, monkeypatch
):
item = await env.db.get(CollaborationFile, env.initial[0].id)
first = await office.build_editor_config(env.db, item, env.admin)
stale = await env.db.scalar(select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
CollaborationSession.generation == item.generation,
))
stale.last_callback_at = datetime.now(timezone.utc)
await env.db.commit()
live_users = AsyncMock(return_value=[])
recovery = AsyncMock(return_value=None)
monkeypatch.setattr(office, "_document_server_users", live_users)
monkeypatch.setattr(office, "_recover_forgotten_content", recovery)
reopened = await office.build_editor_config(env.db, item, env.admin)
await env.db.refresh(item)
await env.db.refresh(stale)
assert first.config["document"]["key"] != reopened.config["document"]["key"]
assert item.generation == 2
assert stale.status == "CLOSED"
live_users.assert_awaited_once_with(stale.document_key)
recovery.assert_awaited_once_with(stale.document_key, "cell")
@pytest.mark.asyncio
async def test_new_active_session_is_reused_during_browser_connection_grace(env, monkeypatch):
item = await env.db.get(CollaborationFile, env.initial[0].id)
first = await office.build_editor_config(env.db, item, env.admin)
live_users = AsyncMock(return_value=[])
monkeypatch.setattr(office, "_document_server_users", live_users)
repeated = await office.build_editor_config(env.db, item, env.admin)
assert first.config["document"]["key"] == repeated.config["document"]["key"]
live_users.assert_not_awaited()
@pytest.mark.asyncio
async def test_account_grants_are_independent_and_all_file_routes_require_access(env):
await grant(env, env.editor, "EDITOR")
env.identity.user = env.editor
response = await env.client.get("/api/v1/ledgers")
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
assert [row["id"] for row in response.json()] == [str(env.initial[0].id)]
assert response.json()[0]["can_edit"] is True
other = env.initial[1].id
for suffix in ["", "/editor-config", "/revisions", "/members", "/candidates"]:
assert (await env.client.get(f"/api/v1/ledgers/{other}{suffix}")).status_code == 404
assert (await env.client.post("/api/v1/ledgers/initialize")).status_code == 403
env.identity.user = env.outsider
assert (await env.client.get("/api/v1/ledgers")).json() == []
env.app.dependency_overrides.pop(get_current_user)
assert (await env.client.get("/api/v1/ledgers")).status_code == 401
@pytest.mark.asyncio
async def test_editor_and_viewer_share_session_but_receive_distinct_signed_permissions(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
editor = await office.build_editor_config(env.db, item, env.editor)
viewer = await office.build_editor_config(env.db, item, env.viewer)
assert editor.config["document"]["key"] == viewer.config["document"]["key"]
assert editor.access_mode == "edit" and viewer.access_mode == "view"
assert editor.config["document"]["permissions"]["edit"] is True
assert viewer.config["document"]["permissions"]["edit"] is False
assert not editor.can_save_as and not editor.can_request_edit and not editor.can_download
env.identity.user = env.editor
assert (await env.client.get(f"/api/v1/ledgers/{item.id}/members")).status_code == 403
assert (await env.client.post(f"/api/v1/ledgers/{item.id}/downloads", json={"file_type": "xlsx"})).status_code == 403
with pytest.raises(HTTPException) as error:
await collaboration.restore_revision(env.db, item, item.current_revision_id, env.editor, None)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_neither_editors_nor_admins_can_delete_ledger_or_revision(env):
item = await grant(env, env.editor, "EDITOR")
for user in [env.admin, env.editor]:
env.identity.user = user
assert (await env.client.delete(f"/api/v1/ledgers/{item.id}")).status_code == 405
with pytest.raises(HTTPException) as error:
await collaboration.move_to_trash(env.db, item, user)
assert error.value.status_code == 403
with pytest.raises(HTTPException) as error:
await collaboration.delete_revision(env.db, item, item.current_revision_id, user)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_revocation_invalidates_old_callback_before_result_download(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
config = await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
baseline = item.current_revision_id
download = AsyncMock(return_value=b"should never be downloaded")
monkeypatch.setattr(office, "_download_result", download)
await ledgers.remove_member(env.db, item, env.editor.id, env.admin)
result = await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=config.config["document"]["key"], status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
))
assert result == {"error": 0} and item.current_revision_id == baseline
download.assert_not_awaited()
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
assert receipt.result == "STALE"
@pytest.mark.asyncio
async def test_saved_callback_is_versioned_and_duplicate_is_idempotent(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
config = await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
monkeypatch.setattr(office, "_download_result", AsyncMock(return_value=collaboration.blank_file_bytes("cell")))
payload = CollaborationCallbackPayload(key=config.config["document"]["key"], status=6,
url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)])
await office.process_callback(env.db, session.id, payload)
saved = item.current_revision_id
await office.process_callback(env.db, session.id, payload)
assert item.current_revision_id == saved
assert len(await collaboration.list_revisions(env.db, item)) == 2
@pytest.mark.asyncio
async def test_archive_disables_edit_and_stale_settings_are_rejected(env):
item = await grant(env, env.editor, "EDITOR")
payload = LedgerSettings(title=item.title, description="Archived", status="ARCHIVED", owner_id=item.owner_id,
allow_export=False, allow_sheet_structure_edit=False, generation=item.generation)
await ledgers.update_settings(env.db, item, payload, env.admin)
assert not (await ledgers.read_ledger(env.db, item, env.editor)).can_edit
assert (await office.build_editor_config(env.db, item, env.editor)).access_mode == "view"
with pytest.raises(HTTPException) as error:
await ledgers.update_settings(env.db, item, payload, env.admin)
assert error.value.status_code == 409
@pytest.mark.asyncio
async def test_import_and_restore_keep_previous_versions_and_workbook_protection(env):
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
first = item.current_revision_id
upload = UploadFile(filename="import.xlsx", file=io.BytesIO(collaboration.blank_file_bytes("cell")))
await ledgers.import_content(env.db, item, upload, item.generation, env.admin)
second = item.current_revision_id
await ledgers.restore_revision(env.db, item, first, item.generation, env.admin)
assert item.current_revision_id not in {first, second}
assert len(await collaboration.list_revisions(env.db, item)) == 3
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(revision.file_uri) as package:
assert b'lockStructure="1"' in package.read("xl/workbook.xml")
@pytest.mark.asyncio
async def test_inactive_account_cannot_be_granted_or_save_existing_session(env, monkeypatch):
item = await grant(env, env.editor, "EDITOR")
await office.build_editor_config(env.db, item, env.editor)
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
env.editor.status = UserStatus.DISABLED
await env.db.commit()
with pytest.raises(HTTPException) as error:
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=env.editor.id, role="MANAGER"), env.admin)
assert error.value.status_code == 422
download = AsyncMock()
monkeypatch.setattr(office, "_download_result", download)
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
))
download.assert_not_awaited()
@pytest.mark.asyncio
async def test_restore_respects_current_unlocked_workbook_policy(env):
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
initial = item.current_revision_id
payload = LedgerSettings(title=item.title, status="ACTIVE", owner_id=item.owner_id,
allow_sheet_structure_edit=True, generation=item.generation)
await ledgers.update_settings(env.db, item, payload, env.admin)
await ledgers.import_content(env.db, item, UploadFile(filename="blank.xlsx", file=io.BytesIO(
collaboration.blank_file_bytes("cell"))), item.generation, env.admin)
await ledgers.restore_revision(env.db, item, initial, item.generation, env.admin)
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
with zipfile.ZipFile(revision.file_uri) as package:
assert b"workbookProtection" not in package.read("xl/workbook.xml")
@pytest.mark.asyncio
async def test_shared_access_uses_global_candidates_and_protects_implicit_managers(env):
item = await grant(env, env.editor, "EDITOR")
base = f"/api/v1/ledgers/{item.id}"
members = (await env.client.get(f"{base}/members")).json()
owner = next(row for row in members if row["user_id"] == str(env.admin.id))
assert owner["protected_label"] == "所有者" and owner["role"] == "MANAGER"
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
candidates = (await env.client.get(f"{base}/candidates")).json()
assert {row["user_id"] for row in candidates} == {str(user.id) for user in [env.admin, env.editor, env.viewer, env.outsider]}
assert all(row["can_be_manager"] and row["can_be_editor"] for row in candidates)
original_title = item.title
result = await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": False})
assert result.status_code == 200 and not result.json()["allow_edit_request"]
assert result.json()["title"] == original_title and not result.json()["allow_export"]
for actor, code in [(env.editor, 403), (env.outsider, 404)]:
env.identity.user = actor
for suffix in ["share-link", "edit-requests", "members", "candidates"]:
assert (await env.client.get(f"{base}/{suffix}")).status_code == code
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})).status_code == code
assert (await env.client.put(f"{base}/share-link", json={"enabled": True})).status_code == code
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == code
@pytest.mark.asyncio
async def test_viewer_edit_request_is_deduplicated_and_approval_grants_editor(env):
item = await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
env.identity.user = env.viewer
assert (await env.client.get(base)).json()["can_request_edit"]
request = await env.client.post(f"{base}/edit-requests")
assert request.status_code == 200
request_id = request.json()["id"]
assert (await env.client.post(f"{base}/edit-requests")).json()["id"] == request_id
read = (await env.client.get(base)).json()
assert read["edit_request_status"] == "PENDING" and not read["can_request_edit"]
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 403
generation = item.generation
env.identity.user = env.admin
assert len((await env.client.get(f"{base}/edit-requests")).json()) == 1
result = await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})
assert result.status_code == 200 and result.json()["status"] == "APPROVED"
env.identity.user = env.viewer
read = (await env.client.get(base)).json()
assert read["role"] == "EDITOR" and read["can_edit"] and read["generation"] == generation + 1
assert not read["can_request_edit"]
@pytest.mark.asyncio
async def test_revoked_requester_cannot_regain_access_through_pending_approval(env):
item = await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
env.identity.user = env.viewer
request_id = (await env.client.post(f"{base}/edit-requests")).json()["id"]
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
env.identity.user = env.admin
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 422
assert not await collaboration.can_edit_file(env.db, item, env.viewer)
@pytest.mark.asyncio
async def test_ownership_transfer_accepts_system_accounts_and_keeps_previous_owner_access(env):
item = await grant(env, env.editor, "MANAGER")
base = f"/api/v1/ledgers/{item.id}"
env.identity.user = env.editor
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 403
env.identity.user = env.admin
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 200
env.identity.user = env.viewer
result = await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.outsider.id)})
assert result.status_code == 200 and result.json()["owner_id"] == str(env.outsider.id)
assert result.json()["can_manage"] and not result.json()["can_transfer_ownership"]
assert item.study_id is None
members = (await env.client.get(f"{base}/members")).json()
admin = next(row for row in members if row["user_id"] == str(env.admin.id))
assert admin["protected_label"] == "系统管理员"
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
async def shared_ledger(env, *, mode="EDIT", password=None):
from app.models.collaboration import CollaborationShareLink
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin)
response = await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={
"enabled": True, "access_mode": mode, "expiry_policy": "SEVEN_DAYS",
"password_mode": "SET" if password else "KEEP", **({"password": password} if password else {}),
})
assert response.status_code == 200
link = await env.db.get(CollaborationShareLink, uuid.UUID(response.json()["id"]))
return item, link, response.json()["share_token"]
@pytest.mark.asyncio
async def test_ledger_share_defaults_closed_and_password_change_revokes_old_credentials(env):
from app.services import collaboration_share_service as shares
base = f"/api/v1/ledgers/{env.initial[0].id}"
initial = (await env.client.get(f"{base}/share-link")).json()
assert not initial["enabled"] and initial["share_token"] is None
item, link, token = await shared_ledger(env, password="test-password")
grant_token = await shares.verify_share_password(env.db, token, "test-password")
shares.validate_access_grant(link, grant_token.access_token)
with pytest.raises(HTTPException) as wrong:
await shares.verify_share_password(env.db, token, "incorrect")
assert wrong.value.status_code == 401
generation = item.generation
await env.client.put(f"{base}/share-link", json={"enabled": True, "password_mode": "SET", "password": "new-password"})
assert item.generation == generation + 1
with pytest.raises(HTTPException):
await shares.resolve_active_share(env.db, token)
with pytest.raises(HTTPException):
shares.validate_access_grant(link, grant_token.access_token)
@pytest.mark.asyncio
async def test_anonymous_ledger_save_keeps_share_identity_and_close_blocks_stale_callback(env, monkeypatch):
item, link, _ = await shared_ledger(env)
config = await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
assert config.access_mode == "edit" and not config.can_save_as and not config.can_download
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
download = AsyncMock(return_value=collaboration.blank_file_bytes("cell"))
monkeypatch.setattr(office, "_download_result", download)
# ONLYOFFICE can send a final save with an empty user list. It must not become an owner save.
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/first.xlsx", users=[],
))
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert revision.source == "SHARE_FORCE_SAVE" and revision.created_by is None
saved = item.current_revision_id
await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={"enabled": False})
download.reset_mock()
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=2, url="http://onlyoffice/after-close.xlsx", users=[],
))
download.assert_not_awaited()
assert item.current_revision_id == saved
with pytest.raises(HTTPException):
await office.build_shared_editor_config(env.db, item, link, client_id="new-client", display_name="访客")
@pytest.mark.asyncio
@pytest.mark.parametrize("mode,expired", [("VIEW", False), ("EDIT", True)])
async def test_anonymous_view_or_expired_link_cannot_save_even_without_callback_users(env, monkeypatch, mode, expired):
from datetime import datetime, timedelta, timezone
item, link, _ = await shared_ledger(env, mode=mode)
await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
if expired:
link.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
await env.db.commit()
saved = item.current_revision_id
download = AsyncMock()
monkeypatch.setattr(office, "_download_result", download)
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
key=session.document_key, status=6, url="http://onlyoffice/denied.xlsx", users=[],
))
download.assert_not_awaited()
assert item.current_revision_id == saved
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
assert receipt.result == "ACCESS_REVOKED"
@pytest.mark.asyncio
async def test_ledger_uses_existing_public_share_http_flow_without_a_project(env):
from app.api.v1.collaboration import public_router
env.app.include_router(public_router, prefix="/api/v1/collaboration/shares")
item, _, token = await shared_ledger(env, password="share-password")
env.app.dependency_overrides.pop(get_current_user)
headers = {"X-CTMS-Share-Token": token}
base = "/api/v1/collaboration/shares"
metadata = await env.client.get(f"{base}/metadata", headers=headers)
assert metadata.status_code == 200 and metadata.json()["requires_password"]
body = {"client_id": "test-client", "display_name": "访客"}
assert (await env.client.post(f"{base}/editor-config", headers=headers, json=body)).status_code == 401
access = await env.client.post(f"{base}/access", headers=headers, json={"password": "share-password"})
assert access.status_code == 200
config = await env.client.post(f"{base}/editor-config", headers=headers,
json={**body, "access_token": access.json()["access_token"]})
assert config.status_code == 200 and config.headers["cache-control"] == "no-store"
assert config.json()["file_name"] == item.title and config.json()["access_mode"] == "edit"
assert not config.json()["can_save_as"] and item.study_id is None
@pytest.mark.asyncio
async def test_revision_names_reuse_collaboration_metadata_without_replacing_content(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
revision_id = item.current_revision_id
generation = item.generation
base = f"/api/v1/ledgers/{item.id}/revisions/{revision_id}"
env.identity.user = env.editor
result = await env.client.patch(base, json={"change_summary": "审核提交版"})
assert result.status_code == 200 and result.json()["change_summary"] == "审核提交版"
assert item.current_revision_id == revision_id and item.generation == generation
assert len(await collaboration.list_revisions(env.db, item)) == 1
for actor, status in [(env.viewer, 403), (env.outsider, 404)]:
env.identity.user = actor
assert (await env.client.patch(base, json={"change_summary": "不应保存"})).status_code == status
env.identity.user = env.editor
other_revision = env.initial[1].current_revision_id
assert (await env.client.patch(f"/api/v1/ledgers/{item.id}/revisions/{other_revision}", json={"change_summary": "跨台账"})).status_code == 404
env.identity.user = env.admin
await env.client.patch(f"/api/v1/ledgers/{item.id}", json={
"title": item.title, "status": "ARCHIVED", "owner_id": str(item.owner_id), "generation": item.generation,
})
assert (await env.client.patch(base, json={"change_summary": "归档后"})).status_code == 403
@pytest.mark.asyncio
async def test_history_export_uses_live_export_permission_and_checks_revision_belongs_to_ledger(env):
item = await grant(env, env.editor, "EDITOR")
await grant(env, env.viewer, "VIEWER")
base = f"/api/v1/ledgers/{item.id}"
path = f"{base}/revisions/{item.current_revision_id}/download"
for actor in [env.editor, env.viewer]:
env.identity.user = actor
assert (await env.client.get(path)).status_code == 403
env.identity.user = env.admin
response = await env.client.get(path)
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
assert response.content == Path(revision.file_uri).read_bytes()
await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})
env.identity.user = env.viewer
assert (await env.client.get(path)).status_code == 200
assert (await env.client.get(f"{base}/revisions/{env.initial[1].current_revision_id}/download")).status_code == 404
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
assert (await env.client.get(path)).status_code == 404
env.identity.user = env.outsider
assert (await env.client.get(path)).status_code == 404
+4
View File
@@ -119,6 +119,10 @@ services:
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
ONLYOFFICE_ENABLED: ${ONLYOFFICE_ENABLED:-true}
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?请先运行安装脚本生成 ONLYOFFICE_JWT_SECRET}
ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice}
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:?请先运行安装脚本生成 ONLYOFFICE_INSTANCE_ID}
depends_on:
db:
condition: service_healthy
+48
View File
@@ -0,0 +1,48 @@
# 全局协作台账(第一版)
台账与项目平级,从网页端、桌面端工作台进入 `/ledgers/:fileId`,不依赖项目 ID、项目成员或项目权限。进入台账时清理当前项目上下文。
## 初始模板
系统管理员在工作台点击“初始化两本台账”,幂等创建:
- 医学事务部合同台账明细表:华邦云合同编号、医学事务部合同编号、项目代码、合同内容、备注。
- 医学事务部临床运营项目编号:项目编号、产品名称、产品代码、临床试验内容、生效日期。
模板来自用户提供的同名 XLSX,保存在 `backend/app/templates/ledgers/`;原文件只有表头和空白格式行,无业务记录。初始工作表、列宽、单元格样式保持原样,仅应用现有工作簿结构保护。管理员可修改名称、说明或导入新的 XLSX;导入产生新修订并保留原版本,不改变台账 ID 和授权。
## 权限
每本台账独立授予系统内有效账号 `VIEWER`、`EDITOR`、`MANAGER`。所有者及系统管理员具有管理权限,其隐含权限显示在授权列表中且不能移除;所有者或系统管理员可将所有权转让给有效系统账号,原所有者保留管理权限。未授权账号看不到台账,直接访问返回 404。无项目成员身份的有效账号也可获得台账授权。
查看者只读,编辑者可编辑内容,管理者可修改设置、维护授权、归档、导入和恢复历史版本。文件与历史版本不提供删除 API,协作底层删除函数同时拒绝台账删除。归档后所有账号均以只读模式打开。项目协作编辑者原有的历史恢复权限不扩展到台账。
“不可删除”指整个台账文件和历史版本。单元格、行和记录允许编辑及清空;工作簿结构保护控制工作表增删,不提供记录级防删除或单元格审计。文件历史版本不等同于逐项业务审计。
查看者/编辑者及匿名访问者的下载、打印和复制由台账导出开关控制;管理者始终可导出。台账不支持另存为项目文件。
台账卡片的“台账管理”提供三个并列入口,各自打开独立弹窗,不通过 Tab 或弹窗内链接跳转:
- 信息维护:`LedgerInfoDialog.vue` 维护名称、用途说明和使用/归档状态,展示所有者。
- 访问与权限:与项目共同使用 `CollaborationAccessDialog.vue`,通过 `collaborationAccess.ts` 适配各自 API;包含账号授权、协作者选择、匿名访问、权限设置、编辑申请审批及所有权转让。项目选择器继续使用项目成员;台账选择器使用系统有效账号,不需要项目上下文。
- 历史版本:`LedgerHistoryDialog.vue` 作为台账入口,直接复用项目的 `CollaborationHistoryDialog.vue`。按日期分组显示时间、更新者、大小、来源、版本名称和当前版本,支持仅看已命名版本、来源筛选、手动刷新和延迟保存回调刷新。编辑者及管理者可命名版本,导入及恢复限使用中台账的管理者;台账历史始终不提供删除操作。预览复用完整的 Office 只读预览页,通过不含项目 ID 的台账路由打开;另存为按当前导出权限保存本地 XLSX,项目历史另存为仍创建项目工作区文件。
所有弹窗挂载到页面根节点。信息维护只加载台账元数据,历史版本单独加载版本列表;关闭后重新打开会刷新数据。
匿名访问默认关闭。管理员可以使用与项目相同的只读/编辑模式、有效期、密码和复制链接功能,复用现有公开分享入口、密码校验和签名凭证。台账分享配置变化会更新链接版本和文件代次,之前复制的台账链接及密码凭证随之失效,需复制新链接。已授权查看者可在开放申请时提交编辑申请;管理者在访问与权限中审批,台账申请不会写入项目通知。
## 协作与保存边界
复用 `CollaborationWorkspace.vue`、ONLYOFFICE 配置、会话、签名回调和不可变内容修订。`collaboration_files.scope` 显式区分 `PROJECT` 与 `LEDGER`;数据库约束保证台账的 `study_id`/`folder_id` 为空且只允许 XLSX。台账内容保存在上传目录的 `collaboration/ledgers/<file-id>/`,不会进入项目目录。
设置/授权变化、编辑申请获批、所有权转让、匿名访问变更、导入、恢复推进文件代次;旧代次保存回调不覆盖新内容。台账回调在文件锁内复核代次、归档状态与保存账号有效编辑权限;匿名回调另行检查链接仍启用、未过期且允许编辑,不回退为所有者身份。客户端每 15 秒及窗口重新获得焦点时复核账号访问状态,权限/代次变化或验证失败时卸载编辑器并提示重新打开。台账 API 禁用缓存和自动网络重试,不使用离线存储。
信息维护、导入及历史恢复在更新前提示协作者先保存;访问与权限沿用项目界面的即时保存交互。变更代次会使原会话未保存内容无法继续写入,权限变更前应先保存内容。授权撤销立即阻断旧代次保存,已显示的账号编辑页面会在下一次客户端检查时关闭。历史预览沿用已有短时签名内部只读内容链路。
## 迁移与验证
迁移 `20260903_01` 位于 `20260716_05` 之后;已有文件默认为 `PROJECT`。首次创建台账后,降级迁移主动拒绝删除台账数据,应保留数据并前向修复。
验证覆盖模板逐部件保真、初始化幂等、独立账号授权、未授权 API、查看/编辑配置、文件和历史禁止删除、撤权/停用后的保存拒绝、回调幂等、归档、导入与恢复,以及历史命名和导出的角色限制、跨台账隔离。前端覆盖无项目入口、初始化、加载失败重试、全局编辑器及权限变化卸载,以及共享历史的筛选、命名、恢复、另存为与预览。
本次未新增 Tauri command、capability、CSP 或底层存储能力,文件导入与下载使用现有运行时入口;现有桌面发布检查脚本适用。
+11 -5
View File
@@ -137,7 +137,9 @@ bash scripts/install.sh release --base-url https://ctms.example.com
可选参数:
```text
--base-url <url> 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888,release 必填或交互输入。
--base-url <url> 对外访问基址,同时用于健康检查及 ONLYOFFICE 保存地址校验。dev/main 默认
http://127.0.0.1:8888;已有环境优先复用 .env 的 CTMS_BASE_URL,
首次 release 安装必填或交互输入。只接受协议与主机组成的 origin。
--yes 跳过交互确认,适合自动化执行。
--skip-build 跳过镜像构建,仍会执行 docker compose up -d。
--skip-migrate 跳过 alembic upgrade head。
@@ -147,12 +149,14 @@ bash scripts/install.sh release --base-url https://ctms.example.com
```text
1. 检查 docker、docker compose、openssl、curl。
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建。
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建;将
`CTMS_BASE_URL` 与 `FRONTEND_PUBLIC_URL` 同步为 `--base-url`,确保公开缓存 URL 可通过保存回调校验。
3. 所有环境自动生成独立的 ONLYOFFICE JWT 与稳定实例标识;新建 main/release 的 .env 时同时生成登录 JWT 和 RSA 私钥。
4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。
5. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
6. 执行 docker compose run --rm backend python -m alembic upgrade head。
7. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
5. 在重启服务前通过 ONLYOFFICE `info` 命令检查真实在线编辑者;存在在线编辑时中止部署,待用户退出并完成最终保存后重试。
6. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
7. 执行 docker compose run --rm backend python -m alembic upgrade head。
8. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
```
更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。
@@ -193,6 +197,8 @@ cat > .env <<'EOF'
COMPOSE_PROJECT_NAME=ctms_dev
ENV=development
JWT_SECRET_KEY=dev-secret
CTMS_BASE_URL=http://127.0.0.1:8888
FRONTEND_PUBLIC_URL=http://127.0.0.1:8888
LOGIN_RSA_KEY_ID=default
LOGIN_RSA_PRIVATE_KEY=
ONLYOFFICE_ENABLED=true
+9 -1
View File
@@ -28,7 +28,15 @@ bash scripts/onlyoffice-dev-up.sh --rotate-secret
轮换会强制重建相关容器,已签发但尚未使用的短时预览配置会立即失效。生产环境不使用该自动生成流程,仍必须由部署密钥管理系统显式提供密钥。
可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像只增加 Noto Sans CJK/Noto Serif CJK,不包含微软字体。
可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像只额外增加 Noto Sans CJK/Noto Serif CJK;其他字体仅继承自获准使用的上游镜像。
## 字体许可边界
项目只在派生镜像中额外安装可再分发的 Noto CJK 字体,不打包、复制、下载或自动注入部署方提供的宋体、Times New Roman 等专有字体。文档指定了未安装字体时,ONLYOFFICE 会使用可用字体替代,版式可能产生差异。
部署方不得将来源不明或没有服务器部署及再分发许可的字体放入项目目录、Docker 构建上下文或 `onlyoffice_data` 卷。确需增加其他字体时,应先取得相应授权并独立完成合规评估;当前安装和更新脚本不提供专有字体注入入口。
`ONLYOFFICE_IMAGE` 上游基础镜像可能自带其他字体;其内容和许可不属于项目字体注入流程。分发派生镜像前仍应单独审查获准使用的上游镜像,不能以本项目已移除自定义字体作为上游字体合规结论。
## 配置边界
+5
View File
@@ -45,8 +45,12 @@
5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。
6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。
状态 2、3 或 4 的最终回调结束当前编辑生命周期,已关闭会话不得重新激活或复用原 `document.key`。数据库仍标记为 `ACTIVE`、但已接收过回调且 Document Server 的 `info` 命令确认没有在线编辑者时,同样按中断会话退役;刚签发但尚未收到首次连接回调的 key 保留 15 秒连接宽限,避免并发配置请求误判。对于保存错误或这类中断会话,再次打开文件时后端通过 ONLYOFFICE `getForgotten` 命令检查服务器是否保留了备份副本;存在备份时,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。正常的无修改关闭只推进代次,不做不必要的备份查询。如果文档服务器没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。
内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。
安装和更新流程在重启服务前使用 ONLYOFFICE `info` 命令核实数据库中 `ACTIVE` 会话的真实在线用户。仍有编辑者时部署会在服务重启前终止,并只显示文件名和在线人数;应通知用户退出编辑器并等待最终保存后重试。数据库中的陈旧 `ACTIVE` 记录若已不在 Document Server 中存在,不会误阻塞部署。不要通过强制重启或删除 ONLYOFFICE 数据卷绕过检查。
## 本地开发
标准开发安装会直接启动 ONLYOFFICE:
@@ -60,6 +64,7 @@ bash scripts/install.sh dev
关键配置:
- `ONLYOFFICE_ENABLED`
- `FRONTEND_PUBLIC_URL`(必须与用户访问 CTMS 的 origin 一致;安装脚本会同步为 `--base-url`)
- `ONLYOFFICE_JWT_SECRET`
- `ONLYOFFICE_INTERNAL_URL`
- `ONLYOFFICE_STORAGE_BASE_URL`
+56
View File
@@ -0,0 +1,56 @@
import {
fetchCollaborationFile, fetchCollaborationMembers, fetchCollaborationCandidates,
upsertCollaborationMember, removeCollaborationMember, fetchCollaborationShareLink,
updateCollaborationShareLink, updateCollaborationFile, fetchCollaborationEditRequests,
resolveCollaborationEditRequest, transferCollaborationOwnership,
} from "./collaboration";
import {
fetchLedger, fetchLedgerMembers, fetchLedgerCandidates, setLedgerMember, removeLedgerMember,
fetchLedgerShareLink, updateLedgerShareLink, updateLedgerAccessSettings, fetchLedgerEditRequests,
resolveLedgerEditRequest, transferLedgerOwnership,
} from "./ledgers";
import type { CollaborationCandidate, CollaborationFile } from "../types/collaboration";
export type CollaborationAccessTarget = { scope: "project"; studyId: string } | { scope: "ledger" };
export type CollaborationAccessFile = Pick<CollaborationFile,
"id" | "title" | "owner_id" | "owner_name" | "file_type" | "status" | "generation" |
"can_manage" | "can_transfer_ownership" | "allow_export" | "allow_edit_request" | "allow_sheet_structure_edit"
>;
export type CollaborationAccessRole = "VIEWER" | "EDITOR" | "MANAGER";
export interface CollaborationAccessMember {
user_id: string;
full_name: string;
email: string;
role: CollaborationAccessRole;
is_active?: boolean;
protected_label?: string | null;
}
export type CollaborationAccessCandidate = CollaborationCandidate;
type AccessPatch = Partial<Pick<CollaborationAccessFile, "allow_export" | "allow_edit_request" | "allow_sheet_structure_edit">>;
export const createCollaborationAccessApi = (target: CollaborationAccessTarget) => {
const project = target.scope === "project" ? target.studyId : null;
return {
file: (id: string) => project !== null ? fetchCollaborationFile(project, id) : fetchLedger(id),
members: (id: string) => project !== null ? fetchCollaborationMembers(project, id) : fetchLedgerMembers(id),
candidates: (id: string) => project !== null ? fetchCollaborationCandidates(project) : fetchLedgerCandidates(id),
setMember: (id: string, payload: { user_id: string; role: CollaborationAccessRole }) => {
if (project === null) return setLedgerMember(id, payload.user_id, payload.role);
if (payload.role === "VIEWER") return Promise.reject(new Error("项目协作不支持此授权角色"));
return upsertCollaborationMember(project, id, { ...payload, role: payload.role });
},
removeMember: (id: string, userId: string) => project !== null
? removeCollaborationMember(project, id, userId) : removeLedgerMember(id, userId),
share: (id: string) => project !== null ? fetchCollaborationShareLink(project, id) : fetchLedgerShareLink(id),
updateShare: (id: string, payload: Parameters<typeof updateCollaborationShareLink>[2]) => project !== null
? updateCollaborationShareLink(project, id, payload) : updateLedgerShareLink(id, payload),
updateFile: (file: CollaborationAccessFile, payload: AccessPatch) => project !== null
? updateCollaborationFile(project, file.id, payload)
: updateLedgerAccessSettings(file.id, { ...payload, generation: file.generation }),
requests: (id: string) => project !== null ? fetchCollaborationEditRequests(project, id) : fetchLedgerEditRequests(id),
resolveRequest: (id: string, requestId: string, status: "APPROVED" | "REJECTED") => project !== null
? resolveCollaborationEditRequest(project, id, requestId, status) : resolveLedgerEditRequest(id, requestId, status),
transfer: (id: string, userId: string) => project !== null
? transferCollaborationOwnership(project, id, userId) : transferLedgerOwnership(id, userId),
};
};
+19
View File
@@ -0,0 +1,19 @@
import type { CollaborationFile } from "../types/collaboration";
import type { CollaborationAccessTarget } from "./collaborationAccess";
import { fetchCollaborationFile, fetchCollaborationRevisions, updateCollaborationRevision, restoreCollaborationRevision } from "./collaboration";
import { fetchLedger, fetchLedgerRevisions, updateLedgerRevision, restoreLedgerRevision } from "./ledgers";
export type CollaborationHistoryFile = Pick<CollaborationFile,
"id" | "title" | "owner_name" | "file_type" | "extension" | "folder_id" | "status" | "generation" |
"current_revision_id" | "current_revision_no" | "current_revision_file_size" | "current_revision_mime_type" |
"current_revision_created_at" | "can_edit" | "can_manage" | "can_export"
>;
export const createCollaborationHistoryApi = (target: CollaborationAccessTarget) => ({
file: (id: string) => target.scope === "ledger" ? fetchLedger(id) : fetchCollaborationFile(target.studyId, id),
revisions: (id: string) => target.scope === "ledger" ? fetchLedgerRevisions(id) : fetchCollaborationRevisions(target.studyId, id),
name: (id: string, revisionId: string, payload: { change_summary: string }) => target.scope === "ledger"
? updateLedgerRevision(id, revisionId, payload) : updateCollaborationRevision(target.studyId, id, revisionId, payload),
restore: (file: CollaborationHistoryFile, revisionId: string) => target.scope === "ledger"
? restoreLedgerRevision(file.id, revisionId, file.generation) : restoreCollaborationRevision(target.studyId, file.id, revisionId),
});
+49
View File
@@ -0,0 +1,49 @@
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./axios";
import type { Ledger, LedgerCandidate, LedgerMember, LedgerRole, LedgerSettings } from "../types/ledger";
import type { CollaborationEditorConfig, CollaborationRevision, CollaborationEditRequest, CollaborationShareLink } from "../types/collaboration";
import type { updateCollaborationShareLink } from "./collaboration";
import type { OnlyOfficePreviewConfig } from "../types/onlyoffice";
const base = "/api/v1/ledgers";
const online = { cache: false, disableRequestDedupe: true, disableNetworkRetry: true, suppressErrorMessage: true } as const;
export const fetchLedgers = () => apiGet<Ledger[]>(base, online);
export const initializeLedgers = () => apiPost<Ledger[]>(`${base}/initialize`, {}, online);
export const fetchLedger = (id: string) => apiGet<Ledger>(`${base}/${id}`, online);
export const updateLedger = (id: string, payload: LedgerSettings) => apiPatch<Ledger>(`${base}/${id}`, payload, online);
export const importLedger = (id: string, file: File, generation: number) => {
const form = new FormData();
form.append("file", file);
form.append("generation", String(generation));
return apiPost<Ledger>(`${base}/${id}/import`, form, online);
};
export const fetchLedgerMembers = (id: string) => apiGet<LedgerMember[]>(`${base}/${id}/members`, online);
export const fetchLedgerCandidates = (id: string, keyword = "") =>
apiGet<LedgerCandidate[]>(`${base}/${id}/candidates`, { ...online, params: { keyword } });
export const setLedgerMember = (id: string, userId: string, role: LedgerRole) =>
apiPut(`${base}/${id}/members`, { user_id: userId, role }, online);
export const removeLedgerMember = (id: string, userId: string) => apiDelete(`${base}/${id}/members/${userId}`, online);
export const updateLedgerAccessSettings = (id: string, payload: {
generation: number; allow_export?: boolean; allow_edit_request?: boolean; allow_sheet_structure_edit?: boolean;
}) => apiPatch<Ledger>(`${base}/${id}/access-settings`, payload, online);
export const fetchLedgerShareLink = (id: string) => apiGet<CollaborationShareLink>(`${base}/${id}/share-link`, online);
export const updateLedgerShareLink = (id: string, payload: Parameters<typeof updateCollaborationShareLink>[2]) =>
apiPut<CollaborationShareLink>(`${base}/${id}/share-link`, payload, online);
export const createLedgerEditRequest = (id: string) => apiPost<CollaborationEditRequest>(`${base}/${id}/edit-requests`, {}, online);
export const fetchLedgerEditRequests = (id: string) => apiGet<CollaborationEditRequest[]>(`${base}/${id}/edit-requests`, online);
export const resolveLedgerEditRequest = (id: string, requestId: string, status: "APPROVED" | "REJECTED") =>
apiPost<CollaborationEditRequest>(`${base}/${id}/edit-requests/${requestId}/resolve`, { status }, online);
export const transferLedgerOwnership = (id: string, userId: string) =>
apiPost<Ledger>(`${base}/${id}/transfer-ownership`, { new_owner_id: userId }, online);
export const fetchLedgerEditorConfig = (id: string) => apiGet<CollaborationEditorConfig>(`${base}/${id}/editor-config`, online);
export const checkLedgerDownload = (id: string, fileType: string) =>
apiPost(`${base}/${id}/downloads`, { file_type: fileType }, online);
export const fetchLedgerRevisions = (id: string) => apiGet<CollaborationRevision[]>(`${base}/${id}/revisions`, online);
export const updateLedgerRevision = (id: string, revisionId: string, payload: { change_summary: string }) =>
apiPatch<CollaborationRevision>(`${base}/${id}/revisions/${revisionId}`, payload, online);
export const downloadLedgerRevision = (id: string, revisionId: string) =>
apiGet<Blob>(`${base}/${id}/revisions/${revisionId}/download`, { ...online, responseType: "blob" });
export const restoreLedgerRevision = (id: string, revisionId: string, generation: number) =>
apiPost<CollaborationRevision>(`${base}/${id}/revisions/${revisionId}/restore`, { generation }, online);
export const fetchLedgerRevisionPreview = (id: string, revisionId: string) =>
apiGet<OnlyOfficePreviewConfig>(`${base}/${id}/revisions/${revisionId}/preview-config`, online);
@@ -0,0 +1,36 @@
<template>
<span class="entry-count-badge">
<span class="entry-count-badge__dot" aria-hidden="true"></span>
<span><slot /></span>
</span>
</template>
<style scoped>
.entry-count-badge {
display: inline-flex;
align-items: center;
flex-shrink: 0;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
font-size: 11.5px;
font-weight: 700;
white-space: nowrap;
}
.entry-count-badge__dot {
width: 5px;
height: 5px;
flex-shrink: 0;
border-radius: 50%;
background: #3b82f6;
}
:global([data-ctms-theme="dark"]) .entry-count-badge {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
</style>
@@ -47,11 +47,12 @@ describe("PermissionIpLocations", () => {
expect(source).not.toContain('all: "近 90 天"');
});
it("keeps fullscreen animation sharp without multiplying large canvas layers", () => {
it("keeps fullscreen animation sharp without dirty-rect artifacts or extra canvas layers", () => {
const source = readSource();
expect(source).toContain('v-if="!fullscreenVisible"');
expect(source).toContain("useDirtyRect: true");
expect(source).toContain("useDirtyRect: false");
expect(source).not.toContain("useDirtyRect: true");
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
expect(source).not.toContain("getDevicePixelRatio() * 1.5");
expect(source).not.toContain("zlevel:");
@@ -223,8 +223,9 @@ const mapLoadError = ref("");
const mapReady = ref(false);
const lastUpdatedAt = ref<Date | null>(null);
const getDevicePixelRatio = () => (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: true };
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: true };
// Animated lines/effectScatter leave cleared rectangles over the geo layer when dirty-rect rendering is enabled.
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: false };
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: false };
const chartUpdateOptions = { notMerge: true };
const chartAutoresize = { throttle: 120 };
const fullscreenVisible = ref(false);
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import AccessDialog from "./CollaborationAccessDialog.vue";
import { createCollaborationAccessApi, type CollaborationAccessFile, type CollaborationAccessTarget } from "../../api/collaborationAccess";
const api = vi.hoisted(() => ({
file: vi.fn(), members: vi.fn(), candidates: vi.fn(), requests: vi.fn(), share: vi.fn(),
setMember: vi.fn(), removeMember: vi.fn(), updateFile: vi.fn(), updateShare: vi.fn(), resolveRequest: vi.fn(), transfer: vi.fn(),
}));
vi.mock("../../api/collaborationAccess", () => ({ createCollaborationAccessApi: vi.fn(() => api) }));
vi.mock("../../runtime", () => ({ clientRuntime: { apiBaseUrl: () => "http://localhost:8888" } }));
vi.mock("../../composables/useProjectNotifications", () => ({ notifyProjectNotificationsChanged: vi.fn() }));
vi.mock("../../utils/apiErrorMessage", () => ({ getApiErrorMessage: async (_error: unknown, fallback: string) => fallback }));
const file: CollaborationAccessFile = {
id: "file-a", title: "台账.xlsx", owner_id: "owner", owner_name: "Owner", file_type: "cell",
status: "ACTIVE", generation: 4, can_manage: true, can_transfer_ownership: true,
allow_export: false, allow_edit_request: true, allow_sheet_structure_edit: false,
};
const owner = { user_id: "owner", full_name: "Owner", email: "owner@example.com", role: "MANAGER", protected_label: "所有者" };
const candidate = { user_id: "candidate", full_name: "Candidate", email: "candidate@example.com", role_in_study: "系统账号", can_be_editor: true, can_be_manager: true };
const share = { enabled: false, has_password: false, access_mode: "VIEW", expiry_policy: "SEVEN_DAYS", share_path: "/collaboration/share", share_token: null };
let wrapper: ReturnType<typeof mount> | undefined;
const render = (target: CollaborationAccessTarget = { scope: "ledger" }) => {
wrapper = mount(AccessDialog, { props: { file, target }, global: {
directives: { loading: () => {} },
stubs: {
ElDialog: { props: ["modelValue", "title"], template: '<div v-if="modelValue" role="dialog"><slot name="header" />{{ title }}<slot /><slot name="footer" /></div>' },
ElButton: { template: '<button><slot /></button>' }, ElAvatar: { template: '<span><slot /></span>' },
ElTag: { template: '<span><slot /></span>' }, ElIcon: { template: '<span><slot /></span>' },
ElEmpty: true, ElInput: true,
ElSelect: { props: ["modelValue"], emits: ["update:modelValue", "change"], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><slot /></select>' },
ElOption: { props: ["label", "value"], template: '<option :value="value">{{ label }}</option>' },
ElSwitch: { props: ["modelValue", "disabled"], emits: ["update:modelValue", "change"], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target.checked); $emit(\'change\', $event.target.checked)" />' },
},
} });
return wrapper;
};
describe("shared collaboration access dialog", () => {
beforeEach(() => {
vi.clearAllMocks();
api.file.mockResolvedValue({ data: { ...file } });
api.members.mockResolvedValue({ data: [owner] });
api.candidates.mockResolvedValue({ data: [candidate] });
api.requests.mockResolvedValue({ data: [] });
api.share.mockResolvedValue({ data: share });
api.setMember.mockResolvedValue({});
});
afterEach(() => { wrapper?.unmount(); wrapper = undefined; });
it.each(["ledger", "project"] as const)("uses the same picker with the %s account directory and role boundary", async (scope) => {
const target: CollaborationAccessTarget = scope === "project" ? { scope, studyId: "study-a" } : { scope };
const page = render(target);
await flushPromises();
expect(createCollaborationAccessApi).toHaveBeenCalledWith(target);
expect(page.get(".access-member-row").text()).toContain("所有者");
expect(page.find(".access-member-remove").exists()).toBe(false);
expect(page.get('[aria-label="启用匿名访问"]').element).toHaveProperty("checked", false);
await page.get(".access-section__manage-button").trigger("click");
expect(page.get('[role="listbox"]').attributes("aria-label")).toBe(scope === "ledger" ? "可添加的系统账号" : "可添加的项目成员");
const select = page.get('[aria-label="文件授权角色"]');
expect(select.find('option[value="VIEWER"]').exists()).toBe(scope === "ledger");
if (scope === "ledger") await select.setValue("VIEWER");
await page.get(".member-picker-contact").trigger("click");
await page.findAll(".member-picker-selection__footer button").find((button) => button.text() === "确定")!.trigger("click");
await flushPromises();
expect(api.setMember).toHaveBeenCalledWith("file-a", { user_id: "candidate", role: scope === "ledger" ? "VIEWER" : "EDITOR" });
expect(page.emitted("changed")).toBeTruthy();
});
it("uses the refreshed generation after a share change before saving file permissions", async () => {
const page = render();
await flushPromises();
api.updateShare.mockResolvedValue({ data: { ...share, enabled: true, share_token: "test-token" } });
api.file.mockResolvedValue({ data: { ...file, generation: 5 } });
await page.get('[aria-label="启用匿名访问"]').setValue(true);
await flushPromises();
expect(api.updateShare).toHaveBeenCalledWith("file-a", expect.objectContaining({ enabled: true, password_mode: "KEEP" }));
await page.get(".access-setting-navigation").trigger("click");
api.updateFile.mockResolvedValue({ data: { ...file, generation: 6, allow_export: true } });
await page.get(".access-permission-row input").setValue(true);
await flushPromises();
expect(api.updateFile).toHaveBeenCalledWith(expect.objectContaining({ generation: 5 }), { allow_export: true });
});
it("closes access controls when a removed manager no longer has access", async () => {
api.members.mockResolvedValue({ data: [{ ...owner, user_id: "manager", protected_label: null }] });
const page = render();
await flushPromises();
api.removeMember.mockResolvedValue({});
api.members.mockRejectedValue({ response: { status: 404 } });
await page.get(".access-member-remove").trigger("click");
await flushPromises();
expect(page.find('[role="dialog"]').exists()).toBe(false);
expect(page.emitted("changed")).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import HistoryDialog from "./CollaborationHistoryDialog.vue";
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
import { downloadLedgerRevision } from "../../api/ledgers";
import { prepareSaveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const state = vi.hoisted(() => ({
api: { file: vi.fn(), revisions: vi.fn(), name: vi.fn(), restore: vi.fn() },
resolve: vi.fn(() => ({ href: "/ledger-preview" })), prompt: vi.fn(), confirm: vi.fn(),
}));
vi.mock("../../api/collaborationHistory", () => ({ createCollaborationHistoryApi: () => state.api }));
vi.mock("../../api/collaboration", () => ({ copyCollaborationRevision: vi.fn(), deleteCollaborationRevision: vi.fn() }));
vi.mock("../../api/ledgers", () => ({ downloadLedgerRevision: vi.fn(), importLedger: vi.fn() }));
vi.mock("../../runtime", () => ({ prepareSaveFile: vi.fn(), pickFiles: vi.fn() }));
vi.mock("../../utils/fileTaskFeedback", () => ({ saveFileWithFeedback: vi.fn() }));
vi.mock("../../store/auth", () => ({ useAuthStore: () => ({ user: { id: "u", full_name: "Owner" } }) }));
vi.mock("vue-router", () => ({ useRouter: () => ({ resolve: state.resolve }) }));
vi.mock("element-plus", () => ({ ElMessage: { error: vi.fn(), success: vi.fn() }, ElMessageBox: { prompt: state.prompt, confirm: state.confirm } }));
vi.mock("./CollaborationSaveAsDialog.vue", () => ({ default: { template: "<div />" } }));
const file: CollaborationHistoryFile = { id: "ledger-a", title: "台账.xlsx", extension: "xlsx", file_type: "cell", status: "ACTIVE", generation: 7, can_manage: true, can_edit: true, can_export: true, current_revision_id: "r2", owner_name: "Owner" };
const revisions = [
{ id: "r2", revision_no: 2, source: "FORCE_SAVE", created_at: "2026-09-03T01:00:00Z", file_size: 200, mime_type: "application/xlsx", change_summary: "", created_by_name: "Owner" },
{ id: "r1", revision_no: 1, source: "IMPORT", created_at: "2026-09-02T01:00:00Z", file_size: 100, mime_type: "application/xlsx", change_summary: "提交版", created_by_name: "Owner" },
];
let wrapper: ReturnType<typeof mount> | undefined;
const render = (overrides = {}, scope: "project" | "ledger" = "ledger") => {
const current = { ...file, ...overrides };
state.api.file.mockResolvedValue({ data: current });
wrapper = mount(HistoryDialog, { props: { file: current, target: scope === "ledger" ? { scope } : { scope, studyId: "study-a" }, canCreate: true }, global: {
directives: { loading: () => {} }, stubs: {
ElDialog: { props: ["modelValue"], template: '<div v-if="modelValue"><slot name="header"/><slot/></div>' },
ElButton: { template: '<button><slot/></button>' }, ElAvatar: { template: '<span><slot/></span>' },
ElTag: { template: '<span><slot/></span>' }, ElIcon: { template: '<span><slot/></span>' }, ElTooltip: { template: '<span><slot/></span>' }, ElEmpty: true,
ElDropdown: { name: "ElDropdown", emits: ["command"], template: '<div><slot/><slot name="dropdown"/></div>' },
ElDropdownMenu: { template: '<div><slot/></div>' }, ElDropdownItem: { template: '<span><slot/></span>' },
ElSelect: { props: ["modelValue"], emits: ["update:modelValue"], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot/></select>' },
ElOption: { props: ["value", "label"], template: '<option :value="value">{{ label }}</option>' },
ElSwitch: { props: ["modelValue"], emits: ["update:modelValue"], template: '<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)"/>' },
},
} });
return wrapper;
};
describe("shared project and ledger history", () => {
beforeEach(() => {
vi.clearAllMocks(); vi.useFakeTimers();
state.api.revisions.mockResolvedValue({ data: revisions.map(item => ({ ...item })) });
state.prompt.mockResolvedValue({ value: "复核版" }); state.confirm.mockResolvedValue("confirm");
});
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
it.each(["project", "ledger"] as const)("filters and names versions in the same %s history UI", async (scope) => {
const page = render({}, scope); await flushPromises();
expect(page.findAll(".history-day-group")).toHaveLength(2);
await page.get(".history-described-filter input").setValue(true);
expect(page.findAll(".history-row")).toHaveLength(1);
await page.get(".history-source-filter").setValue("FORCE_SAVE");
expect(page.findAll(".history-row")).toHaveLength(0);
await page.get(".history-described-filter input").setValue(false);
state.api.name.mockResolvedValue({ data: { ...revisions[0], change_summary: "复核版" } });
await page.findAll(".history-row button").find(button => button.text() === "命名")!.trigger("click");
await flushPromises();
expect(state.api.name).toHaveBeenCalledWith("ledger-a", "r2", { change_summary: "复核版" });
expect(page.get(".history-description").text()).toContain("复核版");
});
it("preserves ledger no-delete and manager-only restore while using the current generation", async () => {
const editor = render({ can_manage: false, can_export: false }); await flushPromises();
expect(editor.text()).toContain("命名");
expect(editor.text()).not.toContain("恢复此版本"); expect(editor.text()).not.toContain("另存为");
editor.unmount();
const manager = render(); await flushPromises();
expect(manager.text()).not.toContain("删除版本");
const restored = { ...revisions[0], id: "r3", revision_no: 3, source: "RESTORE" };
state.api.restore.mockResolvedValue({ data: restored });
state.api.revisions.mockResolvedValue({ data: [restored, ...revisions] });
state.api.file.mockResolvedValue({ data: { ...file, generation: 8, current_revision_id: "r3" } });
manager.findComponent({ name: "ElDropdown" }).vm.$emit("command", "restore");
// Emit the same command as the real row menu with its bound revision.
await flushPromises();
expect(state.api.restore).toHaveBeenCalledWith(expect.objectContaining({ generation: 7 }), "r1");
expect(manager.findAll(".history-row")).toHaveLength(3);
expect(manager.get(".history-row.is-current").text()).toContain("历史恢复");
});
it("opens a global read-only preview and saves a historical XLSX without project context", async () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
const page = render(); await flushPromises();
await page.findAll(".history-row button").find(button => button.text() === "预览")!.trigger("click");
expect(state.resolve).toHaveBeenCalledWith({ name: "LedgerRevisionPreview", params: { fileId: "ledger-a", id: "r2" } });
expect(open).toHaveBeenCalledWith("/ledger-preview", "_blank", "noopener,noreferrer");
const destination = { kind: "web-download" };
vi.mocked(prepareSaveFile).mockResolvedValue(destination as any);
const blob = new Blob(["historical bytes"]);
vi.mocked(downloadLedgerRevision).mockResolvedValue({ data: blob } as any);
await page.findAll(".history-row button").find(button => button.text() === "另存为")!.trigger("click");
await flushPromises();
expect(downloadLedgerRevision).toHaveBeenCalledWith("ledger-a", "r2");
expect(saveFileWithFeedback).toHaveBeenCalledWith(expect.objectContaining({ data: blob, suggestedName: expect.stringMatching(/台账\[.*\]\.xlsx$/) }), expect.anything(), destination);
});
});
@@ -0,0 +1,553 @@
<template>
<el-dialog
v-model="historyDialogVisible"
width="840px"
class="collaboration-action-dialog history-dialog"
modal-class="history-dialog-overlay"
destroy-on-close
append-to-body
@closed="closeHistory"
>
<template #header>
<div v-if="historyFile" class="history-dialog__title">
<span class="history-file-badge" :class="`is-${historyFile.file_type}`">{{ fileTypeLabel(historyFile.file_type) }}</span>
<div>
<strong>{{ historyFile.title }}</strong>
<div class="history-dialog__meta">
<span>创建者:{{ historyFile.owner_name || "—" }}</span>
<span :title="historyFile.id">文件 ID:{{ historyFile.id.slice(0, 8) }}…</span>
<span>文件大小:{{ fileSizeLabel(historyFile.current_revision_file_size) }}</span>
</div>
</div>
</div>
</template>
<div class="history-toolbar">
<label class="history-described-filter">
<span>仅显示已命名版本</span>
<el-switch v-model="historyOnlyDescribed" />
</label>
<span class="history-toolbar__spacer" />
<el-button v-if="target.scope === 'ledger' && canRestore" :disabled="historyLoading || historyActionKey !== null || restoringRevisionId !== null" @click="importNewRevision">导入新版本</el-button>
<el-select v-model="historySourceFilter" class="history-source-filter" aria-label="版本来源筛选">
<el-option label="全部来源" value="ALL" />
<el-option v-for="option in historySourceOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-tooltip content="刷新历史版本" placement="top">
<el-button circle :loading="historyLoading" aria-label="刷新历史版本" @click="refreshHistory()">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
</div>
<div v-loading="historyLoading" class="history-panel">
<div class="history-list-header">
<span>时间</span>
<span>更新者</span>
<span>大小</span>
<span>版本来源</span>
<span>版本名称</span>
<span>操作</span>
</div>
<div v-if="historyRevisionGroups.length" class="history-groups">
<section v-for="group in historyRevisionGroups" :key="group.key" class="history-day-group">
<h3>{{ group.label }}</h3>
<div v-for="row in group.items" :key="row.id" class="history-row" :class="{ 'is-current': row.id === historyFile?.current_revision_id }">
<span class="history-time">{{ revisionTimeLabel(row.created_at) }}</span>
<span class="history-author">
<el-avatar :size="26" :src="row.created_by_avatar_url || undefined">{{ revisionAuthorInitial(row) }}</el-avatar>
<span>{{ revisionAuthorName(row) }}</span>
</span>
<span>{{ formatFileSize(row.file_size) }}</span>
<span class="history-source">{{ sourceLabel(row.source) }}</span>
<span class="history-description">
<span>{{ row.change_summary || "未命名" }}</span>
<el-tag v-if="row.id === historyFile?.current_revision_id" size="small" type="info" effect="plain">当前版本</el-tag>
</span>
<span class="history-row__action">
<el-button
v-if="historyFile?.can_edit"
link
type="primary"
:loading="historyActionKey === `${row.id}:name`"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
@click="nameRevision(row)"
>命名</el-button>
<el-button
link
type="primary"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
@click="previewRevision(row)"
>预览</el-button>
<el-button
v-if="canSaveCopy"
link
type="primary"
:loading="historyActionKey === `${row.id}:copy`"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
@click="saveRevisionAs(row)"
>另存为</el-button>
<el-dropdown
v-if="canRestore && row.id !== historyFile?.current_revision_id"
trigger="click"
@command="handleRevisionMoreAction($event, row)"
>
<el-button
link
class="history-row__more"
aria-label="更多版本操作"
:loading="restoringRevisionId === row.id"
:disabled="historyActionKey !== null || restoringRevisionId !== null"
>•••</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="restore">恢复此版本</el-dropdown-item>
<el-dropdown-item v-if="target.scope === 'project' && historyFile?.can_manage" command="delete" divided>删除版本</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</span>
</div>
</section>
</div>
<el-empty v-else description="没有符合条件的历史版本" :image-size="72" />
</div>
</el-dialog>
<CollaborationSaveAsDialog
v-if="target.scope === 'project'"
v-model="revisionSaveAsDialogVisible"
:study-id="target.scope === 'project' ? target.studyId : ''"
:initial-title="revisionSaveAsTitle"
:initial-folder-id="revisionSaveAsFolderId"
:saving="revisionSaveAsSaving"
:can-create-folder="canManageFolders"
@confirm="submitRevisionSaveAs"
/>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, onDeactivated, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Refresh } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { formatFileSize } from "../attachments/attachmentUtils";
import { copyCollaborationRevision, deleteCollaborationRevision } from "../../api/collaboration";
import { downloadLedgerRevision, importLedger } from "../../api/ledgers";
import { createCollaborationHistoryApi, type CollaborationHistoryFile } from "../../api/collaborationHistory";
import type { CollaborationAccessTarget } from "../../api/collaborationAccess";
import type { CollaborationRevision, CollaborationFileType } from "../../types/collaboration";
import { prepareSaveFile, pickFiles } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import CollaborationSaveAsDialog from "./CollaborationSaveAsDialog.vue";
const props = withDefaults(defineProps<{ file: CollaborationHistoryFile; target: CollaborationAccessTarget; canCreate?: boolean; canManageFolders?: boolean }>(), { canCreate: false, canManageFolders: false });
const emit = defineEmits<{ close: []; changed: []; updated: [file: CollaborationHistoryFile] }>();
const auth = useAuthStore();
const router = useRouter();
const api = createCollaborationHistoryApi(props.target);
const canRestore = computed(() => props.target.scope === "ledger"
? Boolean(historyFile.value?.can_manage && historyFile.value.status === "ACTIVE") : Boolean(historyFile.value?.can_edit));
const canSaveCopy = computed(() => Boolean(historyFile.value?.can_export && (props.target.scope === "ledger" || props.canCreate)));
const fileTypeLabel = (value: CollaborationFileType) => ({ word: "W", cell: "X", slide: "P" })[value];
const fileSizeLabel = (value?: number | null) => value == null ? "—" : formatFileSize(value);
const historyDialogVisible = ref(false);
const historyLoading = ref(false);
const restoringRevisionId = ref<string | null>(null);
const historyActionKey = ref<string | null>(null);
const historyOnlyDescribed = ref(false);
const historySourceFilter = ref("ALL");
const historyFile = ref<CollaborationHistoryFile | null>(null);
const revisions = ref<CollaborationRevision[]>([]);
const revisionSaveAsDialogVisible = ref(false);
const revisionSaveAsTarget = ref<CollaborationRevision | null>(null);
const revisionSaveAsFolderId = ref<string | null>(null);
const revisionSaveAsTitle = ref("");
const revisionSaveAsSaving = ref(false);
const historyRefreshTimers: number[] = [];
let historyRequestSequence = 0;
const historySourceOptions = computed(() =>
[...new Set(revisions.value.map((item) => item.source))]
.map((value) => ({ value, label: sourceLabel(value) }))
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN")),
);
const historyFilteredRevisions = computed(() => revisions.value.filter((item) =>
(historySourceFilter.value === "ALL" || item.source === historySourceFilter.value) &&
(!historyOnlyDescribed.value || Boolean(item.change_summary?.trim())),
));
const revisionDateKey = (value: string) => {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
};
const revisionDateLabel = (value: string) => {
const date = new Date(value);
const weekday = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"][date.getDay()];
return `${revisionDateKey(value)} ${weekday}`;
};
const revisionTimeLabel = (value: string) => {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const historyRevisionGroups = computed(() => {
const groups = new Map<string, CollaborationRevision[]>();
for (const revision of historyFilteredRevisions.value) {
const key = revisionDateKey(revision.created_at);
const items = groups.get(key) || [];
items.push(revision);
groups.set(key, items);
}
return [...groups.entries()].map(([key, items]) => ({
key,
label: revisionDateLabel(items[0].created_at),
items,
}));
});
const revisionAuthorName = (revision: CollaborationRevision) => {
if (revision.created_by_name) return revision.created_by_name;
if (revision.created_by && revision.created_by === auth.user?.id) {
return auth.user.full_name || auth.user.email || "当前用户";
}
return revision.created_by ? (props.target.scope === "ledger" ? "系统账号" : "项目成员") : revision.source.startsWith("SHARE_") ? "匿名协作者" : "系统";
};
const revisionAuthorInitial = (revision: CollaborationRevision) =>
revisionAuthorName(revision).trim().slice(0, 1).toUpperCase() || "?";
function sourceLabel(source: string) {
return ({
CREATE: "新建",
IMPORT: "导入",
FORCE_SAVE: "手动保存",
SESSION_CLOSE: "自动保存",
RESTORE: "历史恢复",
COPY: "复制创建",
SHARE_FORCE_SAVE: "链接协作保存",
SHARE_SESSION_CLOSE: "链接协作关闭",
SERVER_RECOVERY: "服务器备份恢复",
} as Record<string, string>)[source] || source;
}
const clearHistoryRefreshTimers = () => {
while (historyRefreshTimers.length) window.clearTimeout(historyRefreshTimers.pop());
};
const scheduleHistoryRefresh = () => {
clearHistoryRefreshTimers();
// ONLYOFFICE 的会话关闭回调可能晚于用户返回文件库;弹窗打开后继续
// 追踪最终自动保存版本,避免用户必须手动点击刷新。
for (const delay of [2_500, 12_000]) {
historyRefreshTimers.push(window.setTimeout(() => {
if (historyDialogVisible.value) void refreshHistory({ silent: true });
}, delay));
}
};
const refreshHistory = async (options: { silent?: boolean } = {}) => {
if (!historyFile.value) return;
const fileId = historyFile.value.id;
const requestSequence = historyRequestSequence + 1;
historyRequestSequence = requestSequence;
historyLoading.value = true;
try {
const [revisionResponse, fileResponse] = await Promise.all([
api.revisions(fileId),
api.file(fileId),
]);
if (
requestSequence !== historyRequestSequence ||
!historyDialogVisible.value ||
historyFile.value?.id !== fileId
) return;
revisions.value = revisionResponse.data;
historyFile.value = fileResponse.data;
emit("updated", fileResponse.data);
} catch (error) {
if ([403, 404].includes(Number((error as { response?: { status?: number } })?.response?.status))) {
closeHistory();
}
if (requestSequence === historyRequestSequence && !options.silent) {
ElMessage.error(await getApiErrorMessage(error, "修订历史加载失败"));
}
} finally {
if (requestSequence === historyRequestSequence) historyLoading.value = false;
}
};
const openHistory = async (item: CollaborationHistoryFile) => {
clearHistoryRefreshTimers();
historyFile.value = item;
revisions.value = [];
historyOnlyDescribed.value = false;
historySourceFilter.value = "ALL";
historyDialogVisible.value = true;
scheduleHistoryRefresh();
await refreshHistory();
};
const closeHistory = () => {
clearHistoryRefreshTimers();
historyRequestSequence += 1;
historyLoading.value = false;
historyFile.value = null;
revisions.value = [];
emit("close");
};
const revisionCopyTitle = (revision: CollaborationRevision) => {
if (!historyFile.value) return `历史版本[${revisionSaveAsTimeLabel(revision.created_at)}]`;
const suffix = `.${historyFile.value.extension}`;
const title = historyFile.value.title;
const stem = title.toLowerCase().endsWith(suffix) ? title.slice(0, -suffix.length) : title;
return `${stem}[${revisionSaveAsTimeLabel(revision.created_at)}]${suffix}`;
};
const revisionSaveAsTimeLabel = (value: string) => {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}︰${pad(date.getMinutes())}`;
};
const nameRevision = async (revision: CollaborationRevision) => {
if (!historyFile.value || historyActionKey.value) return;
const fileId = historyFile.value.id;
try {
const { value } = await ElMessageBox.prompt("请输入版本名称", "命名版本", {
inputValue: revision.change_summary || "",
inputValidator: (input) => Boolean(String(input || "").trim()) || "版本名称不能为空",
confirmButtonText: "保存",
});
historyActionKey.value = `${revision.id}:name`;
const { data } = await api.name(fileId, revision.id, {
change_summary: value.trim(),
});
revisions.value = revisions.value.map((item) => item.id === revision.id ? {
...item,
...data,
created_by_name: item.created_by_name,
created_by_avatar_url: item.created_by_avatar_url,
} : item);
ElMessage.success("版本名称已保存");
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "版本命名失败"));
} finally {
historyActionKey.value = null;
}
};
const previewRevision = (revision: CollaborationRevision) => {
if (!historyFile.value || historyActionKey.value || restoringRevisionId.value) return;
const fileId = historyFile.value.id;
const previewRoute = router.resolve({
name: props.target.scope === "ledger" ? "LedgerRevisionPreview" : "OfficeCollaborationRevisionPreview",
params: { fileId, id: revision.id },
});
window.open(previewRoute.href, "_blank", "noopener,noreferrer");
};
const saveRevisionAs = (revision: CollaborationRevision) => {
if (!canSaveCopy.value) return;
if (props.target.scope === "ledger") { void saveLedgerRevisionAs(revision); return; }
if (!historyFile.value || historyActionKey.value) return;
revisionSaveAsTarget.value = revision;
revisionSaveAsFolderId.value = historyFile.value.folder_id || null;
revisionSaveAsTitle.value = revisionCopyTitle(revision);
revisionSaveAsDialogVisible.value = true;
};
const submitRevisionSaveAs = async (payload: { title: string; folderId: string | null }) => {
const revision = revisionSaveAsTarget.value;
const file = historyFile.value;
if (!revision || !file || revisionSaveAsSaving.value || props.target.scope !== "project") return;
revisionSaveAsSaving.value = true;
historyActionKey.value = `${revision.id}:copy`;
try {
const { data } = await copyCollaborationRevision(props.target.studyId, file.id, revision.id, {
title: payload.title,
folder_id: payload.folderId,
});
revisionSaveAsDialogVisible.value = false;
revisionSaveAsTarget.value = null;
emit("changed");
ElMessage.success(`已在工作区创建“${data.title}”`);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "历史版本另存为失败"));
} finally {
revisionSaveAsSaving.value = false;
historyActionKey.value = null;
}
};
const handleRevisionMoreAction = (command: string, revision: CollaborationRevision) => {
if (command === "restore") void restoreRevision(revision);
if (command === "delete") void deleteRevision(revision);
};
const deleteRevision = async (revision: CollaborationRevision) => {
if (!historyFile.value || historyActionKey.value || revision.id === historyFile.value.current_revision_id || props.target.scope !== "project" || !historyFile.value.can_manage) return;
const fileId = historyFile.value.id;
try {
await ElMessageBox.confirm(
"删除此历史版本后将不再显示且无法恢复,是否继续?",
"删除历史版本",
{ type: "warning", confirmButtonText: "删除", confirmButtonClass: "el-button--danger" },
);
historyActionKey.value = `${revision.id}:delete`;
await deleteCollaborationRevision(props.target.studyId, fileId, revision.id);
revisions.value = revisions.value.filter((item) => item.id !== revision.id);
ElMessage.success("历史版本已删除");
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "历史版本删除失败"));
} finally {
historyActionKey.value = null;
}
};
const restoreRevision = async (revision: CollaborationRevision) => {
if (!historyFile.value || restoringRevisionId.value || !canRestore.value) return;
const file = historyFile.value;
const fileId = file.id;
try {
await ElMessageBox.confirm(props.target.scope === "ledger" ? "恢复将生成新版本,已打开的台账需重新加载。请确认协作者已保存内容后继续。" : "恢复此历史版本将生成一个新版本,是否继续?", "恢复历史版本", { type: "warning" });
restoringRevisionId.value = revision.id;
const { data: restored } = await api.restore(file, revision.id);
const restoredForDisplay: CollaborationRevision = {
...restored,
created_by_name: restored.created_by_name || auth.user?.full_name || auth.user?.email || null,
created_by_avatar_url: restored.created_by_avatar_url || auth.user?.avatar_url || null,
};
if (historyFile.value?.id === fileId) {
historyFile.value = {
...historyFile.value,
current_revision_id: restoredForDisplay.id,
current_revision_no: restoredForDisplay.revision_no,
current_revision_file_size: restoredForDisplay.file_size,
current_revision_mime_type: restoredForDisplay.mime_type,
current_revision_created_at: restoredForDisplay.created_at,
};
revisions.value = [
restoredForDisplay,
...revisions.value.filter((item) => item.id !== restoredForDisplay.id),
].sort((left, right) => right.revision_no - left.revision_no);
}
const [revisionResponse, fileResponse] = await Promise.all([
api.revisions(fileId),
api.file(fileId),
]);
if (historyFile.value?.id === fileId) {
revisions.value = revisionResponse.data;
const refreshedFile = fileResponse.data;
emit("updated", refreshedFile);
if (refreshedFile) historyFile.value = refreshedFile;
}
emit("changed");
ElMessage.success("已恢复并生成新版本");
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "版本恢复失败"));
} finally {
restoringRevisionId.value = null;
}
};
const saveLedgerRevisionAs = async (revision: CollaborationRevision) => {
const file = historyFile.value;
if (!file || historyActionKey.value || restoringRevisionId.value || !canSaveCopy.value) return;
const suggestedName = revisionCopyTitle(revision);
const destinationPromise = prepareSaveFile(suggestedName);
historyActionKey.value = `${revision.id}:copy`;
try {
const destination = await destinationPromise;
if (!destination) return;
const { data } = await downloadLedgerRevision(file.id, revision.id);
await saveFileWithFeedback({ suggestedName, mimeType: revision.mime_type, data }, { title: "另存台账历史版本" }, destination);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "历史版本另存为失败"));
} finally { historyActionKey.value = null; }
};
const importNewRevision = async () => {
const file = historyFile.value;
if (!file || props.target.scope !== "ledger" || !canRestore.value || historyActionKey.value || restoringRevisionId.value) return;
historyActionKey.value = "import";
try {
const [upload] = await pickFiles({ accept: [".xlsx"], multiple: false, title: "选择台账 Excel 文件" });
if (!upload) return;
await ElMessageBox.confirm("导入将生成新版本,已打开的台账需重新加载。请确认协作者已保存内容后继续。", "导入新版本", { type: "warning" });
await importLedger(file.id, upload, file.generation);
await refreshHistory();
emit("changed");
ElMessage.success("已导入为新版本");
} catch (error) {
if (error !== "cancel" && error !== "close") ElMessage.error(await getApiErrorMessage(error, "台账导入失败"));
} finally { historyActionKey.value = null; }
};
onMounted(() => { void openHistory(props.file); });
onDeactivated(closeHistory);
onBeforeUnmount(() => { clearHistoryRefreshTimers(); historyRequestSequence += 1; });
</script>
<style scoped>
.history-dialog__title { display: flex; align-items: flex-start; gap: 12px; min-width: 0; padding-right: 44px; }
.history-dialog__title > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 5px; }
.history-dialog__title strong { overflow: hidden; color: var(--ctms-text-main); font-size: 18px; font-weight: 700; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
.history-dialog__meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; color: var(--ctms-text-secondary); font-size: 12px; line-height: 1.35; }
.history-dialog__meta span { display: inline-flex; align-items: center; white-space: nowrap; }
.history-dialog__meta span + span::before { width: 1px; height: 13px; margin: 0 13px; background: var(--ctms-border-color); content: ""; }
.history-file-badge { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex: 0 0 auto; border-radius: 6px; color: #fff; font-size: 13px; font-weight: 750; }
.history-file-badge.is-word { background: #4472c4; }
.history-file-badge.is-cell { background: #2e8b57; }
.history-file-badge.is-slide { background: #d05a35; }
.history-toolbar { display: flex; align-items: center; gap: 8px; min-height: 52px; padding: 7px 18px; border-bottom: 1px solid var(--ctms-border-color); }
.history-described-filter { display: inline-flex; align-items: center; gap: 8px; color: var(--ctms-text-main); font-size: 13px; }
.history-toolbar__spacer { flex: 1; }
.history-source-filter { width: 132px; }
.history-panel { display: flex; min-height: 0; margin: 14px 18px 18px; overflow: hidden; flex: 1; flex-direction: column; border: 1px solid var(--ctms-border-color); border-radius: 9px; background: var(--ctms-bg-card); }
.history-list-header,
.history-row { display: grid; grid-template-columns: 70px 130px 78px 96px minmax(140px, 1fr) 202px; min-width: 760px; align-items: center; column-gap: 8px; }
.history-list-header { min-height: 44px; padding: 0 18px; flex: 0 0 auto; border-bottom: 1px solid var(--ctms-border-color); background: var(--ctms-bg-muted); color: var(--ctms-text-regular); font-size: 13px; font-weight: 650; }
.history-groups { min-height: 0; max-height: none; overflow: auto; flex: 1; }
.history-day-group { min-width: 760px; padding: 0 18px; }
.history-day-group + .history-day-group { border-top: 1px solid var(--ctms-border-color); }
.history-day-group h3 { margin: 0; padding: 15px 0 5px; color: var(--ctms-text-main); font-size: 15px; font-weight: 700; line-height: 1.25; }
.history-row { min-height: 48px; padding: 0; border-bottom: 1px solid color-mix(in srgb, var(--ctms-border-color) 42%, transparent); color: var(--ctms-text-regular); font-size: 13px; transition: background-color 0.16s ease; }
.history-row:last-child { border-bottom: 0; }
.history-row:hover { background: var(--ctms-bg-muted); }
.history-row.is-current { background: color-mix(in srgb, var(--ctms-primary) 5%, var(--ctms-bg-card)); }
.history-time { padding-left: 5px; color: var(--ctms-text-main); font-variant-numeric: tabular-nums; }
.history-author { display: inline-flex; min-width: 0; align-items: center; gap: 7px; }
.history-author :deep(.el-avatar) { flex: 0 0 auto; background: var(--ctms-primary); color: #fff; font-size: 11px; font-weight: 700; }
.history-author > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-source { color: var(--ctms-text-regular); }
.history-description { display: flex; min-width: 0; align-items: center; gap: 8px; }
.history-description > span { overflow: hidden; color: var(--ctms-text-secondary); text-overflow: ellipsis; white-space: nowrap; }
.history-row__action { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 1px; opacity: 0.72; transition: opacity 0.16s ease; }
.history-row__action :deep(.el-button) { margin-left: 0; padding: 4px 5px; font-size: 12px; }
.history-row__more { letter-spacing: 1px; }
.history-row:hover .history-row__action,
.history-row:focus-within .history-row__action { opacity: 1; }
:global(.history-dialog.el-dialog) { display: flex; width: 840px; height: min(760px, 82vh); max-width: calc(100vw - 40px); max-height: calc(100vh - 64px); margin: 8vh auto 0 !important; overflow: hidden; flex-direction: column; padding: 0; border-radius: 14px; }
:global(.history-dialog.el-dialog .el-dialog__header) { margin: 0; padding: 16px 18px 14px; flex: 0 0 auto; border-bottom: 1px solid var(--ctms-border-color); }
:global(.history-dialog.el-dialog .el-dialog__headerbtn) { top: 13px; right: 13px; width: 36px; height: 36px; }
:global(.history-dialog.el-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; flex-direction: column; padding: 0; }
:global(.history-dialog-overlay.el-overlay),
:global(.history-dialog-overlay .el-overlay-dialog) { overflow: hidden; }
:global(body.is-desktop-runtime .history-dialog.el-dialog) { margin: 0 auto !important; --el-dialog-padding-primary: 0; padding: 0 !important; overflow: hidden !important; }
@media (max-width: 900px) {
.history-dialog__meta span + span::before { margin: 0 8px; }
.history-toolbar { flex-wrap: wrap; }
.history-toolbar__spacer { display: none; }
.history-source-filter { margin-left: auto; }
.history-panel { margin-right: 12px; margin-left: 12px; overflow-x: auto; }
}
</style>
@@ -0,0 +1,13 @@
<template>
<CollaborationHistoryDialog :file="ledger" :target="{ scope: 'ledger' }"
@close="emit('close')" @changed="emit('changed')" @updated="emit('updated', $event)" />
</template>
<script setup lang="ts">
import CollaborationHistoryDialog from "../collaboration/CollaborationHistoryDialog.vue";
import type { Ledger } from "../../types/ledger";
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
const { ledger } = defineProps<{ ledger: Ledger }>();
const emit = defineEmits<{ close: []; changed: []; updated: [file: CollaborationHistoryFile] }>();
</script>
@@ -0,0 +1,132 @@
<template>
<el-dialog :model-value="true" title="信息维护" width="560px" class="ledger-info-dialog"
append-to-body align-center :close-on-click-modal="false" @close="emit('close')">
<template #header="{ titleId }">
<div class="ledger-info-header">
<span class="ledger-info-header__icon" aria-hidden="true"><el-icon><EditPen /></el-icon></span>
<h2 :id="titleId">信息维护</h2>
</div>
</template>
<div v-if="error" class="ledger-error" role="alert">{{ error }} <el-button link @click="refresh">重试</el-button></div>
<el-form id="ledger-info-form" v-loading="loading" class="ledger-info-form" label-position="top"
:disabled="busy || loading || !current.can_manage" @submit.prevent="save">
<el-form-item label="台账名称" required>
<el-input v-model="form.title" maxlength="240" aria-label="台账名称" />
</el-form-item>
<el-form-item label="用途说明">
<template #label>用途说明<span class="ledger-field-optional">选填</span></template>
<el-input v-model="form.description" type="textarea" maxlength="500" :rows="3"
show-word-limit resize="vertical" aria-label="用途说明" />
</el-form-item>
<div class="ledger-info-details">
<el-form-item label="所有者" class="ledger-info-owner">
<div class="ledger-owner-identity">
<el-avatar :size="32" class="ledger-owner-avatar" aria-hidden="true">{{ ownerInitial }}</el-avatar>
<span class="ledger-owner-name" :title="current.owner_name">{{ current.owner_name || '—' }}</span>
</div>
</el-form-item>
<el-form-item label="台账状态" class="ledger-info-status">
<el-radio-group v-model="form.status" class="ledger-status-options" aria-label="台账状态">
<el-radio-button value="ACTIVE">使用中</el-radio-button>
<el-radio-button value="ARCHIVED">已归档(只读)</el-radio-button>
</el-radio-group>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button :disabled="busy" @click="emit('close')">取消</el-button>
<el-button type="primary" :loading="busy" :disabled="loading || !current.can_manage"
native-type="submit" form="ledger-info-form">保存信息</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { EditPen } from "@element-plus/icons-vue";
import { fetchLedger, updateLedger } from "../../api/ledgers";
import type { Ledger, LedgerSettings } from "../../types/ledger";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
const props = defineProps<{ ledger: Ledger }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const current = ref(props.ledger);
const ownerInitial = computed(() => current.value.owner_name?.trim().slice(0, 1).toUpperCase() || "?");
const toSettings = (ledger: Ledger): LedgerSettings => ({
title: ledger.title.replace(/\.xlsx$/i, ""), description: ledger.description, status: ledger.status,
owner_id: ledger.owner_id, allow_export: ledger.allow_export,
allow_sheet_structure_edit: ledger.allow_sheet_structure_edit, generation: ledger.generation,
});
const form = ref(toSettings(props.ledger));
const loading = ref(false);
const busy = ref(false);
const error = ref("");
const refresh = async () => {
loading.value = true;
error.value = "";
try {
current.value = (await fetchLedger(current.value.id)).data;
form.value = toSettings(current.value);
if (!current.value.can_manage) {
emit("changed");
emit("close");
}
} catch (cause) { error.value = await getApiErrorMessage(cause, "台账信息加载失败"); }
finally { loading.value = false; }
};
const save = async () => {
if (busy.value || loading.value || !current.value.can_manage) return;
if (!form.value.title.trim()) { ElMessage.warning("请输入台账名称"); return; }
busy.value = true;
try {
await ElMessageBox.confirm("此操作会使已打开的台账重新加载。请确认协作者已保存内容后继续。", "更新台账信息", {
confirmButtonText: "继续", cancelButtonText: "取消", type: "warning",
});
await updateLedger(current.value.id, form.value);
ElMessage.success("台账信息已保存");
emit("changed");
emit("close");
} catch (cause) {
if (cause !== "cancel" && cause !== "close") error.value = await getApiErrorMessage(cause, "信息保存失败,请刷新后重试");
} finally { busy.value = false; }
};
onMounted(refresh);
</script>
<style scoped>
.ledger-info-header { display: flex; align-items: center; gap: 10px; padding-right: 30px; }
.ledger-info-header__icon { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; border-radius: 9px; background: var(--ctms-primary-light); color: var(--ctms-primary); font-size: 17px; }
.ledger-info-header h2 { margin: 0; color: var(--ctms-text-main); font-size: 18px; font-weight: 700; line-height: 26px; }
.ledger-info-form :deep(.el-form-item) { margin-bottom: 18px; }
.ledger-info-form :deep(.el-form-item__label) { height: auto; margin-bottom: 7px; color: var(--ctms-text-main); font-size: 13px; font-weight: 600; line-height: 20px; }
.ledger-field-optional { margin-left: 7px; color: var(--ctms-text-secondary); font-size: 12px; font-weight: 400; }
.ledger-info-form :deep(.el-input__wrapper) { min-height: 36px; padding: 0 11px; border-radius: 8px; }
.ledger-info-form :deep(.el-input__inner) { height: 36px; color: var(--ctms-text-main); font-size: 14px; }
.ledger-info-form :deep(.el-textarea__inner) { min-height: 88px; padding: 9px 11px 23px; border-radius: 8px; background: var(--ctms-bg-card); color: var(--ctms-text-main); font-size: 13px; line-height: 20px; }
.ledger-info-form :deep(.el-input__count) { right: 11px; bottom: 5px; background: var(--ctms-bg-card); color: var(--ctms-text-disabled); font-size: 11px; line-height: 16px; }
.ledger-info-details { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: 20px; padding-top: 16px; border-top: 1px solid var(--ctms-border-color); }
.ledger-info-details :deep(.el-form-item) { min-width: 0; margin-bottom: 0; }
.ledger-owner-identity { display: flex; align-items: center; gap: 9px; min-width: 0; width: 100%; min-height: 36px; }
.ledger-owner-avatar { flex-shrink: 0; background: var(--ctms-bg-muted); color: var(--ctms-primary); font-size: 13px; font-weight: 700; }
.ledger-owner-name { overflow: hidden; color: var(--ctms-text-regular); font-size: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.ledger-status-options { display: flex; flex-wrap: nowrap; width: 100%; }
.ledger-status-options :deep(.el-radio-button) { flex: 1; }
.ledger-status-options :deep(.el-radio-button__inner) { display: flex; align-items: center; justify-content: center; width: 100%; height: 36px; padding: 0 10px; font-size: 12px; line-height: 18px; }
.ledger-status-options :deep(.el-radio-button:first-child .el-radio-button__inner) { border-radius: 8px 0 0 8px; }
.ledger-status-options :deep(.el-radio-button:last-child .el-radio-button__inner) { border-radius: 0 8px 8px 0; }
.ledger-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 16px; color: var(--ctms-danger); background: color-mix(in srgb, var(--ctms-danger) 8%, var(--ctms-bg-card)); border-radius: 8px; font-size: 13px; }
:global(.ledger-info-dialog.el-dialog) { display: flex; flex-direction: column; max-width: calc(100vw - 32px); max-height: calc(100vh - 48px); max-height: calc(100dvh - 48px); padding: 0; overflow: hidden; border: 1px solid var(--ctms-border-color); border-radius: 14px; background: var(--ctms-bg-card); }
:global(.ledger-info-dialog.el-dialog .el-dialog__header) { flex-shrink: 0; margin: 0; padding: 16px 22px; border-bottom: 1px solid var(--ctms-border-color); background: var(--ctms-bg-card); }
:global(.ledger-info-dialog.el-dialog .el-dialog__headerbtn) { top: 16px; right: 16px; width: 32px; height: 32px; }
:global(.ledger-info-dialog.el-dialog .el-dialog__body) { min-height: 0; padding: 20px 22px; overflow-y: auto; }
:global(.ledger-info-dialog.el-dialog .el-dialog__footer) { display: flex; flex-shrink: 0; align-items: center; justify-content: flex-end; gap: 10px; padding: 13px 22px; border-top: 1px solid var(--ctms-border-color); background: var(--ctms-bg-muted); }
:global(.ledger-info-dialog.el-dialog .el-dialog__footer .el-button) { height: 34px; min-width: 80px; margin-left: 0; padding: 0 16px; border-radius: 8px; font-size: 13px; }
:global(body.is-desktop-runtime .ledger-info-dialog.el-dialog) { max-height: calc(100dvh - 96px); }
@media (max-width: 540px) {
.ledger-info-details { grid-template-columns: minmax(0, 1fr); gap: 16px; }
:global(.ledger-info-dialog.el-dialog .el-dialog__header),
:global(.ledger-info-dialog.el-dialog .el-dialog__body),
:global(.ledger-info-dialog.el-dialog .el-dialog__footer) { padding-left: 16px; padding-right: 16px; }
}
</style>
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import LedgerPanel from "./LedgerPanel.vue";
import { fetchLedgers, initializeLedgers } from "../../api/ledgers";
const state = vi.hoisted(() => ({ user: { is_admin: false }, push: vi.fn() }));
vi.mock("../../api/ledgers", () => ({ fetchLedgers: vi.fn(), initializeLedgers: vi.fn() }));
vi.mock("../../store/auth", () => ({ useAuthStore: () => state }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push: state.push }) }));
vi.mock("./LedgerInfoDialog.vue", () => ({ default: { template: "<div />" } }));
vi.mock("./LedgerHistoryDialog.vue", () => ({ default: { template: "<div />" } }));
vi.mock("../collaboration/CollaborationAccessDialog.vue", () => ({ default: { template: "<div />" } }));
vi.mock("../../utils/apiErrorMessage", () => ({ getApiErrorMessage: async (_error: unknown, fallback: string) => fallback }));
const ledger = {
id: "ledger-a", title: "医学事务部合同台账明细表.xlsx", description: "合同台账", owner_name: "负责人",
status: "ACTIVE", can_edit: true, can_manage: false, current_revision_created_at: "2026-09-03T00:00:00Z",
};
const render = () => mount(LedgerPanel, { global: { stubs: {
ElButton: { template: '<button><slot /></button>' }, ElIcon: { template: '<span><slot /></span>' },
ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
} } });
describe("global ledger entries", () => {
beforeEach(() => { vi.clearAllMocks(); state.user.is_admin = false; });
it("opens the granted ledger without needing a selected project", async () => {
vi.mocked(fetchLedgers).mockResolvedValue({ data: [ledger] } as any);
const wrapper = render();
await flushPromises();
expect(wrapper.text()).toContain("医学事务部合同台账明细表");
expect(wrapper.text()).not.toContain("台账管理");
await wrapper.get(".ledger-open").trigger("click");
expect(state.push).toHaveBeenCalledWith({ name: "LedgerWorkspace", params: { fileId: "ledger-a" } });
wrapper.unmount();
});
it("offers initialization only to administrators and shows an authorization empty state", async () => {
vi.mocked(fetchLedgers).mockResolvedValue({ data: [] } as any);
const viewer = render();
await flushPromises();
expect(viewer.text()).toContain("暂未获得台账访问权限");
expect(viewer.text()).not.toContain("初始化两本台账");
viewer.unmount();
state.user.is_admin = true;
vi.mocked(initializeLedgers).mockResolvedValue({ data: [ledger] } as any);
const admin = render();
await flushPromises();
await admin.get("button").trigger("click");
await flushPromises();
expect(initializeLedgers).toHaveBeenCalledOnce();
expect(admin.findAll(".ledger-card")).toHaveLength(1);
admin.unmount();
});
it("shows a recoverable error instead of misreporting failed authorization loading as no ledgers", async () => {
vi.mocked(fetchLedgers).mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [ledger] } as any);
const wrapper = render();
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain("台账加载失败");
await wrapper.get("button").trigger("click");
await flushPromises();
expect(wrapper.findAll(".ledger-card")).toHaveLength(1);
wrapper.unmount();
});
});
@@ -0,0 +1,128 @@
<template>
<section class="ledger-panel" aria-labelledby="ledger-heading">
<header class="ledger-heading">
<span class="ledger-eyebrow">Collaborative Ledgers</span>
<div class="entry-title-row">
<h2 id="ledger-heading">协作台账</h2>
<EntryCountBadge v-if="ledgers.length">{{ ledgers.length }} 本可访问台账</EntryCountBadge>
</div>
</header>
<div v-if="loading" class="ledger-state" role="status">正在加载台账…</div>
<div v-else-if="error" class="ledger-state" role="alert">
<p>{{ error }}</p><el-button @click="load">重新加载</el-button>
</div>
<div v-else-if="!ledgers.length" class="ledger-state">
<p>{{ isAdmin ? '启用两本医学事务部台账,开始在线协作。' : '暂未获得台账访问权限,请联系管理员授权。' }}</p>
<el-button v-if="isAdmin" type="primary" :loading="initializing" @click="initialize">初始化两本台账</el-button>
</div>
<div v-else class="ledger-grid">
<article v-for="ledger in ledgers" :key="ledger.id" class="ledger-card">
<div class="ledger-card-top">
<div class="ledger-title-stack">
<h3 :title="displayTitle(ledger.title)">{{ displayTitle(ledger.title) }}</h3>
<p v-if="ledger.description?.trim()" class="ledger-description" :title="ledger.description">{{ ledger.description }}</p>
</div>
<span class="ledger-access" :class="{ 'is-readonly': !ledger.can_edit }">
{{ ledger.status === 'ARCHIVED' ? '已归档 · 只读' : ledger.can_edit ? '可编辑' : '只读' }}
</span>
</div>
<dl><div><dt>负责人</dt><dd>{{ ledger.owner_name }}</dd></div>
<div><dt>最近保存</dt><dd>{{ formatTime(ledger.current_revision_created_at) }}</dd></div></dl>
<footer>
<button class="ledger-open" type="button" @click="open(ledger)">打开台账 <el-icon><ArrowRight /></el-icon></button>
<el-dropdown v-if="ledger.can_manage" trigger="click" @command="handleManagementCommand($event, ledger)">
<el-button link>台账管理 <el-icon><ArrowDown /></el-icon></el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item command="info">信息维护</el-dropdown-item>
<el-dropdown-item command="access">访问与权限</el-dropdown-item>
<el-dropdown-item command="history">历史版本</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
<el-button v-else link @click="openManagement('history', ledger)">历史版本</el-button>
</footer>
</article>
</div>
<LedgerInfoDialog v-if="management?.section === 'info'" :ledger="management.ledger" @close="management = null" @changed="load" />
<CollaborationAccessDialog v-if="management?.section === 'access'" :file="management.ledger" :target="{ scope: 'ledger' }"
@close="management = null" @changed="load" />
<LedgerHistoryDialog v-if="management?.section === 'history'" :ledger="management.ledger" @close="management = null" @changed="load" @updated="updateLedgerSummary" />
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ArrowDown, ArrowRight } from "@element-plus/icons-vue";
import { fetchLedgers, initializeLedgers } from "../../api/ledgers";
import { useAuthStore } from "../../store/auth";
import { isSystemAdmin } from "../../utils/roles";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import type { Ledger } from "../../types/ledger";
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
import LedgerInfoDialog from "./LedgerInfoDialog.vue";
import LedgerHistoryDialog from "./LedgerHistoryDialog.vue";
import CollaborationAccessDialog from "../collaboration/CollaborationAccessDialog.vue";
import EntryCountBadge from "../EntryCountBadge.vue";
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => isSystemAdmin(auth.user));
const ledgers = ref<Ledger[]>([]);
const updateLedgerSummary = (file: CollaborationHistoryFile) => {
ledgers.value = ledgers.value.map((ledger) => ledger.id === file.id ? { ...ledger, ...file } as Ledger : ledger);
};
const loading = ref(true);
const initializing = ref(false);
const error = ref("");
type ManagementSection = "info" | "access" | "history";
const management = ref<{ section: ManagementSection; ledger: Ledger } | null>(null);
const openManagement = (section: ManagementSection, ledger: Ledger) => { management.value = { section, ledger }; };
const handleManagementCommand = (command: string, ledger: Ledger) => {
if (command === "info" || command === "access" || command === "history") openManagement(command, ledger);
};
const displayTitle = (title: string) => title.replace(/\.xlsx$/i, "");
const formatTime = (value: string | null) => value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "尚未保存";
const open = (ledger: Ledger) => router.push({ name: "LedgerWorkspace", params: { fileId: ledger.id } });
const load = async () => {
loading.value = true;
error.value = "";
try { ledgers.value = (await fetchLedgers()).data; }
catch (cause) { ledgers.value = []; error.value = await getApiErrorMessage(cause, "台账加载失败"); }
finally { loading.value = false; }
};
const initialize = async () => {
if (initializing.value) return;
initializing.value = true;
try { ledgers.value = (await initializeLedgers()).data; }
catch (cause) { error.value = await getApiErrorMessage(cause, "台账初始化失败"); }
finally { initializing.value = false; }
};
onMounted(load);
</script>
<style scoped>
.ledger-panel { margin-top: 44px; padding-bottom: 28px; }
.ledger-heading { margin-bottom: 26px; }
.ledger-eyebrow { color: #387cf4; font-size: 11px; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; }
.ledger-heading h2 { margin: 0; color: #162033; font-size: 24px; }
.ledger-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.ledger-card { display: flex; flex-direction: column; min-width: 0; background: #fff; border: 1px solid rgba(226, 232, 240, 0.85); border-radius: 12px; box-shadow: 0 4px 15px rgba(15, 23, 42, 0.01), 0 1px 2px rgba(15, 23, 42, 0.02); }
.ledger-card-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; margin-bottom: 16px; }
.ledger-title-stack { display: flex; flex-direction: column; min-width: 0; }
.ledger-access { display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 8px; border-radius: 4px; color: #218266; background: #edfaf5; font-size: 10px; font-weight: 700; white-space: nowrap; }
.ledger-access.is-readonly { color: #667289; background: #f1f4f8; }
.ledger-card h3 { margin: 0; font-size: 14.5px; line-height: 18px; font-weight: 800; color: #0f172a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-description { color: #64748b; font-size: 10.5px; line-height: 13px; font-weight: 700; letter-spacing: 0.01em; margin: 1px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-card dl { display: flex; flex-direction: column; gap: 5px; font-size: 11.5px; margin: 0 0 14px; }
.ledger-card dl > div { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.ledger-card dt { color: #64748b; font-weight: 600; flex-shrink: 0; }
.ledger-card dd { margin: 0; color: #0f172a; font-weight: 550; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ledger-card footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid rgba(241, 245, 249, 0.85); margin-top: auto; padding-top: 10px; }
.ledger-open { display: flex; align-items: center; gap: 10px; border: 0; padding: 0; background: transparent; color: #1e3a8a; font: inherit; font-size: 11px; font-weight: 800; cursor: pointer; }
.ledger-open .el-icon { width: 20px; height: 20px; border-radius: 50%; background: rgba(59, 130, 246, 0.06); color: #2563eb; }
.ledger-card footer .el-button { height: 20px; padding: 0; font-size: 11px; }
.ledger-open:focus-visible { outline: 2px solid #387cf4; outline-offset: 4px; }
.ledger-state { padding: 28px; background: #fff; border: 1px dashed #d6e1ef; border-radius: 14px; color: #738199; font-size: 14px; }
.ledger-state p { margin: 0 0 14px; }
:global(.desktop-entry .ledger-card) { border-radius: 8px; }
</style>
+5 -2
View File
@@ -14,7 +14,10 @@ describe("admin project route permissions", () => {
expect(source).toContain('name: "OfficeVersionPreview"');
expect(source).toContain('path: "office-preview/collaboration/:fileId/revision/:id"');
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(3);
expect(source).toContain('path: "/ledgers/:fileId/revisions/:id/preview"');
expect(source).toContain('name: "LedgerRevisionPreview"');
expect(source).toContain('meta: { title: "台账历史版本预览", ledgerWorkspace: true }');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(4);
expect(source.match(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
expect(source.match(/transientWorkspaceTask: true/g)?.length).toBeGreaterThanOrEqual(2);
});
@@ -139,7 +142,7 @@ describe("admin project route permissions", () => {
expect(source).toContain("const canAccessProjectManagement = studyStore.currentStudyRole === \"PM\"");
expect(source).toContain("if (!canAccessProjectManagement)");
expect(source).toContain('next({ path: DESKTOP_PROJECT_ENTRY_PATH });');
expect(source).toContain("if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy)");
expect(source).toContain("if (token && (isWorkbenchEntryPath(to.path) || to.meta.ledgerWorkspace) && studyStore.currentStudy)");
expect(source).not.toContain("if (token && !studyStore.currentStudy && !isDesktopRuntime)");
expect(source).not.toContain("ensureDefaultActiveStudy();");
expect(source).not.toContain("ensureDefaultStudy();");
+13 -1
View File
@@ -121,6 +121,18 @@ const routes: RouteRecordRaw[] = [
component: WebWorkbenchEntry,
meta: { title: "工作台入口" },
},
{
path: "/ledgers/:fileId",
name: "LedgerWorkspace",
component: CollaborationWorkspace,
meta: { title: "协作台账", ledgerWorkspace: true },
},
{
path: "/ledgers/:fileId/revisions/:id/preview",
name: "LedgerRevisionPreview",
component: OfficePreviewWorkspace,
meta: { title: "台账历史版本预览", ledgerWorkspace: true },
},
{
path: DESKTOP_PROJECT_ENTRY_PATH,
name: "DesktopProjectEntry",
@@ -641,7 +653,7 @@ router.beforeEach(async (to, _from, next) => {
studyStore.rememberCurrentStudyForUser(auth.user.email);
}
if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy) {
if (token && (isWorkbenchEntryPath(to.path) || to.meta.ledgerWorkspace) && studyStore.currentStudy) {
studyStore.clearCurrentStudy();
}
@@ -41,6 +41,7 @@ const createStorage = (): Storage => {
describe("secure session storage", () => {
beforeEach(() => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "");
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
resetSecureSessionStorageForTests();
@@ -53,6 +54,7 @@ describe("secure session storage", () => {
});
afterEach(() => {
vi.unstubAllEnvs();
vi.useRealTimers();
resetSecureSessionStorageForTests();
localStorage.clear();
@@ -158,6 +160,25 @@ describe("secure session storage", () => {
expect(invokeMock).not.toHaveBeenCalled();
});
it("restores credentials for the build default when no desktop server override is stored", async () => {
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
localStorage.removeItem(DESKTOP_SERVER_URL_KEY);
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockResolvedValue(JSON.stringify({
version: 1,
token,
storedAt: Date.now(),
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
}));
await initializeSecureSessionStorage();
expect(getSessionToken()).toBe(token);
expect(invokeMock).toHaveBeenCalledExactlyOnceWith("credential_get", {
serverOrigin: "https://default.ctms.example.com/",
});
});
it("clears the previous server credential after a desktop server switch", async () => {
const previousServerOrigin = "https://old.ctms.example.com/";
const nextServerOrigin = "https://new.ctms.example.com/";
+45
View File
@@ -0,0 +1,45 @@
/* 工作台的项目和台账共用尺寸,说明文字是否存在不影响卡片高度。 */
:is(.web-entry, .desktop-entry) {
--entry-card-height: 172px;
}
:is(.web-entry, .desktop-entry) .entry-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 4px;
}
/* 项目显示第三行资料时,所有卡片一起增加一行的空间。 */
:is(.web-entry, .desktop-entry):has(.card-meta-list > .meta-item:nth-child(3)) {
--entry-card-height: 193px;
}
:is(.web-entry, .desktop-entry) :is(.modern-project-card, .ledger-card, .skeleton-project-card) {
box-sizing: border-box;
height: var(--entry-card-height);
min-height: var(--entry-card-height);
padding: 20px;
font-family: Arial, sans-serif;
}
:is(.web-entry, .desktop-entry) :is(.card-top-info, .ledger-card-top) {
min-height: 32px;
flex-shrink: 0;
}
:is(.web-entry, .desktop-entry) .project-title-stack small {
overflow: hidden;
line-height: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
:is(.web-entry, .desktop-entry) .project-title-stack strong {
line-height: 18px;
}
:is(.web-entry, .desktop-entry) :is(.card-meta-list, .ledger-card dl) {
line-height: 16px;
}
+48
View File
@@ -0,0 +1,48 @@
export type LedgerRole = "VIEWER" | "EDITOR" | "MANAGER";
export interface Ledger {
id: string;
title: string;
description: string;
status: "ACTIVE" | "ARCHIVED";
owner_id: string;
owner_name: string;
generation: number;
updated_at: string;
current_revision_id: string | null;
current_revision_no: number | null;
current_revision_file_size: number | null;
current_revision_mime_type: string | null;
current_revision_created_at: string | null;
role: LedgerRole;
can_edit: boolean;
can_manage: boolean;
can_export: boolean;
allow_export: boolean;
allow_edit_request: boolean;
allow_sheet_structure_edit: boolean;
file_type: "cell";
extension: "xlsx";
can_request_edit: boolean;
edit_request_status?: "PENDING" | "APPROVED" | "REJECTED" | null;
can_transfer_ownership: boolean;
}
export type LedgerSettings = Pick<Ledger,
"title" | "description" | "status" | "owner_id" | "allow_export" | "allow_sheet_structure_edit" | "generation"
>;
export interface LedgerCandidate {
user_id: string;
full_name: string;
email: string;
role_in_study: string;
can_be_editor: boolean;
can_be_manager: boolean;
}
export interface LedgerMember extends Pick<LedgerCandidate, "user_id" | "full_name" | "email"> {
role: LedgerRole;
is_active: boolean;
protected_label?: string | null;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision";
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision" | "ledger_revision";
export interface OnlyOfficePreviewConfig {
resource_type: OnlyOfficeResourceType;
+9 -45
View File
@@ -53,14 +53,10 @@
<div class="project-header">
<div class="header-title-block">
<span class="section-eyebrow">Project Workspace</span>
<h2>选择目标项目</h2>
</div>
<div class="header-actions-block">
<span v-if="!loading && projects.length" class="project-pill-badge">
<span class="badge-dot"></span>
<span>{{ projects.length }} 个可进入项目</span>
</span>
<div class="entry-title-row">
<h2>选择目标项目</h2>
<EntryCountBadge v-if="!loading && projects.length">{{ projects.length }} 个可进入项目</EntryCountBadge>
</div>
</div>
</div>
@@ -130,6 +126,7 @@
<h3>未检测到可用项目</h3>
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
</div>
<LedgerPanel />
</main>
<!-- 科技感转场遮罩 -->
@@ -157,6 +154,9 @@ import type { Study } from "../types/api";
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
import { isSystemAdmin } from "../utils/roles";
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
import LedgerPanel from "../components/ledgers/LedgerPanel.vue";
import EntryCountBadge from "../components/EntryCountBadge.vue";
import "../styles/entry-cards.css";
const auth = useAuthStore();
const studyStore = useStudyStore();
@@ -508,7 +508,7 @@ onMounted(() => {
}
.header-title-block h2 {
margin: 2px 0 0;
margin: 0;
color: #0f172a;
font-size: 22px;
font-weight: 800;
@@ -543,33 +543,6 @@ onMounted(() => {
content: "";
}
.header-actions-block {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.project-pill-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
font-size: 11.5px;
font-weight: 700;
}
.badge-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #3b82f6;
}
.btn-refresh-work {
min-width: 76px;
height: 28px;
@@ -592,8 +565,6 @@ onMounted(() => {
position: relative;
display: flex;
flex-direction: column;
min-height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
background: #ffffff;
@@ -798,8 +769,6 @@ onMounted(() => {
/* 骨架屏 */
.skeleton-project-card {
height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
background: #ffffff;
@@ -930,11 +899,6 @@ onMounted(() => {
color: #e2e8f0;
}
:global([data-ctms-theme="dark"] .project-pill-badge) {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
:global([data-ctms-theme="dark"] .modern-project-card) {
background: rgba(15, 23, 42, 0.55);
border-color: rgba(51, 65, 85, 0.5);
+14 -4
View File
@@ -1,5 +1,5 @@
<template>
<section class="office-preview-page">
<section class="office-preview-page" :class="{ 'is-ledger-preview': resourceType === 'ledger_revision' }">
<header class="office-preview-page__header">
<button
class="office-preview-page__back"
@@ -44,9 +44,10 @@ import {
fetchCollaborationRevisionOnlyOfficeConfig,
fetchVersionOnlyOfficeConfig,
} from "../api/onlyoffice";
import { fetchLedgerRevisionPreview } from "../api/ledgers";
import { useStudyStore } from "../store/study";
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH, isTauriRuntime } from "../runtime";
import { getApiErrorMessage } from "../utils/apiErrorMessage";
import { workspaceTaskControllerKey } from "../components/layout/workspaceTaskController";
@@ -67,6 +68,7 @@ let mounted = false;
const resourceType = computed<OnlyOfficeResourceType>(() => {
if (route.name === "OfficeAttachmentPreview") return "attachment";
if (route.name === "LedgerRevisionPreview") return "ledger_revision";
if (route.name === "OfficeCollaborationRevisionPreview") return "collaboration_revision";
return "version";
});
@@ -111,6 +113,8 @@ const loadConfig = async () => {
try {
const response = resourceType.value === "attachment"
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
: resourceType.value === "ledger_revision"
? await fetchLedgerRevisionPreview(collaborationFileId.value, resourceId.value)
: resourceType.value === "collaboration_revision"
? await fetchCollaborationRevisionOnlyOfficeConfig(
study.currentStudy?.id || "",
@@ -124,7 +128,7 @@ const loadConfig = async () => {
}
previewConfig.value = response.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
if (resourceType.value !== "ledger_revision") study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
} catch (error) {
if (sequence !== requestSequence.value) return;
errorMessage.value = await errorForResponse(error);
@@ -167,6 +171,10 @@ const handleServerChange = () => {
const goBack = () => {
if (workspaceTaskController?.closeTransientTask(route.path)) return;
if (resourceType.value === "ledger_revision") {
void router.push(isTauriRuntime() ? "/desktop/project-entry" : "/workbench");
return;
}
router.back();
};
@@ -189,7 +197,7 @@ onDeactivated(() => {
onBeforeUnmount(() => {
destroyViewer();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
if (resourceType.value !== "ledger_revision") study.setViewContext(null);
});
</script>
@@ -206,6 +214,8 @@ onBeforeUnmount(() => {
background: #f3f5f8;
}
.office-preview-page.is-ledger-preview { height: 100vh; height: 100dvh; }
.office-preview-page__header {
z-index: 1;
display: grid;
+11 -47
View File
@@ -53,14 +53,10 @@
<div class="project-header">
<div class="header-title-block">
<span class="section-eyebrow">Project Workspace</span>
<h2>选择目标项目</h2>
</div>
<div class="header-actions-block">
<span v-if="!loading && projects.length" class="project-pill-badge">
<span class="badge-dot"></span>
<span>{{ projects.length }} 个可进入项目</span>
</span>
<div class="entry-title-row">
<h2>选择目标项目</h2>
<EntryCountBadge v-if="!loading && projects.length">{{ projects.length }} 个可进入项目</EntryCountBadge>
</div>
</div>
</div>
@@ -155,6 +151,7 @@
<h3>未检测到可用项目</h3>
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
</div>
<LedgerPanel />
</main>
<!-- 科技感转场遮罩 -->
@@ -191,6 +188,9 @@ import type { Study } from "../types/api";
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
import { isSystemAdmin } from "../utils/roles";
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
import LedgerPanel from "../components/ledgers/LedgerPanel.vue";
import EntryCountBadge from "../components/EntryCountBadge.vue";
import "../styles/entry-cards.css";
const auth = useAuthStore();
const studyStore = useStudyStore();
@@ -327,7 +327,7 @@ onMounted(() => {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 40px 28px;
padding: 24px 28px 40px;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
border-right: 1px solid #e2e8f0;
box-shadow: 6px 0 30px rgba(15, 23, 42, 0.02);
@@ -537,7 +537,7 @@ onMounted(() => {
z-index: 5;
display: flex;
flex-direction: column;
padding: 48px 48px 48px 40px;
padding: 24px 48px 48px 40px;
overflow-y: auto;
}
@@ -550,7 +550,7 @@ onMounted(() => {
}
.header-title-block h2 {
margin: 2px 0 0;
margin: 0;
color: #0f172a;
font-size: 22px;
font-weight: 800;
@@ -577,33 +577,6 @@ onMounted(() => {
content: "";
}
.header-actions-block {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.project-pill-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #1d4ed8;
font-size: 11.5px;
font-weight: 700;
}
.badge-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #3b82f6;
}
/* 网页端特有步骤条 - 已根据用户要求隐藏,保留 HTML 结构供测试匹配断言 */
.workflow-strip {
display: none !important;
@@ -663,8 +636,6 @@ onMounted(() => {
position: relative;
display: flex;
flex-direction: column;
min-height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 12px;
background: #ffffff;
@@ -870,8 +841,6 @@ onMounted(() => {
/* 骨架屏 */
.skeleton-project-card {
height: 168px;
padding: 20px;
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 8px;
background: #ffffff;
@@ -998,11 +967,6 @@ onMounted(() => {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .project-pill-badge) {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
:global([data-ctms-theme="dark"] .workflow-item) {
background: rgba(15, 23, 42, 0.45);
border-color: rgba(51, 65, 85, 0.4);
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
const source = ["./CollaborationLibrary.vue", "../../components/collaboration/CollaborationAccessDialog.vue", "../../components/collaboration/CollaborationHistoryDialog.vue"]
.map((file) => readFileSync(resolve(__dirname, file), "utf8")).join("\n");
const accessApiSource = readFileSync(resolve(__dirname, "../../api/collaborationAccess.ts"), "utf8");
const historyApiSource = readFileSync(resolve(__dirname, "../../api/collaborationHistory.ts"), "utf8");
const librarySource = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
describe("CollaborationLibrary UI contract", () => {
it("opens the create dialog directly from the primary sidebar action", () => {
@@ -73,16 +77,17 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain("R${");
expect(source).toContain("downloadCollaborationFile");
expect(source).toContain("downloadFileWithFeedback(");
expect(source).not.toContain("prepareSaveFile");
expect(librarySource).not.toContain("prepareSaveFile");
});
it("refreshes history immediately after restoring a revision", () => {
expect(source).toContain("const restoringRevisionId = ref<string | null>(null)");
expect(source).toContain(':loading="restoringRevisionId === row.id"');
expect(source).toContain("const { data: restored } = await restoreCollaborationRevision");
expect(source).toContain("const { data: restored } = await api.restore");
expect(historyApiSource).toContain("restoreCollaborationRevision(target.studyId");
expect(source).toContain("current_revision_id: restoredForDisplay.id");
expect(source).toContain("...revisions.value.filter((item) => item.id !== restoredForDisplay.id)");
expect(source).toContain("const [revisionResponse] = await Promise.all([");
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
expect(source).toContain("if (refreshedFile) historyFile.value = refreshedFile");
});
@@ -92,7 +97,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("scheduleHistoryRefresh();");
expect(source).toContain("refreshHistory({ silent: true })");
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
expect(source).toContain("fetchCollaborationFile(requireStudyId(), fileId)");
expect(historyApiSource).toContain("fetchCollaborationFile(target.studyId, id)");
expect(source).toContain("historyFile.value = fileResponse.data");
expect(source).toContain("requestSequence !== historyRequestSequence");
});
@@ -119,11 +124,11 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("nameRevision(row)");
expect(source).toContain("previewRevision(row)");
expect(source).toContain("saveRevisionAs(row)");
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source).toContain('"LedgerRevisionPreview" : "OfficeCollaborationRevisionPreview"');
expect(source).toContain('window.open(previewRoute.href, "_blank", "noopener,noreferrer")');
expect(source).not.toContain("historyDialogVisible.value = false");
expect(source).toContain("copyCollaborationRevision");
expect(source).toContain("updateCollaborationRevision");
expect(historyApiSource).toContain("updateCollaborationRevision");
expect(source).toContain('v-model="revisionSaveAsDialogVisible"');
expect(source).toContain("<CollaborationSaveAsDialog");
expect(source).toContain(':initial-folder-id="revisionSaveAsFolderId"');
@@ -134,7 +139,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain('PERMISSION_CHANGE: "权限设置"');
expect(source).toContain('command="delete"');
expect(source).toContain("删除版本");
expect(source).toContain('v-if="historyFile?.can_manage" command="delete"');
expect(source).toContain('v-if="target.scope === \'project\' && historyFile?.can_manage" command="delete"');
expect(source).toContain("deleteCollaborationRevision");
expect(source).toContain("revision.id === historyFile.value.current_revision_id");
expect(source).toContain("height: min(760px, 82vh)");
@@ -153,7 +158,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("<span>管理协作者</span>");
expect(source).toContain('class="access-member-identity"');
expect(source).toContain('class="access-member-role"');
expect(source).toContain('class="access-member-owner">所有者</span>');
expect(source).toContain('class="access-member-owner">{{ member.protected_label || "所有者" }}</span>');
expect(source).not.toContain('<el-table :data="members"');
expect(source).toContain('v-model="memberInviteDialogVisible"');
expect(source).toContain('title="管理协作者"');
@@ -162,7 +167,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain('member-picker-group--recent');
expect(source).not.toContain('member-picker-recent-empty');
expect(source).not.toContain('最近选择的账号将显示在这里');
expect(source).toContain('role="listbox" aria-label="可添加的项目成员"');
expect(source).toContain('role="listbox" :aria-label="`可添加的${directoryLabel}`"');
expect(source).toContain("memberForm.user_ids.includes(candidate.user_id)");
expect(source).toContain("toggleMemberCandidate(candidate.user_id)");
expect(source).toContain("selectedMemberCandidates.length");
@@ -191,7 +196,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain("允许申请编辑权限");
expect(source).toContain("handleEditRequestPermissionChange");
expect(source).toContain("待处理申请");
expect(source).toContain("resolveCollaborationEditRequest");
expect(accessApiSource).toContain("resolveCollaborationEditRequest");
expect(source).toContain("notifyProjectNotificationsChanged");
expect(source).toContain("route.query.editRequestFile");
expect(source).toContain("openEditRequestNotificationTarget");
@@ -221,7 +226,7 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).toContain(':disabled="!selectedTransferCandidate"');
expect(source).toContain('>确定</el-button>');
expect(source).not.toContain('accessPanel === "transfer"');
expect(source).toContain("transferCollaborationOwnership");
expect(accessApiSource).toContain("transferCollaborationOwnership");
});
it("saves permission switches silently while retaining failure feedback", () => {
@@ -266,8 +271,8 @@ describe("CollaborationLibrary UI contract", () => {
expect(source).not.toContain("同时约束账号授权和匿名访问");
expect(source).toContain('@change="persistShareSettings()"');
expect(source).not.toContain("保存链接设置");
expect(source).toContain("fetchCollaborationShareLink");
expect(source).toContain("updateCollaborationShareLink");
expect(accessApiSource).toContain("fetchCollaborationShareLink");
expect(accessApiSource).toContain("updateCollaborationShareLink");
expect(source).not.toContain("regenerateCollaborationShareLink");
expect(source).not.toContain("重新生成链接");
expect(source).not.toContain("关闭后重新开启仍使用同一地址");
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@
'is-fullscreen': webFullscreenActive,
'is-fullscreen-toolbar-visible': fullscreenToolbarVisible,
'is-macos-desktop': isMacDesktop,
'is-ledger-workspace': isLedgerWorkspace,
}"
>
<div
@@ -83,6 +84,7 @@
</div>
</main>
<CollaborationSaveAsDialog
v-if="!isLedgerWorkspace"
v-model="saveAsDialogVisible"
:study-id="studyId"
:initial-title="saveAsTitle"
@@ -117,6 +119,8 @@ import {
recordCollaborationDownload,
} from "../../api/collaboration";
import { useStudyStore } from "../../store/study";
import { checkLedgerDownload, createLedgerEditRequest, fetchLedger, fetchLedgerEditorConfig } from "../../api/ledgers";
import type { Ledger } from "../../types/ledger";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getRuntimePlatform,
@@ -142,7 +146,10 @@ const router = useRouter();
const study = useStudyStore();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const { can } = usePermission();
const file = ref<CollaborationFile | null>(null);
const isLedgerWorkspace = computed(() => route.meta.ledgerWorkspace === true);
type WorkspaceFile = Pick<CollaborationFile, "title" | "can_edit"> & Partial<Pick<CollaborationFile, "folder_id" | "edit_request_status" | "can_request_edit">>;
const file = ref<WorkspaceFile | null>(null);
const ledgerSnapshot = ref<Ledger | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const errorMessage = ref("");
const warningMessage = ref("");
@@ -166,6 +173,8 @@ const isMacDesktop = isTauriRuntime() && getRuntimePlatform() === "macos";
let mounted = false;
let webFullscreenUnlisten: (() => void) | null = null;
let fullscreenToolbarHideTimer: number | undefined;
let ledgerCheckTimer: number | undefined;
let checkingLedger = false;
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
@@ -278,7 +287,7 @@ const destroyEditor = () => {
};
const loadWorkspace = async () => {
if (!studyId.value || !fileId.value) return;
if ((!isLedgerWorkspace.value && !studyId.value) || !fileId.value) return;
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
destroyEditor();
@@ -287,17 +296,19 @@ const loadWorkspace = async () => {
warningMessage.value = "";
loading.value = true;
status.value = "loading";
ledgerSnapshot.value = null;
try {
const [fileResponse, configResponse] = await Promise.all([
fetchCollaborationFile(studyId.value, fileId.value),
fetchCollaborationEditorConfig(studyId.value, fileId.value),
isLedgerWorkspace.value ? fetchLedger(fileId.value) : fetchCollaborationFile(studyId.value, fileId.value),
isLedgerWorkspace.value ? fetchLedgerEditorConfig(fileId.value) : fetchCollaborationEditorConfig(studyId.value, fileId.value),
]);
if (requestSequence.value !== sequence) return;
if (configResponse.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
file.value = fileResponse.data;
editorConfig.value = configResponse.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
if (isLedgerWorkspace.value) ledgerSnapshot.value = fileResponse.data as Ledger;
else study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
@@ -307,6 +318,29 @@ const loadWorkspace = async () => {
}
};
const checkLedgerSession = async () => {
if (!isLedgerWorkspace.value || !editorConfig.value || !ledgerSnapshot.value || loading.value || checkingLedger) return;
const sequence = requestSequence.value;
checkingLedger = true;
try {
const { data } = await fetchLedger(fileId.value);
if (sequence !== requestSequence.value) return;
if (data.generation !== ledgerSnapshot.value?.generation || data.role !== ledgerSnapshot.value.role) {
destroyEditor();
errorMessage.value = "台账权限或内容版本已更新,请重新打开台账";
status.value = "error";
} else {
file.value = data;
if (editorConfig.value) editorConfig.value.can_request_edit = data.can_request_edit;
}
} catch (error) {
if (sequence !== requestSequence.value) return;
destroyEditor();
errorMessage.value = await getApiErrorMessage(error, "无法确认台账访问权限,请检查连接后重试");
status.value = "error";
} finally { checkingLedger = false; }
};
const eventMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
@@ -332,7 +366,8 @@ const requestEditRights = async () => {
if (requestingEdit.value || accessMode.value === "edit" || file.value?.edit_request_status === "PENDING") return;
requestingEdit.value = true;
try {
await createCollaborationEditRequest(studyId.value, fileId.value);
if (isLedgerWorkspace.value) await createLedgerEditRequest(fileId.value);
else await createCollaborationEditRequest(studyId.value, fileId.value);
if (file.value) {
file.value = { ...file.value, can_request_edit: false, edit_request_status: "PENDING" };
}
@@ -409,6 +444,7 @@ const savePendingDownload = async () => {
}
return;
}
if (isLedgerWorkspace.value) await checkLedgerDownload(fileId.value, extension);
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
@@ -424,7 +460,7 @@ const savePendingDownload = async () => {
pendingDownload.value = null;
downloadTitle.value = "";
try {
await recordCollaborationDownload(studyId.value, fileId.value, extension);
if (!isLedgerWorkspace.value) await recordCollaborationDownload(studyId.value, fileId.value, extension);
} catch {
ElMessage.warning("文件已下载,但下载审计记录失败");
}
@@ -464,6 +500,10 @@ const handleServerChange = () => {
const goBack = async () => {
await exitWorkspaceFullscreen();
if (isLedgerWorkspace.value) {
await router.push(isTauriRuntime() ? "/desktop/project-entry" : "/workbench");
return;
}
if (workspaceTaskController?.closeTransientTask(route.path)) return;
await router.push("/knowledge/collaboration");
};
@@ -474,6 +514,8 @@ onMounted(() => {
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
ledgerCheckTimer = window.setInterval(() => { void checkLedgerSession(); }, 15000);
window.addEventListener("focus", checkLedgerSession);
void loadWorkspace();
});
onActivated(() => {
@@ -487,14 +529,17 @@ onDeactivated(() => {
errorMessage.value = "";
});
onBeforeUnmount(() => {
if (ledgerCheckTimer !== undefined) window.clearInterval(ledgerCheckTimer);
window.removeEventListener("focus", checkLedgerSession);
cancelFullscreenToolbarHide();
webFullscreenUnlisten?.();
webFullscreenUnlisten = null;
void exitWorkspaceFullscreen();
destroyEditor();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
if (!isLedgerWorkspace.value) study.setViewContext(null);
});
watch(fileId, () => { if (mounted) void loadWorkspace(); });
</script>
<style scoped>
@@ -511,6 +556,7 @@ onBeforeUnmount(() => {
background: #f3f5f8;
}
.collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }
.collaboration-workspace.is-ledger-workspace { height: 100dvh; }
.collaboration-workspace__fullscreen-hotzone {
position: absolute;
z-index: 20;
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import Workspace from "./CollaborationWorkspace.vue";
import { createLedgerEditRequest, fetchLedger, fetchLedgerEditorConfig } from "../../api/ledgers";
import { createCollaborationEditRequest, fetchCollaborationFile } from "../../api/collaboration";
const state = vi.hoisted(() => ({ push: vi.fn(), setViewContext: vi.fn() }));
vi.mock("vue-router", () => ({
useRoute: () => ({ meta: { ledgerWorkspace: true }, params: { fileId: "ledger-a" }, path: "/ledgers/ledger-a" }),
useRouter: () => ({ push: state.push }),
}));
vi.mock("../../store/study", () => ({ useStudyStore: () => ({ currentStudy: null, setViewContext: state.setViewContext }) }));
vi.mock("../../utils/permission", () => ({ usePermission: () => ({ can: () => false }) }));
vi.mock("../../utils/fileTaskFeedback", () => ({ saveFileWithFeedback: vi.fn() }));
vi.mock("../../api/ledgers", () => ({ fetchLedger: vi.fn(), fetchLedgerEditorConfig: vi.fn(), checkLedgerDownload: vi.fn(), createLedgerEditRequest: vi.fn() }));
vi.mock("../../api/collaboration", () => ({
fetchCollaborationFile: vi.fn(), fetchCollaborationEditorConfig: vi.fn(), createCollaborationEditRequest: vi.fn(),
importCollaborationFile: vi.fn(), recordCollaborationDownload: vi.fn(),
}));
vi.mock("../../runtime", () => ({
DESKTOP_SERVER_URL_CHANGED_EVENT: "server-changed", getRuntimePlatform: () => "web", isTauriRuntime: () => false,
isWebFullscreenActive: () => false, isWebFullscreenAvailable: () => false, listenWebFullscreenChange: () => () => {},
ONLYOFFICE_HOST_PATH: "/onlyoffice-host.html", prepareSaveFile: vi.fn(),
refreshWebFullscreenState: async () => {}, toggleWebFullscreen: vi.fn(),
}));
vi.mock("../../components/collaboration/CollaborationSaveAsDialog.vue", () => ({ default: { template: '<div />' } }));
vi.mock("../../components/collaboration/CollaborationDownloadDialog.vue", () => ({ default: { template: '<div />' } }));
vi.mock("../../components/OnlyOfficeViewer.vue", () => ({ default: { name: "OnlyOfficeViewer", props: ["config"], template: '<div data-test="editor" />' } }));
const ledger = { id: "ledger-a", title: "医学事务部临床运营项目编号.xlsx", can_edit: true, role: "EDITOR", generation: 4 };
let wrapper: ReturnType<typeof mount> | undefined;
const render = () => {
wrapper = mount(Workspace, { global: { stubs: {
ElButton: { template: '<button><slot /></button>' }, ElIcon: { template: '<span><slot /></span>' },
ElTooltip: { template: '<span><slot /></span>' },
} } });
return wrapper;
};
describe("ledger collaboration workspace", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(fetchLedger).mockResolvedValue({ data: ledger } as any);
vi.mocked(fetchLedgerEditorConfig).mockResolvedValue({ data: {
file_name: ledger.title, host_path: "/onlyoffice-host.html", access_mode: "edit", can_download: false,
can_save_as: false, can_request_edit: false, config: {},
} } as any);
});
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.useRealTimers(); });
it("uses global APIs and returns to the workbench with no project context", async () => {
const page = render();
await flushPromises();
expect(page.find('[data-test="editor"]').exists()).toBe(true);
expect(fetchLedgerEditorConfig).toHaveBeenCalledWith("ledger-a");
expect(fetchCollaborationFile).not.toHaveBeenCalled();
expect(state.setViewContext).not.toHaveBeenCalled();
await page.get(".collaboration-workspace__back").trigger("click");
expect(state.push).toHaveBeenCalledWith("/workbench");
});
it("destroys the old editor when permissions or generation change", async () => {
const page = render();
await flushPromises();
vi.mocked(fetchLedger).mockResolvedValue({ data: { ...ledger, generation: 5 } } as any);
await vi.advanceTimersByTimeAsync(15000);
await flushPromises();
expect(page.find('[data-test="editor"]').exists()).toBe(false);
expect(page.text()).toContain("台账权限或内容版本已更新");
});
it("removes the editor if access can no longer be verified", async () => {
const page = render();
await flushPromises();
vi.mocked(fetchLedger).mockRejectedValue(new Error("denied"));
await vi.advanceTimersByTimeAsync(15000);
await flushPromises();
expect(page.find('[data-test="editor"]').exists()).toBe(false);
expect(page.text()).toContain("无法打开协作文件");
});
it("submits a viewer edit request without project APIs and refreshes its pending status", async () => {
const viewer = { ...ledger, role: "VIEWER", can_edit: false, can_request_edit: true };
vi.mocked(fetchLedger).mockResolvedValue({ data: viewer } as any);
vi.mocked(fetchLedgerEditorConfig).mockResolvedValue({ data: {
file_name: ledger.title, host_path: "/onlyoffice-host.html", access_mode: "view", can_request_edit: true, config: {},
} } as any);
vi.mocked(createLedgerEditRequest).mockResolvedValue({ data: { status: "PENDING" } } as any);
const page = render();
await flushPromises();
await page.findAll("button").find((button) => button.text() === "申请编辑权限")!.trigger("click");
await flushPromises();
expect(createLedgerEditRequest).toHaveBeenCalledWith("ledger-a");
expect(createCollaborationEditRequest).not.toHaveBeenCalled();
expect(page.text()).toContain("编辑权限申请处理中");
vi.mocked(fetchLedger).mockResolvedValue({ data: { ...viewer, edit_request_status: "REJECTED" } } as any);
await vi.advanceTimersByTimeAsync(15000);
await flushPromises();
expect(page.text()).not.toContain("编辑权限申请处理中");
expect(page.find('[data-test="editor"]').exists()).toBe(true);
});
});
+2 -2
View File
@@ -3,8 +3,8 @@ 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.
# Use redistributable Noto CJK fonts for Chinese document preview. Do not add
# deployment-supplied proprietary Microsoft or Zhongyi fonts to this build.
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
+43 -7
View File
@@ -62,9 +62,9 @@ ${C_BOLD}环境:${C_RESET}
${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对)
${C_BOLD}选项:${C_RESET}
${C_WHITE}--base-url <url>${C_RESET} 健康检查使用的访问地址
${C_WHITE}--base-url <url>${C_RESET} 对外访问基址(同时用于健康检查与 ONLYOFFICE 回调校验)
dev/main 默认 http://127.0.0.1:8888
release 必须通过此参数或交互输入提供
已有环境优先复用 .env;首次 release 安装必须提供
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
${C_WHITE}--skip-build${C_RESET} 跳过镜像构建,仍会启动容器(docker compose up -d)
${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head)
@@ -179,16 +179,25 @@ generate_onlyoffice_instance_id() {
# ── 地址解析 ─────────────────────────────────
resolve_base_url() {
if [[ -z "$BASE_URL" && -f "$ENV_FILE" ]]; then
BASE_URL="$(read_env_value CTMS_BASE_URL || true)"
[[ -n "$BASE_URL" ]] || BASE_URL="$(read_env_value FRONTEND_PUBLIC_URL || true)"
fi
[[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then
printf ' %s▸%s 请输入 release 健康检查域名(例如 https://ctms.example.com): ' \
printf ' %s▸%s 请输入 release 对外访问基址(例如 https://ctms.example.com): ' \
"${C_YELLOW}${C_BOLD}" "${C_RESET}"
read -r BASE_URL || true
fi
[[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址"
BASE_URL="${BASE_URL%/}"
if [[ ! "$BASE_URL" =~ ^https?://[^/?#]+$ || "$BASE_URL" == *"@"* ]]; then
fail "对外访问基址必须是仅包含协议和主机的 HTTP(S) origin,例如 https://ctms.example.com"
fi
if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then
fail "release 环境的访问地址必须使用 HTTPS"
fi
@@ -222,7 +231,7 @@ confirm_install() {
row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}"
row "运行模式" "$runtime_env"
row "Compose 项目" "$project_name"
row "健康检查地址" "$BASE_URL" "${CC_INFO}"
row "对外访问基址" "$BASE_URL" "${CC_INFO}"
row ".env 文件" "$env_status"
row "pg_data 目录" "$pg_status"
row "ONLYOFFICE" "标准组件(自动安装)" "${CC_INFO}"
@@ -376,6 +385,23 @@ run_backend_init() {
fi
}
check_onlyoffice_active_sessions() {
step "检查 ONLYOFFICE 在线编辑会话"
local running output
running="$(compose_cmd ps --services --filter status=running 2>/dev/null || true)"
if ! printf '%s\n' "$running" | grep -qx "onlyoffice"; then
ok "ONLYOFFICE 尚未运行,跳过在线编辑会话检查"
return 0
fi
if output="$(compose_cmd run --rm --no-deps backend-init \
python scripts/check_onlyoffice_active_sessions.py 2>&1)"; then
ok "$output"
return 0
fi
printf '%s\n' "$output" >&2
fail "部署前置检查未通过;为避免在线文件保存失败,尚未重启任何业务服务"
}
run_build_and_start() {
if [[ "$SKIP_BUILD" -eq 1 ]]; then
step "启动服务(跳过镜像构建)"
@@ -423,7 +449,7 @@ check_container_status() {
check_backend_environment() {
step "校验后端运行时环境变量"
local expected_env="$1" expected_key_id="$2" expect_rsa="$3"
local expected_env="$1" expected_key_id="$2" expect_rsa="$3" expected_public_url="$4"
local check_code
check_code=$(cat <<'PY'
import os, sys
@@ -433,6 +459,7 @@ from app.core.login_crypto import _normalize_pem
expected_env = os.environ["EXPECTED_ENV"]
expected_key_id = os.environ["EXPECTED_KEY_ID"]
expect_rsa = os.environ["EXPECT_RSA"] == "1"
expected_public_url = os.environ["EXPECTED_PUBLIC_URL"].rstrip("/")
if settings.ENV != expected_env:
sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}")
@@ -448,6 +475,11 @@ if settings.ONLYOFFICE_JWT_SECRET == settings.JWT_SECRET_KEY:
sys.exit("ONLYOFFICE_JWT_SECRET 不得复用登录 JWT 密钥")
if not settings.ONLYOFFICE_INSTANCE_ID:
sys.exit("ONLYOFFICE_INSTANCE_ID 未配置")
if settings.FRONTEND_PUBLIC_URL.rstrip("/") != expected_public_url:
sys.exit(
"FRONTEND_PUBLIC_URL 不匹配;ONLYOFFICE 公开缓存地址会被保存回调拒绝: "
f"{settings.FRONTEND_PUBLIC_URL} != {expected_public_url}"
)
if expect_rsa:
if not settings.LOGIN_RSA_PRIVATE_KEY:
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
@@ -462,6 +494,7 @@ PY
-e EXPECTED_ENV="$expected_env" \
-e EXPECTED_KEY_ID="$expected_key_id" \
-e EXPECT_RSA="$expect_rsa" \
-e EXPECTED_PUBLIC_URL="$expected_public_url" \
backend python -c "$check_code"
}
@@ -508,7 +541,7 @@ run_health_checks() {
[[ "$runtime_env" == "production" ]] && expect_rsa=1
check_container_status
check_dev_nginx_mode
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa" "$BASE_URL"
step "探测 HTTP 接口可用性"
check_http_endpoint "/health"
check_http_endpoint "/readyz"
@@ -555,10 +588,13 @@ main() {
confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV"
prepare_env_file "$project_name" "$runtime_env" "$login_key_id"
sync_frontend_build_env "$runtime_env"
# 健康检查地址持久化到 .env(单一事实来源);独立 upsert,绕开 prepare_env_file 对已存在 .env 的跳过。
# 对外访问基址同时用于健康检查和后端校验 ONLYOFFICE 返回的公开缓存 URL。
# 两项必须同步;否则生产域名下的最终保存回调会被当作不受信任地址拒绝。
ctms_upsert_env_value CTMS_BASE_URL "$BASE_URL"
ctms_upsert_env_value FRONTEND_PUBLIC_URL "$BASE_URL"
run_compose_config
run_backend_init
check_onlyoffice_active_sessions
run_build_and_start
run_migrations
resolve_effective_config "$project_name" "$runtime_env" "$login_key_id"