6 Commits

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