diff --git a/backend/alembic/versions/20260903_01_add_global_ledgers.py b/backend/alembic/versions/20260903_01_add_global_ledgers.py new file mode 100644 index 00000000..c6e62d5c --- /dev/null +++ b/backend/alembic/versions/20260903_01_add_global_ledgers.py @@ -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) diff --git a/backend/app/api/v1/ledgers.py b/backend/app/api/v1/ledgers.py new file mode 100644 index 00000000..ce652050 --- /dev/null +++ b/backend/app/api/v1/ledgers.py @@ -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, + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index e9f0f25e..6f21143a 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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"]) diff --git a/backend/app/models/collaboration.py b/backend/app/models/collaboration.py index f1727b01..cda80a80 100644 --- a/backend/app/models/collaboration.py +++ b/backend/app/models/collaboration.py @@ -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 ) diff --git a/backend/app/schemas/ledger.py b/backend/app/schemas/ledger.py new file mode 100644 index 00000000..420f1d57 --- /dev/null +++ b/backend/app/schemas/ledger.py @@ -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) diff --git a/backend/app/services/collaboration_service.py b/backend/app/services/collaboration_service.py index 2eae4ea5..a3ee6ee5 100644 --- a/backend/app/services/collaboration_service.py +++ b/backend/app/services/collaboration_service.py @@ -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() diff --git a/backend/app/services/collaboration_share_service.py b/backend/app/services/collaboration_share_service.py index 3745f861..f8ad39da 100644 --- a/backend/app/services/collaboration_share_service.py +++ b/backend/app/services/collaboration_share_service.py @@ -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) diff --git a/backend/app/services/ledger_access.py b/backend/app/services/ledger_access.py new file mode 100644 index 00000000..793c8d85 --- /dev/null +++ b/backend/app/services/ledger_access.py @@ -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, "仅台账管理人员可以执行此操作") diff --git a/backend/app/services/ledger_service.py b/backend/app/services/ledger_service.py new file mode 100644 index 00000000..761f354d --- /dev/null +++ b/backend/app/services/ledger_service.py @@ -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) diff --git a/backend/app/services/onlyoffice_collaboration_service.py b/backend/app/services/onlyoffice_collaboration_service.py index 434461d8..653574ea 100644 --- a/backend/app/services/onlyoffice_collaboration_service.py +++ b/backend/app/services/onlyoffice_collaboration_service.py @@ -25,7 +25,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: @@ -81,6 +81,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 +160,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 +180,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 +249,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 @@ -326,7 +343,7 @@ 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 +361,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 +411,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 +425,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) @@ -404,6 +456,8 @@ async def process_callback( session.status = "ERROR" 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, diff --git a/backend/app/templates/ledgers/clinical-project-register.xlsx b/backend/app/templates/ledgers/clinical-project-register.xlsx new file mode 100644 index 00000000..c91bbaf9 Binary files /dev/null and b/backend/app/templates/ledgers/clinical-project-register.xlsx differ diff --git a/backend/app/templates/ledgers/contract-ledger.xlsx b/backend/app/templates/ledgers/contract-ledger.xlsx new file mode 100644 index 00000000..22a4c1f4 Binary files /dev/null and b/backend/app/templates/ledgers/contract-ledger.xlsx differ diff --git a/backend/tests/test_ledgers.py b/backend/tests/test_ledgers.py new file mode 100644 index 00000000..ad280099 --- /dev/null +++ b/backend/tests/test_ledgers.py @@ -0,0 +1,463 @@ +import io +import uuid +import zipfile +from datetime import 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()) + 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_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 diff --git a/docs/global-ledgers.md b/docs/global-ledgers.md new file mode 100644 index 00000000..7ee3eeda --- /dev/null +++ b/docs/global-ledgers.md @@ -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//`,不会进入项目目录。 + +设置/授权变化、编辑申请获批、所有权转让、匿名访问变更、导入、恢复推进文件代次;旧代次保存回调不覆盖新内容。台账回调在文件锁内复核代次、归档状态与保存账号有效编辑权限;匿名回调另行检查链接仍启用、未过期且允许编辑,不回退为所有者身份。客户端每 15 秒及窗口重新获得焦点时复核账号访问状态,权限/代次变化或验证失败时卸载编辑器并提示重新打开。台账 API 禁用缓存和自动网络重试,不使用离线存储。 + +信息维护、导入及历史恢复在更新前提示协作者先保存;访问与权限沿用项目界面的即时保存交互。变更代次会使原会话未保存内容无法继续写入,权限变更前应先保存内容。授权撤销立即阻断旧代次保存,已显示的账号编辑页面会在下一次客户端检查时关闭。历史预览沿用已有短时签名内部只读内容链路。 + +## 迁移与验证 + +迁移 `20260903_01` 位于 `20260716_05` 之后;已有文件默认为 `PROJECT`。首次创建台账后,降级迁移主动拒绝删除台账数据,应保留数据并前向修复。 + +验证覆盖模板逐部件保真、初始化幂等、独立账号授权、未授权 API、查看/编辑配置、文件和历史禁止删除、撤权/停用后的保存拒绝、回调幂等、归档、导入与恢复,以及历史命名和导出的角色限制、跨台账隔离。前端覆盖无项目入口、初始化、加载失败重试、全局编辑器及权限变化卸载,以及共享历史的筛选、命名、恢复、另存为与预览。 + +本次未新增 Tauri command、capability、CSP 或底层存储能力,文件导入与下载使用现有运行时入口;现有桌面发布检查脚本适用。 diff --git a/docs/guides/onlyoffice-preview.md b/docs/guides/onlyoffice-preview.md index b49de91b..b5655938 100644 --- a/docs/guides/onlyoffice-preview.md +++ b/docs/guides/onlyoffice-preview.md @@ -28,7 +28,31 @@ 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;其他字体由上游镜像或部署方提供。 + +## 导入宋体和 Times New Roman + +将获准使用的字体放到 `onlyoffice/fonts/`,文件名为 `simsun.ttc`、`times.ttf`、`timesbd.ttf`、`timesi.ttf`、`timesbi.ttf`。宋体集合包含 SimSun(宋体)和 NSimSun(新宋体),Times New Roman 的四个文件分别提供常规、粗体、斜体和粗斜体。该目录的字体文件被 Git 忽略,必须在每台构建主机上单独提供;构建得到的镜像会包含这些字体。 + +从仓库根目录执行以下命令;如果运行的是独立开发栈,为每条 Compose 命令增加 `-f docker-compose.dev.yaml -p ctms_dev`: + +```bash +docker compose build onlyoffice +docker compose exec -T onlyoffice documentserver-prepare4shutdown.sh +docker compose up -d --no-deps onlyoffice +``` + +维护前先完成文档保存。上述关闭准备会等待在线文档保存,随后替换 ONLYOFFICE 容器;启动脚本自动重建字体索引。构建脚本仅在四个 Times New Roman 文件齐全时移除上游同名字体,避免编辑器继续选用旧版本。未提供字体的环境沿用原有字体。 + +健康检查通过后,重新打开在线文档,在字体列表中查找 `SimSun`(宋体)和 `Times New Roman`。可用以下命令核对字体来源: + +```bash +docker compose exec -T onlyoffice fc-match SimSun +docker compose exec -T onlyoffice fc-match 'Times New Roman' +docker compose exec -T onlyoffice curl -fsS http://127.0.0.1/healthcheck +``` + +补充说明:当前版本的生成器也会扫描 `onlyoffice_data` 卷中的 `/var/www/onlyoffice/Data/custom-fonts/`。放在该处的字体可跨容器重建保留,但同名字体仍可能被上游版本优先选中;替换 Times New Roman 时使用上述镜像构建流程。迁移部署时应单独携带字体来源文件。字体索引刷新方式参见 [ONLYOFFICE 官方字体安装说明](https://helpcenter.onlyoffice.com/docs/installation/docs-install-fonts-docker.aspx)。 ## 配置边界 diff --git a/frontend/src/api/collaborationAccess.ts b/frontend/src/api/collaborationAccess.ts new file mode 100644 index 00000000..03eecf7e --- /dev/null +++ b/frontend/src/api/collaborationAccess.ts @@ -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; +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>; + +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[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), + }; +}; diff --git a/frontend/src/api/collaborationHistory.ts b/frontend/src/api/collaborationHistory.ts new file mode 100644 index 00000000..b7ef040c --- /dev/null +++ b/frontend/src/api/collaborationHistory.ts @@ -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; + +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), +}); diff --git a/frontend/src/api/ledgers.ts b/frontend/src/api/ledgers.ts new file mode 100644 index 00000000..ab1ffbd7 --- /dev/null +++ b/frontend/src/api/ledgers.ts @@ -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(base, online); +export const initializeLedgers = () => apiPost(`${base}/initialize`, {}, online); +export const fetchLedger = (id: string) => apiGet(`${base}/${id}`, online); +export const updateLedger = (id: string, payload: LedgerSettings) => apiPatch(`${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(`${base}/${id}/import`, form, online); +}; +export const fetchLedgerMembers = (id: string) => apiGet(`${base}/${id}/members`, online); +export const fetchLedgerCandidates = (id: string, keyword = "") => + apiGet(`${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(`${base}/${id}/access-settings`, payload, online); +export const fetchLedgerShareLink = (id: string) => apiGet(`${base}/${id}/share-link`, online); +export const updateLedgerShareLink = (id: string, payload: Parameters[2]) => + apiPut(`${base}/${id}/share-link`, payload, online); +export const createLedgerEditRequest = (id: string) => apiPost(`${base}/${id}/edit-requests`, {}, online); +export const fetchLedgerEditRequests = (id: string) => apiGet(`${base}/${id}/edit-requests`, online); +export const resolveLedgerEditRequest = (id: string, requestId: string, status: "APPROVED" | "REJECTED") => + apiPost(`${base}/${id}/edit-requests/${requestId}/resolve`, { status }, online); +export const transferLedgerOwnership = (id: string, userId: string) => + apiPost(`${base}/${id}/transfer-ownership`, { new_owner_id: userId }, online); +export const fetchLedgerEditorConfig = (id: string) => apiGet(`${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(`${base}/${id}/revisions`, online); +export const updateLedgerRevision = (id: string, revisionId: string, payload: { change_summary: string }) => + apiPatch(`${base}/${id}/revisions/${revisionId}`, payload, online); +export const downloadLedgerRevision = (id: string, revisionId: string) => + apiGet(`${base}/${id}/revisions/${revisionId}/download`, { ...online, responseType: "blob" }); +export const restoreLedgerRevision = (id: string, revisionId: string, generation: number) => + apiPost(`${base}/${id}/revisions/${revisionId}/restore`, { generation }, online); +export const fetchLedgerRevisionPreview = (id: string, revisionId: string) => + apiGet(`${base}/${id}/revisions/${revisionId}/preview-config`, online); diff --git a/frontend/src/components/EntryCountBadge.vue b/frontend/src/components/EntryCountBadge.vue new file mode 100644 index 00000000..21ffc18c --- /dev/null +++ b/frontend/src/components/EntryCountBadge.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/components/collaboration/CollaborationAccessDialog.test.ts b/frontend/src/components/collaboration/CollaborationAccessDialog.test.ts new file mode 100644 index 00000000..82e1c3a6 --- /dev/null +++ b/frontend/src/components/collaboration/CollaborationAccessDialog.test.ts @@ -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 | undefined; +const render = (target: CollaborationAccessTarget = { scope: "ledger" }) => { + wrapper = mount(AccessDialog, { props: { file, target }, global: { + directives: { loading: () => {} }, + stubs: { + ElDialog: { props: ["modelValue", "title"], template: '
{{ title }}
' }, + ElButton: { template: '' }, ElAvatar: { template: '' }, + ElTag: { template: '' }, ElIcon: { template: '' }, + ElEmpty: true, ElInput: true, + ElSelect: { props: ["modelValue"], emits: ["update:modelValue", "change"], template: '' }, + ElOption: { props: ["label", "value"], template: '' }, + ElSwitch: { props: ["modelValue", "disabled"], emits: ["update:modelValue", "change"], template: '' }, + }, + } }); + 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(); + }); +}); diff --git a/frontend/src/components/collaboration/CollaborationAccessDialog.vue b/frontend/src/components/collaboration/CollaborationAccessDialog.vue new file mode 100644 index 00000000..33b9ecb2 --- /dev/null +++ b/frontend/src/components/collaboration/CollaborationAccessDialog.vue @@ -0,0 +1,1190 @@ + + + + + diff --git a/frontend/src/components/collaboration/CollaborationHistoryDialog.test.ts b/frontend/src/components/collaboration/CollaborationHistoryDialog.test.ts new file mode 100644 index 00000000..cdf34c3b --- /dev/null +++ b/frontend/src/components/collaboration/CollaborationHistoryDialog.test.ts @@ -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: "
" } })); + +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 | 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: '
' }, + ElButton: { template: '' }, ElAvatar: { template: '' }, + ElTag: { template: '' }, ElIcon: { template: '' }, ElTooltip: { template: '' }, ElEmpty: true, + ElDropdown: { name: "ElDropdown", emits: ["command"], template: '
' }, + ElDropdownMenu: { template: '
' }, ElDropdownItem: { template: '' }, + ElSelect: { props: ["modelValue"], emits: ["update:modelValue"], template: '' }, + ElOption: { props: ["value", "label"], template: '' }, + ElSwitch: { props: ["modelValue"], emits: ["update:modelValue"], template: '' }, + }, + } }); + 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); + }); +}); diff --git a/frontend/src/components/collaboration/CollaborationHistoryDialog.vue b/frontend/src/components/collaboration/CollaborationHistoryDialog.vue new file mode 100644 index 00000000..6eb27836 --- /dev/null +++ b/frontend/src/components/collaboration/CollaborationHistoryDialog.vue @@ -0,0 +1,552 @@ + + + + + diff --git a/frontend/src/components/ledgers/LedgerHistoryDialog.vue b/frontend/src/components/ledgers/LedgerHistoryDialog.vue new file mode 100644 index 00000000..d3dc0552 --- /dev/null +++ b/frontend/src/components/ledgers/LedgerHistoryDialog.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/components/ledgers/LedgerInfoDialog.vue b/frontend/src/components/ledgers/LedgerInfoDialog.vue new file mode 100644 index 00000000..8066c05f --- /dev/null +++ b/frontend/src/components/ledgers/LedgerInfoDialog.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/frontend/src/components/ledgers/LedgerPanel.test.ts b/frontend/src/components/ledgers/LedgerPanel.test.ts new file mode 100644 index 00000000..b36f8937 --- /dev/null +++ b/frontend/src/components/ledgers/LedgerPanel.test.ts @@ -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: "
" } })); +vi.mock("./LedgerHistoryDialog.vue", () => ({ default: { template: "
" } })); +vi.mock("../collaboration/CollaborationAccessDialog.vue", () => ({ default: { template: "
" } })); +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: '' }, ElIcon: { template: '' }, + 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(); + }); +}); diff --git a/frontend/src/components/ledgers/LedgerPanel.vue b/frontend/src/components/ledgers/LedgerPanel.vue new file mode 100644 index 00000000..95b93418 --- /dev/null +++ b/frontend/src/components/ledgers/LedgerPanel.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index b04ad125..7fe5f590 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -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();"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a07e4d69..b1591264 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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(); } diff --git a/frontend/src/styles/entry-cards.css b/frontend/src/styles/entry-cards.css new file mode 100644 index 00000000..6a4de402 --- /dev/null +++ b/frontend/src/styles/entry-cards.css @@ -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; +} diff --git a/frontend/src/types/ledger.ts b/frontend/src/types/ledger.ts new file mode 100644 index 00000000..8a5dce0f --- /dev/null +++ b/frontend/src/types/ledger.ts @@ -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; + +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 { + role: LedgerRole; + is_active: boolean; + protected_label?: string | null; +} diff --git a/frontend/src/types/onlyoffice.ts b/frontend/src/types/onlyoffice.ts index 31871332..f0c6df8b 100644 --- a/frontend/src/types/onlyoffice.ts +++ b/frontend/src/types/onlyoffice.ts @@ -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; diff --git a/frontend/src/views/DesktopProjectEntry.vue b/frontend/src/views/DesktopProjectEntry.vue index d4d6ec2b..b0ec685d 100644 --- a/frontend/src/views/DesktopProjectEntry.vue +++ b/frontend/src/views/DesktopProjectEntry.vue @@ -53,14 +53,10 @@
Project Workspace -

选择目标项目

-
- -
- - - {{ projects.length }} 个可进入项目 - +
+

选择目标项目

+ {{ projects.length }} 个可进入项目 +
@@ -130,6 +126,7 @@

未检测到可用项目

您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。

+ @@ -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); diff --git a/frontend/src/views/OfficePreviewWorkspace.vue b/frontend/src/views/OfficePreviewWorkspace.vue index 32081726..687a8861 100644 --- a/frontend/src/views/OfficePreviewWorkspace.vue +++ b/frontend/src/views/OfficePreviewWorkspace.vue @@ -1,5 +1,5 @@ - - -
- - -
-
- 允许编辑者和匿名访问者下载、打印、另存和复制 - -
-
- 允许申请编辑权限 - -
-
- 允许所有协作者添加、删除工作表 - -
- -
- -
-
- - -
-
-
- - - -
-
-
-
- 项目成员 - {{ filteredAvailableCandidates.length }} 位可授权 -
-
- - -
-
-
-
- -
-
- 已选择 ({{ selectedMemberCandidates.length }}) - - - - -
-
-
- - {{ collaboratorInitial(candidate.full_name) }} - - - - {{ candidate.full_name }} - {{ candidate.role_in_study }} - - {{ candidate.email }} - - - - -
-
-
- - 暂未选择协作者 - 从左侧项目成员中选择需要授权的账号 -
- -
-
-
- - -
-
-
- - {{ collaboratorInitial(accessFile?.owner_name || "?") }} - - - {{ accessFile?.owner_name || "当前所有者" }} - - {{ currentOwnerCandidate.role_in_study }} - - - 当前所有者 - -
- -
- -
- - -
-
- -
+ @@ -700,128 +300,9 @@ - - - -
- - - - - - - - - - - -
- -
-
- 时间 - 更新者 - 大小 - 版本来源 - 版本名称 - 操作 -
-
-
-

{{ group.label }}

-
- {{ revisionTimeLabel(row.created_at) }} - - {{ revisionAuthorInitial(row) }} - {{ revisionAuthorName(row) }} - - {{ formatFileSize(row.file_size) }} - {{ sourceLabel(row.source) }} - - {{ row.change_summary || "未命名" }} - 当前版本 - - - 命名 - 预览 - 另存为 - - ••• - - - -
-
-
- -
-
- - + @@ -830,11 +311,7 @@ import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMoun import { useRoute, useRouter } from "vue-router"; import { ElMessage, ElMessageBox } from "element-plus"; import { - ArrowLeft, - ArrowRight, - Check, Clock, - Close, CopyDocument, Delete, Document, @@ -844,66 +321,41 @@ import { Folder, FolderOpened, InfoFilled, - Link, - Lock, Plus, - Refresh, - Search, Share, Upload, User, } from "@element-plus/icons-vue"; import { - copyCollaborationRevision, copyCollaborationFile, createCollaborationFile, createCollaborationFolder, - deleteCollaborationRevision, deleteCollaborationFolder, downloadCollaborationFile, - fetchCollaborationCandidates, - fetchCollaborationEditRequests, fetchCollaborationFile, fetchCollaborationFiles, fetchCollaborationFolders, - fetchCollaborationMembers, - fetchCollaborationRevisions, - fetchCollaborationShareLink, importCollaborationFile, - removeCollaborationMember, - resolveCollaborationEditRequest, restoreCollaborationFile, - restoreCollaborationRevision, trashCollaborationFile, - transferCollaborationOwnership, - updateCollaborationRevision, updateCollaborationFile, updateCollaborationFolder, - updateCollaborationShareLink, - upsertCollaborationMember, } from "../../api/collaboration"; import { useStudyStore } from "../../store/study"; import { useAuthStore } from "../../store/auth"; import { usePermission } from "../../utils/permission"; import { displayDateTime } from "../../utils/display"; import { getApiErrorMessage } from "../../utils/apiErrorMessage"; -import { clientRuntime, isTauriRuntime } from "../../runtime"; +import { isTauriRuntime } from "../../runtime"; import { downloadFileWithFeedback } from "../../utils/fileTaskFeedback"; import { formatFileSize } from "../../components/attachments/attachmentUtils"; -import CollaborationSaveAsDialog from "../../components/collaboration/CollaborationSaveAsDialog.vue"; -import { notifyProjectNotificationsChanged } from "../../composables/useProjectNotifications"; +import CollaborationAccessDialog from "../../components/collaboration/CollaborationAccessDialog.vue"; +import CollaborationHistoryDialog from "../../components/collaboration/CollaborationHistoryDialog.vue"; +import type { CollaborationHistoryFile } from "../../api/collaborationHistory"; import type { - CollaborationCandidate, - CollaborationEditRequest, CollaborationFile, CollaborationFileType, CollaborationFolder, - CollaborationMember, - CollaborationMemberRole, - CollaborationRevision, - CollaborationShareAccessMode, - CollaborationShareExpiryPolicy, - CollaborationShareLink, } from "../../types/collaboration"; const router = useRouter(); @@ -981,147 +433,11 @@ const moveFolderId = ref(""); const infoDialogVisible = ref(false); const infoFile = ref(null); -const accessDialogVisible = ref(false); const accessFile = ref(null); -const accessPanel = ref<"overview" | "permissions">("overview"); -const commonExportEnabled = ref(false); -const exportPermissionSaving = ref(false); -const editRequestEnabled = ref(false); -const editRequestPermissionSaving = ref(false); -const sheetStructureEditEnabled = ref(true); -const sheetStructurePermissionSaving = ref(false); -const editRequests = ref([]); -const editRequestSectionRef = ref(null); -const resolvingEditRequestId = ref(null); -const memberInviteDialogVisible = ref(false); -const memberSearch = ref(""); -const transferDialogVisible = ref(false); -const transferOwnerId = ref(""); -const transferringOwnership = ref(false); -const memberLoading = ref(false); -const memberSaving = ref(false); -const members = ref([]); -const candidates = ref([]); -const memberForm = reactive<{ user_ids: string[]; role: CollaborationMemberRole }>({ user_ids: [], role: "EDITOR" }); -const shareLoading = ref(false); -const shareSaving = ref(false); -const shareLink = ref(null); -const shareForm = reactive<{ - enabled: boolean; - access_mode: CollaborationShareAccessMode; - expiry_policy: CollaborationShareExpiryPolicy; - password_enabled: boolean; -}>({ - enabled: false, - access_mode: "VIEW", - expiry_policy: "SEVEN_DAYS", - password_enabled: false, -}); -const shareUrl = computed(() => { - if (!shareLink.value?.share_token || !shareForm.enabled) return ""; - const apiBase = new URL(clientRuntime.apiBaseUrl(), window.location.origin); - const url = new URL(shareLink.value.share_path, apiBase.origin); - url.hash = shareLink.value.share_token; - return url.href; -}); -const availableCandidates = computed(() => { - const existing = new Set(members.value.map((item) => item.user_id)); - return candidates.value.filter((item) => !existing.has(item.user_id)); -}); -const filteredAvailableCandidates = computed(() => { - const query = memberSearch.value.trim().toLocaleLowerCase(); - if (!query) return availableCandidates.value; - return availableCandidates.value.filter((item) => - [item.full_name, item.email, item.role_in_study].some((value) => value.toLocaleLowerCase().includes(query)), - ); -}); -const selectedMemberCandidates = computed(() => { - const selected = new Set(memberForm.user_ids); - return availableCandidates.value.filter((item) => selected.has(item.user_id)); -}); -const transferCandidates = computed(() => - candidates.value.filter((item) => item.user_id !== accessFile.value?.owner_id && item.can_be_manager), -); -const currentOwnerCandidate = computed(() => - candidates.value.find((item) => item.user_id === accessFile.value?.owner_id) || null, -); -const selectedTransferCandidate = computed(() => - transferCandidates.value.find((item) => item.user_id === transferOwnerId.value) || null, -); -const accessPanelTitle = computed(() => ({ - overview: "访问与权限", - permissions: "权限设置", -})[accessPanel.value]); -const accessPermissionSummary = computed(() => { - let enabledCount = Number(commonExportEnabled.value) + Number(editRequestEnabled.value); - if (accessFile.value?.file_type === "cell") enabledCount += Number(sheetStructureEditEnabled.value); - return enabledCount > 0 ? `已开启 ${enabledCount} 项` : "全部关闭"; -}); +const accessManagementRef = ref | null>(null); -const historyDialogVisible = ref(false); -const historyLoading = ref(false); -const restoringRevisionId = ref(null); -const historyActionKey = ref(null); -const historyOnlyDescribed = ref(false); -const historySourceFilter = ref("ALL"); const historyFile = ref(null); -const revisions = ref([]); -const revisionSaveAsDialogVisible = ref(false); -const revisionSaveAsTarget = ref(null); -const revisionSaveAsFolderId = ref(null); -const revisionSaveAsTitle = ref(""); -const revisionSaveAsSaving = ref(false); const revisionRefreshTimers: number[] = []; -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(); - 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 ? "项目成员" : "系统"; -}; -const revisionAuthorInitial = (revision: CollaborationRevision) => - revisionAuthorName(revision).trim().slice(0, 1).toUpperCase() || "?"; const requireStudyId = () => study.currentStudy?.id || ""; const fileTypeLabel = (value: CollaborationFileType) => ({ word: "W", cell: "X", slide: "P" })[value]; @@ -1176,19 +492,6 @@ const fileActions = (item: CollaborationFile): FileActionDefinition[] => { } return actions; }; -function sourceLabel(source: string) { - return ({ - CREATE: "新建", - IMPORT: "导入", - FORCE_SAVE: "手动保存", - SESSION_CLOSE: "自动保存", - RESTORE: "历史恢复", - COPY: "复制创建", - SHARE_FORCE_SAVE: "链接协作保存", - SHARE_SESSION_CLOSE: "链接协作关闭", - } as Record)[source] || source; -} - const loadFolders = async () => { const studyId = requireStudyId(); if (!studyId) return; @@ -1226,21 +529,6 @@ const scheduleRevisionRefresh = () => { } }; -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 refresh = async () => { try { await loadFolders(); @@ -1559,33 +847,7 @@ const handleFileContextAction = (command: string) => { const openAccessManagement = async (item: CollaborationFile) => { accessFile.value = item; - accessPanel.value = "overview"; - commonExportEnabled.value = item.allow_export; - editRequestEnabled.value = item.allow_edit_request; - sheetStructureEditEnabled.value = item.allow_sheet_structure_edit; - accessDialogVisible.value = true; - memberLoading.value = true; - shareLoading.value = true; - shareLink.value = null; - try { - const [memberResponse, candidateResponse, shareResponse, requestResponse] = await Promise.all([ - fetchCollaborationMembers(requireStudyId(), item.id), - fetchCollaborationCandidates(requireStudyId()), - fetchCollaborationShareLink(requireStudyId(), item.id), - fetchCollaborationEditRequests(requireStudyId(), item.id), - ]); - if (accessFile.value?.id !== item.id) return; - members.value = memberResponse.data; - candidates.value = candidateResponse.data; - memberForm.user_ids = []; - editRequests.value = requestResponse.data; - applyShareLink(shareResponse.data); - } catch (error) { - ElMessage.error(await getApiErrorMessage(error, "访问与权限信息加载失败")); - } finally { - memberLoading.value = false; - shareLoading.value = false; - } + await nextTick(); }; const openEditRequestNotificationTarget = async () => { @@ -1606,8 +868,7 @@ const openEditRequestNotificationTarget = async () => { await router.replace({ path: route.path, query: nextQuery }); await openAccessManagement(target); await nextTick(); - editRequestSectionRef.value?.focus({ preventScroll: true }); - editRequestSectionRef.value?.scrollIntoView({ behavior: "smooth", block: "start" }); + await accessManagementRef.value?.focusEditRequests(); }; watch( @@ -1615,524 +876,9 @@ watch( () => { void openEditRequestNotificationTarget(); }, ); -const refreshAccessMembers = async (item: CollaborationFile) => { - memberLoading.value = true; - try { - const [memberResponse, candidateResponse] = await Promise.all([ - fetchCollaborationMembers(requireStudyId(), item.id), - fetchCollaborationCandidates(requireStudyId()), - ]); - if (accessFile.value?.id !== item.id) return; - members.value = memberResponse.data; - candidates.value = candidateResponse.data; - memberForm.user_ids = []; - } catch (error) { - ElMessage.error(await getApiErrorMessage(error, "账号授权信息加载失败")); - } finally { - memberLoading.value = false; - } -}; - -const closeAccessManagement = () => { - accessPanel.value = "overview"; - accessFile.value = null; - members.value = []; - candidates.value = []; - memberForm.user_ids = []; - shareLink.value = null; - editRequests.value = []; - memberInviteDialogVisible.value = false; - memberSearch.value = ""; - transferDialogVisible.value = false; - transferOwnerId.value = ""; -}; - -const openPermissionSettings = () => { - accessPanel.value = "permissions"; -}; - -const returnToAccessOverview = () => { - accessPanel.value = "overview"; -}; - -const handleAccessPanelBack = () => { - returnToAccessOverview(); -}; - -const openMemberInvite = () => { - memberForm.user_ids = []; - memberForm.role = "EDITOR"; - memberSearch.value = ""; - memberInviteDialogVisible.value = true; -}; - -const closeMemberInvite = () => { - memberForm.user_ids = []; - memberSearch.value = ""; -}; - -const toggleMemberCandidate = (userId: string) => { - const candidate = availableCandidates.value.find((item) => item.user_id === userId); - if (!candidate || !isCandidateEligible(candidate)) return; - memberForm.user_ids = memberForm.user_ids.includes(userId) - ? memberForm.user_ids.filter((id) => id !== userId) - : [...memberForm.user_ids, userId]; -}; - -const isCandidateEligible = (candidate: CollaborationCandidate) => - memberForm.role === "MANAGER" ? candidate.can_be_manager : candidate.can_be_editor; - -const handleMemberRoleChange = () => { - const eligibleIds = new Set( - availableCandidates.value.filter(isCandidateEligible).map((candidate) => candidate.user_id), - ); - memberForm.user_ids = memberForm.user_ids.filter((userId) => eligibleIds.has(userId)); -}; - -const applyShareLink = (value: CollaborationShareLink) => { - shareLink.value = value; - shareForm.enabled = value.enabled; - shareForm.access_mode = value.access_mode; - shareForm.expiry_policy = value.expiry_policy; - shareForm.password_enabled = value.has_password; -}; - -const handleCommonExportChange = async (value: boolean | string | number) => { - const target = accessFile.value; - if (!target || exportPermissionSaving.value) return; - const previous = target.allow_export; - const next = Boolean(value); - exportPermissionSaving.value = true; - try { - const { data } = await updateCollaborationFile(requireStudyId(), target.id, { allow_export: next }); - accessFile.value = data; - commonExportEnabled.value = data.allow_export; - files.value = files.value.map((item) => item.id === data.id ? data : item); - } catch (error) { - commonExportEnabled.value = previous; - ElMessage.error(await getApiErrorMessage(error, "内容操作权限保存失败")); - } finally { - exportPermissionSaving.value = false; - } -}; - -const handleEditRequestPermissionChange = async (value: boolean | string | number) => { - const target = accessFile.value; - if (!target || editRequestPermissionSaving.value) return; - const previous = target.allow_edit_request; - editRequestPermissionSaving.value = true; - try { - const { data } = await updateCollaborationFile(requireStudyId(), target.id, { - allow_edit_request: Boolean(value), - }); - accessFile.value = data; - editRequestEnabled.value = data.allow_edit_request; - files.value = files.value.map((item) => item.id === data.id ? data : item); - } catch (error) { - editRequestEnabled.value = previous; - ElMessage.error(await getApiErrorMessage(error, "编辑权限申请设置保存失败")); - } finally { - editRequestPermissionSaving.value = false; - } -}; - -const handleSheetStructurePermissionChange = async (value: boolean | string | number) => { - const target = accessFile.value; - if (!target || sheetStructurePermissionSaving.value) return; - const previous = target.allow_sheet_structure_edit; - sheetStructurePermissionSaving.value = true; - try { - const { data } = await updateCollaborationFile(requireStudyId(), target.id, { - allow_sheet_structure_edit: Boolean(value), - }); - accessFile.value = data; - sheetStructureEditEnabled.value = data.allow_sheet_structure_edit; - files.value = files.value.map((item) => item.id === data.id ? data : item); - } catch (error) { - sheetStructureEditEnabled.value = previous; - ElMessage.error(await getApiErrorMessage(error, "工作表结构权限保存失败")); - } finally { - sheetStructurePermissionSaving.value = false; - } -}; - -const resolveEditRequest = async ( - request: CollaborationEditRequest, - status: "APPROVED" | "REJECTED", -) => { - const target = accessFile.value; - if (!target || resolvingEditRequestId.value) return; - resolvingEditRequestId.value = request.id; - try { - await resolveCollaborationEditRequest(requireStudyId(), target.id, request.id, status); - editRequests.value = editRequests.value.filter((item) => item.id !== request.id); - notifyProjectNotificationsChanged(); - if (status === "APPROVED") await refreshAccessMembers(target); - ElMessage.success(status === "APPROVED" ? "已授予编辑权限" : "已拒绝编辑权限申请"); - } catch (error) { - ElMessage.error(await getApiErrorMessage(error, "编辑权限申请处理失败")); - } finally { - resolvingEditRequestId.value = null; - } -}; - -const openOwnershipTransfer = () => { - transferOwnerId.value = ""; - transferDialogVisible.value = true; -}; - -const closeOwnershipTransfer = () => { - transferOwnerId.value = ""; -}; - -const submitOwnershipTransfer = async () => { - const target = accessFile.value; - const candidate = selectedTransferCandidate.value; - if (!target || !candidate || transferringOwnership.value) return; - try { - transferringOwnership.value = true; - const { data } = await transferCollaborationOwnership(requireStudyId(), target.id, candidate.user_id); - accessFile.value = data; - files.value = files.value.map((item) => item.id === data.id ? data : item); - commonExportEnabled.value = data.allow_export; - editRequestEnabled.value = data.allow_edit_request; - sheetStructureEditEnabled.value = data.allow_sheet_structure_edit; - transferOwnerId.value = ""; - transferDialogVisible.value = false; - accessPanel.value = "permissions"; - await refreshAccessMembers(data); - ElMessage.success(`文档所有权已转让给 ${candidate.full_name}`); - } catch (error: any) { - ElMessage.error(await getApiErrorMessage(error, "文档所有权转让失败")); - } finally { - transferringOwnership.value = false; - } -}; - -const persistShareSettings = async ( - passwordMode: "KEEP" | "SET" | "CLEAR" = "KEEP", - password?: string, -) => { - if (!accessFile.value || !shareLink.value || shareSaving.value) return false; - const previous = shareLink.value; - shareSaving.value = true; - try { - const response = await updateCollaborationShareLink(requireStudyId(), accessFile.value.id, { - enabled: shareForm.enabled, - access_mode: shareForm.access_mode, - expiry_policy: shareForm.expiry_policy, - password_mode: passwordMode, - ...(passwordMode === "SET" ? { password } : {}), - }); - applyShareLink(response.data); - return true; - } catch (error) { - applyShareLink(previous); - ElMessage.error(await getApiErrorMessage(error, "链接分享设置保存失败")); - return false; - } finally { - shareSaving.value = false; - } -}; - -const handleShareEnabledChange = async () => { - await persistShareSettings(); -}; - -const promptSharePassword = async () => { - if (!shareLink.value || shareSaving.value) return; - try { - const result = await ElMessageBox.prompt("请输入 4–64 位链接密码", "设置链接密码", { - inputType: "password", - inputPlaceholder: "链接访问密码", - inputValidator: (value) => value.length >= 4 && value.length <= 64 ? true : "请输入 4–64 位密码", - confirmButtonText: "设置", - cancelButtonText: "取消", - closeOnClickModal: false, - }); - shareForm.password_enabled = true; - await persistShareSettings("SET", result.value); - } catch (error: any) { - if (error !== "cancel" && error !== "close") { - ElMessage.error("链接密码设置失败"); - } - shareForm.password_enabled = Boolean(shareLink.value?.has_password); - } -}; - -const handleSharePasswordToggle = async (value: boolean | string | number) => { - if (Boolean(value)) { - if (!shareLink.value?.has_password) await promptSharePassword(); - return; - } - await persistShareSettings("CLEAR"); -}; - -const copyText = async (value: string) => { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(value); - return; - } - const input = document.createElement("textarea"); - input.value = value; - input.style.position = "fixed"; - input.style.opacity = "0"; - document.body.appendChild(input); - input.select(); - const copied = document.execCommand("copy"); - input.remove(); - if (!copied) throw new Error("copy failed"); -}; - -const copyShareUrl = async () => { - if (!shareUrl.value) return; - try { - await copyText(shareUrl.value); - ElMessage.success("共享链接已复制"); - } catch { - ElMessage.error("复制失败,请手动复制链接"); - } -}; - -const saveMember = async () => { - if (!accessFile.value || memberForm.user_ids.length === 0) return ElMessage.warning("请选择需要授权的系统账号"); - const targetFile = accessFile.value; - const selectedUserIds = [...memberForm.user_ids]; - memberSaving.value = true; - try { - const results = await Promise.allSettled(selectedUserIds.map((userId) => upsertCollaborationMember( - requireStudyId(), - targetFile.id, - { user_id: userId, role: memberForm.role }, - ))); - const failed = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); - const succeededCount = results.length - failed.length; - await refreshAccessMembers(targetFile); - if (succeededCount > 0) memberInviteDialogVisible.value = false; - if (failed.length === 0) { - ElMessage.success(`已授权 ${succeededCount} 个账号`); - } else if (succeededCount > 0) { - ElMessage.warning(`已授权 ${succeededCount} 个账号,${failed.length} 个账号授权失败`); - } else { - ElMessage.error(await getApiErrorMessage(failed[0].reason, "账号授权失败")); - } - } finally { - memberSaving.value = false; - } -}; - -const removeMember = async (member: CollaborationMember) => { - if (!accessFile.value) return; - const targetFile = accessFile.value; - try { - await removeCollaborationMember(requireStudyId(), targetFile.id, member.user_id); - await refreshAccessMembers(targetFile); - } catch (error) { - ElMessage.error(await getApiErrorMessage(error, "移除账号授权失败")); - } -}; - -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([ - fetchCollaborationRevisions(requireStudyId(), fileId), - fetchCollaborationFile(requireStudyId(), fileId), - ]); - if ( - requestSequence !== historyRequestSequence || - !historyDialogVisible.value || - historyFile.value?.id !== fileId - ) return; - revisions.value = revisionResponse.data; - historyFile.value = fileResponse.data; - files.value = files.value.map((item) => item.id === fileId ? fileResponse.data : item); - } catch (error) { - if (requestSequence === historyRequestSequence && !options.silent) { - ElMessage.error(await getApiErrorMessage(error, "修订历史加载失败")); - } - } finally { - if (requestSequence === historyRequestSequence) historyLoading.value = false; - } -}; - -const openHistory = async (item: CollaborationFile) => { - 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 = []; -}; - -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 || "", - inputPlaceholder: "例如:伦理审查提交版", - inputValidator: (input) => Boolean(String(input || "").trim()) || "版本名称不能为空", - confirmButtonText: "保存", - }); - historyActionKey.value = `${revision.id}:name`; - const { data } = await updateCollaborationRevision(requireStudyId(), 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: "OfficeCollaborationRevisionPreview", - params: { fileId, id: revision.id }, - }); - window.open(previewRoute.href, "_blank", "noopener,noreferrer"); -}; - -const saveRevisionAs = (revision: CollaborationRevision) => { - 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) return; - revisionSaveAsSaving.value = true; - historyActionKey.value = `${revision.id}:copy`; - try { - const { data } = await copyCollaborationRevision(requireStudyId(), file.id, revision.id, { - title: payload.title, - folder_id: payload.folderId, - }); - revisionSaveAsDialogVisible.value = false; - revisionSaveAsTarget.value = null; - await loadFiles(); - 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) return; - const fileId = historyFile.value.id; - try { - await ElMessageBox.confirm( - "删除此历史版本后将不再显示且无法恢复,是否继续?", - "删除历史版本", - { type: "warning", confirmButtonText: "删除", confirmButtonClass: "el-button--danger" }, - ); - historyActionKey.value = `${revision.id}:delete`; - await deleteCollaborationRevision(requireStudyId(), 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) return; - const fileId = historyFile.value.id; - try { - await ElMessageBox.confirm("恢复此历史版本将生成一个新版本,是否继续?", "恢复历史版本", { type: "warning" }); - restoringRevisionId.value = revision.id; - const { data: restored } = await restoreCollaborationRevision(requireStudyId(), fileId, 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] = await Promise.all([ - fetchCollaborationRevisions(requireStudyId(), fileId), - loadFiles(), - ]); - if (historyFile.value?.id === fileId) { - revisions.value = revisionResponse.data; - const refreshedFile = files.value.find((item) => item.id === fileId); - if (refreshedFile) historyFile.value = refreshedFile; - } - ElMessage.success("已恢复并生成新版本"); - } catch (error: any) { - if (error === "cancel" || error === "close") return; - ElMessage.error(await getApiErrorMessage(error, "版本恢复失败")); - } finally { - restoringRevisionId.value = null; - } +const openHistory = (item: CollaborationFile) => { historyFile.value = item; }; +const updateHistoryFile = (file: CollaborationHistoryFile) => { + files.value = files.value.map((item) => item.id === file.id ? { ...item, ...file } : item); }; onMounted(() => { @@ -2144,9 +890,8 @@ onActivated(() => { scheduleRevisionRefresh(); }); onDeactivated(clearRevisionRefreshTimers); -onDeactivated(clearHistoryRefreshTimers); +onDeactivated(() => { historyFile.value = null; }); onBeforeUnmount(clearRevisionRefreshTimers); -onBeforeUnmount(clearHistoryRefreshTimers); diff --git a/frontend/src/views/knowledge/CollaborationWorkspace.vue b/frontend/src/views/knowledge/CollaborationWorkspace.vue index 044ccc69..f5342528 100644 --- a/frontend/src/views/knowledge/CollaborationWorkspace.vue +++ b/frontend/src/views/knowledge/CollaborationWorkspace.vue @@ -5,6 +5,7 @@ 'is-fullscreen': webFullscreenActive, 'is-fullscreen-toolbar-visible': fullscreenToolbarVisible, 'is-macos-desktop': isMacDesktop, + 'is-ledger-workspace': isLedgerWorkspace, }" >
(null); +const isLedgerWorkspace = computed(() => route.meta.ledgerWorkspace === true); +type WorkspaceFile = Pick & Partial>; +const file = ref(null); +const ledgerSnapshot = ref(null); const editorConfig = ref(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) => { 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(); });