From c1dd8def2c39ff86c06bc49d25f27bec741b43bc Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 3 Sep 2026 16:55:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=A8=E5=B1=80=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E8=B4=A6=E5=B9=B6=E7=BB=9F=E4=B8=80=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增与项目平级的协作台账,提供空白模板、独立账号授权和在线编辑。 复用共享库的访问权限与历史版本界面,拆分信息维护入口并统一工作台卡片样式。 补充在线文档字体构建支持、数据库表结构升级和回归测试。 不包含本地台账内容、账号授权记录或历史文件迁移数据。 --- .../20260903_01_add_global_ledgers.py | 38 + backend/app/api/v1/ledgers.py | 201 ++ backend/app/api/v1/router.py | 2 + backend/app/models/collaboration.py | 14 +- backend/app/schemas/ledger.py | 88 + backend/app/services/collaboration_service.py | 190 +- .../services/collaboration_share_service.py | 7 +- backend/app/services/ledger_access.py | 41 + backend/app/services/ledger_service.py | 259 +++ .../onlyoffice_collaboration_service.py | 62 +- .../ledgers/clinical-project-register.xlsx | Bin 0 -> 9012 bytes .../templates/ledgers/contract-ledger.xlsx | Bin 0 -> 11607 bytes backend/tests/test_ledgers.py | 463 +++++ docs/global-ledgers.md | 48 + docs/guides/onlyoffice-preview.md | 26 +- frontend/src/api/collaborationAccess.ts | 56 + frontend/src/api/collaborationHistory.ts | 19 + frontend/src/api/ledgers.ts | 49 + frontend/src/components/EntryCountBadge.vue | 36 + .../CollaborationAccessDialog.test.ts | 97 + .../CollaborationAccessDialog.vue | 1190 ++++++++++++ .../CollaborationHistoryDialog.test.ts | 104 ++ .../CollaborationHistoryDialog.vue | 552 ++++++ .../ledgers/LedgerHistoryDialog.vue | 13 + .../components/ledgers/LedgerInfoDialog.vue | 132 ++ .../components/ledgers/LedgerPanel.test.ts | 63 + .../src/components/ledgers/LedgerPanel.vue | 128 ++ frontend/src/router.test.ts | 7 +- frontend/src/router/index.ts | 14 +- frontend/src/styles/entry-cards.css | 45 + frontend/src/types/ledger.ts | 48 + frontend/src/types/onlyoffice.ts | 2 +- frontend/src/views/DesktopProjectEntry.vue | 54 +- frontend/src/views/OfficePreviewWorkspace.vue | 18 +- frontend/src/views/WebWorkbenchEntry.vue | 58 +- .../knowledge/CollaborationLibrary.test.ts | 33 +- .../views/knowledge/CollaborationLibrary.vue | 1633 +---------------- .../knowledge/CollaborationWorkspace.vue | 62 +- .../views/knowledge/LedgerWorkspace.test.ts | 99 + onlyoffice/Dockerfile | 17 + onlyoffice/fonts/.gitignore | 2 + 41 files changed, 4155 insertions(+), 1815 deletions(-) create mode 100644 backend/alembic/versions/20260903_01_add_global_ledgers.py create mode 100644 backend/app/api/v1/ledgers.py create mode 100644 backend/app/schemas/ledger.py create mode 100644 backend/app/services/ledger_access.py create mode 100644 backend/app/services/ledger_service.py create mode 100644 backend/app/templates/ledgers/clinical-project-register.xlsx create mode 100644 backend/app/templates/ledgers/contract-ledger.xlsx create mode 100644 backend/tests/test_ledgers.py create mode 100644 docs/global-ledgers.md create mode 100644 frontend/src/api/collaborationAccess.ts create mode 100644 frontend/src/api/collaborationHistory.ts create mode 100644 frontend/src/api/ledgers.ts create mode 100644 frontend/src/components/EntryCountBadge.vue create mode 100644 frontend/src/components/collaboration/CollaborationAccessDialog.test.ts create mode 100644 frontend/src/components/collaboration/CollaborationAccessDialog.vue create mode 100644 frontend/src/components/collaboration/CollaborationHistoryDialog.test.ts create mode 100644 frontend/src/components/collaboration/CollaborationHistoryDialog.vue create mode 100644 frontend/src/components/ledgers/LedgerHistoryDialog.vue create mode 100644 frontend/src/components/ledgers/LedgerInfoDialog.vue create mode 100644 frontend/src/components/ledgers/LedgerPanel.test.ts create mode 100644 frontend/src/components/ledgers/LedgerPanel.vue create mode 100644 frontend/src/styles/entry-cards.css create mode 100644 frontend/src/types/ledger.ts create mode 100644 frontend/src/views/knowledge/LedgerWorkspace.test.ts create mode 100644 onlyoffice/fonts/.gitignore 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 0000000000000000000000000000000000000000..c91bbaf91aa518c71cafa2ec7be49a0cb470f762 GIT binary patch literal 9012 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 T9zVM=;Q-wr@90hSbM*fJu/`,不会进入项目目录。 + +设置/授权变化、编辑申请获批、所有权转让、匿名访问变更、导入、恢复推进文件代次;旧代次保存回调不覆盖新内容。台账回调在文件锁内复核代次、归档状态与保存账号有效编辑权限;匿名回调另行检查链接仍启用、未过期且允许编辑,不回退为所有者身份。客户端每 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(); });