feat(collaboration): 完善在线文档协作与通知闭环
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。

- 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。

- 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。

- 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。

- 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。

- 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。

- 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。

- 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。

- 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。

- 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。

- 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。

- 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
Cheng Zhou
2026-07-16 14:14:54 +08:00
parent c68dddfc01
commit 1d26646a96
97 changed files with 11911 additions and 316 deletions
@@ -0,0 +1,136 @@
"""Add the independent shared-library collaboration module.
Revision ID: 20260714_01
Revises: 20260713_02
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260714_01"
down_revision = "20260713_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
uuid_type = postgresql.UUID(as_uuid=True)
op.create_table(
"collaboration_folders",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
sa.Column("parent_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_collaboration_folders_study_parent", "collaboration_folders", ["study_id", "parent_id"])
op.create_table(
"collaboration_files",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
sa.Column("folder_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("file_type", sa.String(16), nullable=False),
sa.Column("extension", sa.String(16), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
sa.Column("owner_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("current_revision_id", uuid_type),
sa.Column("generation", sa.Integer(), nullable=False, server_default="1"),
sa.Column("deleted_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_collaboration_files_study_folder", "collaboration_files", ["study_id", "folder_id"])
op.create_index("ix_collaboration_files_study_status", "collaboration_files", ["study_id", "status"])
op.create_table(
"collaboration_members",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("role", sa.String(16), nullable=False),
sa.Column("invited_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"),
)
op.create_index("ix_collaboration_members_user", "collaboration_members", ["user_id"])
op.create_table(
"collaboration_revisions",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
sa.Column("revision_no", sa.Integer(), nullable=False),
sa.Column("parent_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
sa.Column("file_uri", sa.String(500), nullable=False),
sa.Column("original_filename", sa.String(255), nullable=False),
sa.Column("file_hash", sa.String(128), nullable=False),
sa.Column("file_size", sa.BigInteger(), nullable=False),
sa.Column("mime_type", sa.String(100), nullable=False),
sa.Column("source", sa.String(24), nullable=False),
sa.Column("change_summary", sa.Text()),
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"),
)
op.create_index("ix_collaboration_revisions_file_created", "collaboration_revisions", ["file_id", "created_at"])
op.create_foreign_key(
"fk_collaboration_files_current_revision",
"collaboration_files",
"collaboration_revisions",
["current_revision_id"],
["id"],
ondelete="SET NULL",
)
op.create_table(
"collaboration_sessions",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
sa.Column("base_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id"), nullable=False),
sa.Column("document_key", sa.String(128), nullable=False),
sa.Column("generation", sa.Integer(), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
sa.Column("started_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("active_users", sa.Text()),
sa.Column("last_callback_at", sa.DateTime(timezone=True)),
sa.Column("closed_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("document_key", name="uq_collaboration_session_document_key"),
)
op.create_index("ix_collaboration_sessions_file_status", "collaboration_sessions", ["file_id", "status"])
op.create_table(
"collaboration_callback_receipts",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("session_id", uuid_type, sa.ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False),
sa.Column("fingerprint", sa.String(128), nullable=False),
sa.Column("callback_status", sa.Integer(), nullable=False),
sa.Column("result", sa.String(24), nullable=False),
sa.Column("revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"),
)
def downgrade() -> None:
op.drop_table("collaboration_callback_receipts")
op.drop_index("ix_collaboration_sessions_file_status", table_name="collaboration_sessions")
op.drop_table("collaboration_sessions")
op.drop_constraint("fk_collaboration_files_current_revision", "collaboration_files", type_="foreignkey")
op.drop_index("ix_collaboration_revisions_file_created", table_name="collaboration_revisions")
op.drop_table("collaboration_revisions")
op.drop_index("ix_collaboration_members_user", table_name="collaboration_members")
op.drop_table("collaboration_members")
op.drop_index("ix_collaboration_files_study_status", table_name="collaboration_files")
op.drop_index("ix_collaboration_files_study_folder", table_name="collaboration_files")
op.drop_table("collaboration_files")
op.drop_index("ix_collaboration_folders_study_parent", table_name="collaboration_folders")
op.drop_table("collaboration_folders")
@@ -0,0 +1,53 @@
"""Add protected public links for collaboration files.
Revision ID: 20260715_01
Revises: 20260714_01
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260715_01"
down_revision = "20260714_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
uuid_type = postgresql.UUID(as_uuid=True)
op.create_table(
"collaboration_share_links",
sa.Column("id", uuid_type, primary_key=True),
sa.Column(
"file_id",
uuid_type,
sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("access_mode", sa.String(12), nullable=False, server_default="VIEW"),
sa.Column("expiry_policy", sa.String(16), nullable=False, server_default="SEVEN_DAYS"),
sa.Column("expires_at", sa.DateTime(timezone=True)),
sa.Column("password_hash", sa.String(255)),
sa.Column("token_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("failed_attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_failed_at", sa.DateTime(timezone=True)),
sa.Column("locked_until", sa.DateTime(timezone=True)),
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("updated_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("file_id", name="uq_collaboration_share_link_file"),
)
op.create_index(
"ix_collaboration_share_links_enabled_expiry",
"collaboration_share_links",
["enabled", "expires_at"],
)
def downgrade() -> None:
op.drop_index("ix_collaboration_share_links_enabled_expiry", table_name="collaboration_share_links")
op.drop_table("collaboration_share_links")
@@ -0,0 +1,25 @@
"""Add effective export controls to collaboration share links.
Revision ID: 20260715_02
Revises: 20260715_01
"""
import sqlalchemy as sa
from alembic import op
revision = "20260715_02"
down_revision = "20260715_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_share_links",
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
)
def downgrade() -> None:
op.drop_column("collaboration_share_links", "allow_export")
@@ -0,0 +1,43 @@
"""Add soft deletion metadata to collaboration revisions.
Revision ID: 20260715_03
Revises: 20260715_02
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260715_03"
down_revision = "20260715_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_revisions",
sa.Column("deleted_by", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"collaboration_revisions",
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_foreign_key(
"fk_collaboration_revision_deleted_by",
"collaboration_revisions",
"users",
["deleted_by"],
["id"],
)
def downgrade() -> None:
op.drop_constraint(
"fk_collaboration_revision_deleted_by",
"collaboration_revisions",
type_="foreignkey",
)
op.drop_column("collaboration_revisions", "deleted_at")
op.drop_column("collaboration_revisions", "deleted_by")
@@ -0,0 +1,35 @@
"""Move collaboration export control to the file.
Revision ID: 20260715_04
Revises: 20260715_03
"""
import sqlalchemy as sa
from alembic import op
revision = "20260715_04"
down_revision = "20260715_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_files",
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
)
# Preserve links that already granted export: after consolidation the same
# setting applies to authenticated collaborators and anonymous visitors.
op.execute(
"""
UPDATE collaboration_files AS file
SET allow_export = true
FROM collaboration_share_links AS link
WHERE link.file_id = file.id AND link.allow_export = true
"""
)
def downgrade() -> None:
op.drop_column("collaboration_files", "allow_export")
@@ -0,0 +1,33 @@
"""Remove the redundant share-link export permission.
Revision ID: 20260715_05
Revises: 20260715_04
"""
import sqlalchemy as sa
from alembic import op
revision = "20260715_05"
down_revision = "20260715_04"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("collaboration_share_links", "allow_export")
def downgrade() -> None:
op.add_column(
"collaboration_share_links",
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.execute(
"""
UPDATE collaboration_share_links AS link
SET allow_export = file.allow_export
FROM collaboration_files AS file
WHERE file.id = link.file_id
"""
)
@@ -0,0 +1,66 @@
"""Add collaboration edit requests and ownership controls.
Revision ID: 20260715_06
Revises: 20260715_05
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260715_06"
down_revision = "20260715_05"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_files",
sa.Column("allow_edit_request", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"collaboration_files",
sa.Column("allow_sheet_structure_edit", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"collaboration_files",
sa.Column("sheet_structure_protection_backup", sa.Text(), nullable=True),
)
op.create_table(
"collaboration_edit_requests",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("requester_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default="PENDING"),
sa.Column("resolved_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["file_id"], ["collaboration_files.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requester_id"], ["users.id"]),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_collaboration_edit_requests_file_status",
"collaboration_edit_requests",
["file_id", "status"],
)
op.create_index(
"uq_collaboration_edit_requests_pending_user",
"collaboration_edit_requests",
["file_id", "requester_id"],
unique=True,
postgresql_where=sa.text("status = 'PENDING'"),
)
def downgrade() -> None:
op.drop_index("uq_collaboration_edit_requests_pending_user", table_name="collaboration_edit_requests")
op.drop_index("ix_collaboration_edit_requests_file_status", table_name="collaboration_edit_requests")
op.drop_table("collaboration_edit_requests")
op.drop_column("collaboration_files", "sheet_structure_protection_backup")
op.drop_column("collaboration_files", "allow_sheet_structure_edit")
op.drop_column("collaboration_files", "allow_edit_request")
@@ -0,0 +1,53 @@
"""Add recipient-scoped generic notifications.
Revision ID: 20260716_01
Revises: 20260715_06
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260716_01"
down_revision = "20260715_06"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("recipient_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("category", sa.String(length=64), nullable=False),
sa.Column("priority", sa.String(length=16), nullable=False, server_default="NORMAL"),
sa.Column("title", sa.String(length=180), nullable=False),
sa.Column("message", sa.String(length=500), nullable=False),
sa.Column("action_path", sa.Text(), nullable=True),
sa.Column("source_type", sa.String(length=64), nullable=False),
sa.Column("source_id", sa.String(length=100), nullable=False),
sa.Column("source_version", sa.String(length=100), nullable=True),
sa.Column("dedupe_key", sa.String(length=255), nullable=False),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["recipient_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"),
)
op.create_index(
"ix_notifications_recipient_study_state",
"notifications",
["recipient_id", "study_id", "resolved_at", "read_at", "created_at"],
)
op.create_index("ix_notifications_source", "notifications", ["source_type", "source_id"])
def downgrade() -> None:
op.drop_index("ix_notifications_source", table_name="notifications")
op.drop_index("ix_notifications_recipient_study_state", table_name="notifications")
op.drop_table("notifications")
@@ -0,0 +1,75 @@
"""Backfill notifications for pending collaboration edit requests.
Revision ID: 20260716_02
Revises: 20260716_01
"""
from alembic import op
revision = "20260716_02"
down_revision = "20260716_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
INSERT INTO notifications (
id,
study_id,
recipient_id,
category,
priority,
title,
message,
action_path,
source_type,
source_id,
dedupe_key,
created_at,
updated_at
)
SELECT
gen_random_uuid(),
collaboration_files.study_id,
study_members.user_id,
'COLLABORATION_EDIT_REQUEST',
'NORMAL',
'新的编辑权限申请',
concat(
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
' 申请编辑“',
collaboration_files.title,
''
),
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
'COLLABORATION_EDIT_REQUEST',
collaboration_edit_requests.id::text,
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
collaboration_edit_requests.created_at,
collaboration_edit_requests.updated_at
FROM collaboration_edit_requests
JOIN collaboration_files
ON collaboration_files.id = collaboration_edit_requests.file_id
JOIN users
ON users.id = collaboration_edit_requests.requester_id
JOIN study_members
ON study_members.study_id = collaboration_files.study_id
AND study_members.is_active IS TRUE
LEFT JOIN collaboration_members
ON collaboration_members.file_id = collaboration_files.id
AND collaboration_members.user_id = study_members.user_id
WHERE collaboration_edit_requests.status = 'PENDING'
AND collaboration_files.deleted_at IS NULL
AND (
study_members.user_id = collaboration_files.owner_id
OR collaboration_members.role = 'MANAGER'
)
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
""")
def downgrade() -> None:
# 这是业务通知数据迁移;降级时保留已读/未读状态,避免删除升级后新产生的同源通知。
pass
@@ -0,0 +1,65 @@
"""Backfill pending edit request notifications for file owners.
Revision ID: 20260716_03
Revises: 20260716_02
"""
from alembic import op
revision = "20260716_03"
down_revision = "20260716_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
INSERT INTO notifications (
id,
study_id,
recipient_id,
category,
priority,
title,
message,
action_path,
source_type,
source_id,
dedupe_key,
created_at,
updated_at
)
SELECT
gen_random_uuid(),
collaboration_files.study_id,
collaboration_files.owner_id,
'COLLABORATION_EDIT_REQUEST',
'NORMAL',
'新的编辑权限申请',
concat(
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
' 申请编辑“',
collaboration_files.title,
''
),
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
'COLLABORATION_EDIT_REQUEST',
collaboration_edit_requests.id::text,
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
collaboration_edit_requests.created_at,
collaboration_edit_requests.updated_at
FROM collaboration_edit_requests
JOIN collaboration_files
ON collaboration_files.id = collaboration_edit_requests.file_id
JOIN users
ON users.id = collaboration_edit_requests.requester_id
WHERE collaboration_edit_requests.status = 'PENDING'
AND collaboration_files.deleted_at IS NULL
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
""")
def downgrade() -> None:
# 与上一数据迁移一致,降级时保留已产生的业务通知状态。
pass
+629
View File
@@ -0,0 +1,629 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, File, Form, Header, Request, Response, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_api_permission
from app.schemas.collaboration import (
CollaborationCallbackPayload,
CollaborationCandidateRead,
CollaborationEditorConfigRead,
CollaborationEditRequestRead,
CollaborationEditRequestResolve,
CollaborationExportRecord,
CollaborationFileCreate,
CollaborationFileRead,
CollaborationFileUpdate,
CollaborationFolderCreate,
CollaborationFolderRead,
CollaborationFolderUpdate,
CollaborationMemberRead,
CollaborationMemberUpsert,
CollaborationOwnershipTransferRequest,
CollaborationPublicEditorConfigRequest,
CollaborationPublicShareMetadata,
CollaborationRestoreRequest,
CollaborationRevisionCopyRequest,
CollaborationRevisionRead,
CollaborationRevisionUpdate,
CollaborationShareAccessGrant,
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
CollaborationSharePasswordRequest,
)
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import collaboration_service, collaboration_share_service, onlyoffice_collaboration_service, onlyoffice_service
router = APIRouter()
public_router = APIRouter()
internal_router = APIRouter(include_in_schema=False)
@router.get(
"/folders",
response_model=list[CollaborationFolderRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_folders(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
return await collaboration_service.list_folders(db, study_id)
@router.post(
"/folders",
response_model=CollaborationFolderRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def create_folder(
study_id: uuid.UUID,
payload: CollaborationFolderCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
return await collaboration_service.create_folder(db, study_id, payload, current_user)
@router.patch(
"/folders/{folder_id}",
response_model=CollaborationFolderRead,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def update_folder(
study_id: uuid.UUID,
folder_id: uuid.UUID,
payload: CollaborationFolderUpdate,
db: AsyncSession = Depends(get_db_session),
):
return await collaboration_service.update_folder(db, study_id, folder_id, payload)
@router.delete(
"/folders/{folder_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def delete_folder(study_id: uuid.UUID, folder_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
await collaboration_service.delete_folder(db, study_id, folder_id)
@router.get(
"/files",
response_model=list[CollaborationFileRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_files(
study_id: uuid.UUID,
folder_id: uuid.UUID | None = None,
keyword: str | None = None,
deleted: bool = False,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
return await collaboration_service.list_files(
db, study_id, current_user, folder_id=folder_id, keyword=keyword, deleted=deleted
)
@router.post(
"/files",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def create_file(
study_id: uuid.UUID,
payload: CollaborationFileCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.create_blank_file(db, study_id, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.post(
"/files/import",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def import_file(
study_id: uuid.UUID,
file: UploadFile = File(...),
folder_id: uuid.UUID | None = Form(None),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.import_file(db, study_id, folder_id, file, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.post(
"/files/{file_id}/copy",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def copy_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
source = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.copy_file(db, source, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/download",
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def download_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
revision = await collaboration_service.prepare_download(db, item, current_user)
return FileResponse(
path=revision.file_uri,
media_type=revision.mime_type,
filename=item.title,
content_disposition_type="attachment",
headers={"Cache-Control": "no-store"},
)
@router.patch(
"/files/{file_id}",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationFileUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.update_file(db, item, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.delete(
"/files/{file_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def trash_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.move_to_trash(db, item, current_user)
@router.post(
"/files/{file_id}/restore",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def restore_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id, include_deleted=True)
item = await collaboration_service.restore_file(db, item, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/members",
response_model=list[CollaborationMemberRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_members(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_members(db, item)
@router.put(
"/files/{file_id}/members",
response_model=CollaborationMemberRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def upsert_member(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationMemberUpsert,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.upsert_member(db, item, payload, current_user)
@router.delete(
"/files/{file_id}/members/{user_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def remove_member(
study_id: uuid.UUID,
file_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.remove_member(db, item, user_id, current_user)
@router.post(
"/files/{file_id}/edit-requests",
response_model=CollaborationEditRequestRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def create_edit_request(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.create_edit_request(db, item, current_user)
@router.get(
"/files/{file_id}/edit-requests",
response_model=list[CollaborationEditRequestRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_edit_requests(
study_id: uuid.UUID,
file_id: uuid.UUID,
pending_only: bool = True,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_edit_requests(db, item, current_user, pending_only=pending_only)
@router.post(
"/files/{file_id}/edit-requests/{request_id}/resolve",
response_model=CollaborationEditRequestRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def resolve_edit_request(
study_id: uuid.UUID,
file_id: uuid.UUID,
request_id: uuid.UUID,
payload: CollaborationEditRequestResolve,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, current_user)
@router.post(
"/files/{file_id}/transfer-ownership",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def transfer_ownership(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationOwnershipTransferRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.transfer_ownership(db, item, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/share-link",
response_model=CollaborationShareLinkRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_share_link(
study_id: uuid.UUID,
file_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await collaboration_share_service.get_share_link(db, item, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.put(
"/files/{file_id}/share-link",
response_model=CollaborationShareLinkRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_share_link(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationShareLinkUpdate,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await collaboration_share_service.update_share_link(db, item, payload, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.get(
"/member-candidates",
response_model=list[CollaborationCandidateRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_member_candidates(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
return await collaboration_service.list_candidates(db, study_id)
@router.get(
"/files/{file_id}/revisions",
response_model=list[CollaborationRevisionRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_revisions(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_revisions(db, item)
@router.patch(
"/files/{file_id}/revisions/{revision_id}",
response_model=CollaborationRevisionRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRevisionUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.update_revision(db, item, revision_id, payload, current_user)
@router.delete(
"/files/{file_id}/revisions/{revision_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def delete_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.delete_revision(db, item, revision_id, current_user)
@router.post(
"/files/{file_id}/revisions/{revision_id}/copy",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[
Depends(require_api_permission("collaboration:create")),
Depends(require_api_permission("collaboration:read")),
],
)
async def copy_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRevisionCopyRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
copied = await collaboration_service.copy_revision(db, item, revision_id, payload, current_user)
return await collaboration_service.file_read(db, copied, current_user)
@router.get(
"/files/{file_id}/revisions/{revision_id}/preview-config",
response_model=OnlyOfficePreviewConfigRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_revision_preview_config(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, current_user)
await onlyoffice_service.ensure_onlyoffice_available()
result = onlyoffice_service.build_preview_config(
resource_type="collaboration_revision",
resource_id=revision.id,
file_name=item.title,
file_hash=revision.file_hash,
user_id=current_user.id,
user_name=current_user.full_name,
)
response.headers["Cache-Control"] = "no-store"
return result
@router.post(
"/files/{file_id}/revisions/{revision_id}/restore",
response_model=CollaborationRevisionRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def restore_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRestoreRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.restore_revision(
db, item, revision_id, current_user, payload.change_summary
)
@router.get(
"/files/{file_id}/editor-config",
response_model=CollaborationEditorConfigRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_editor_config(
study_id: uuid.UUID,
file_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await onlyoffice_collaboration_service.build_editor_config(db, item, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.post(
"/files/{file_id}/exports",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def record_export(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.record_export(db, item, current_user, payload.file_type)
@router.post(
"/files/{file_id}/downloads",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def record_download(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.record_download(db, item, current_user, payload.file_type)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@public_router.get("/metadata", response_model=CollaborationPublicShareMetadata)
async def get_public_share_metadata(
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
result = await collaboration_share_service.public_metadata(db, x_ctms_share_token)
response.headers["Cache-Control"] = "no-store"
return result
@public_router.post("/access", response_model=CollaborationShareAccessGrant)
async def verify_public_share_password(
payload: CollaborationSharePasswordRequest,
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
result = await collaboration_share_service.verify_share_password(
db, x_ctms_share_token, payload.password
)
response.headers["Cache-Control"] = "no-store"
return result
@public_router.post("/editor-config", response_model=CollaborationEditorConfigRead)
async def get_public_share_editor_config(
payload: CollaborationPublicEditorConfigRequest,
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
link, item = await collaboration_share_service.resolve_active_share(db, x_ctms_share_token)
collaboration_share_service.validate_access_grant(link, payload.access_token)
result = await onlyoffice_collaboration_service.build_shared_editor_config(
db,
item,
link,
client_id=payload.client_id,
display_name=payload.display_name,
)
response.headers["Cache-Control"] = "no-store"
return result
@internal_router.get("/internal/onlyoffice/collaboration/sessions/{session_id}/content")
async def get_session_content(session_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session)):
revision, item = await onlyoffice_collaboration_service.get_session_content(
db, session_id, request.headers.get("AuthorizationJwt")
)
return FileResponse(
path=revision.file_uri,
media_type=revision.mime_type,
filename=item.title,
content_disposition_type="inline",
)
@internal_router.post("/internal/onlyoffice/collaboration/sessions/{session_id}/callback")
async def collaboration_callback(
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
onlyoffice_collaboration_service.validate_callback_token(
request.headers.get("AuthorizationJwt"), payload
)
return await onlyoffice_collaboration_service.process_callback(db, session_id, payload)
+62 -3
View File
@@ -1,13 +1,15 @@
import logging
import uuid
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, get_current_user, require_study_member
from app.schemas.notification import NotificationItem
from app.services import document_service
from app.schemas.notification import GeneralNotificationFeed, GeneralNotificationRead, NotificationItem
from app.services import document_service, notification_service, project_reminder_service
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get(
@@ -29,3 +31,60 @@ async def list_notifications(
skip=skip,
limit=limit,
)
@router.get(
"/notifications/feed",
response_model=GeneralNotificationFeed,
dependencies=[Depends(require_study_member())],
)
async def list_general_notifications(
study_id: uuid.UUID,
limit: int = 10,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> GeneralNotificationFeed:
try:
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
except Exception:
await db.rollback()
logger.warning("Failed to synchronize legacy project reminders", exc_info=True)
return await notification_service.list_feed(
db,
study_id=study_id,
recipient_id=current_user.id,
limit=limit,
)
@router.post(
"/notifications/{notification_id}/read",
response_model=GeneralNotificationRead,
dependencies=[Depends(require_study_member())],
)
async def mark_general_notification_read(
study_id: uuid.UUID,
notification_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> GeneralNotificationRead:
return await notification_service.mark_read(
db,
study_id=study_id,
recipient_id=current_user.id,
notification_id=notification_id,
)
@router.post(
"/notifications/read-all",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member())],
)
async def mark_all_general_notifications_read(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> Response:
await notification_service.mark_all_read(db, study_id=study_id, recipient_id=current_user.id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+22
View File
@@ -16,6 +16,7 @@ from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import document as document_crud
from app.crud import document_version as version_crud
from app.models.collaboration import CollaborationRevision
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import document_service, onlyoffice_service
@@ -210,3 +211,24 @@ async def get_internal_version_content(
media_type=version.mime_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(file_name)},
)
@internal_router.get("/internal/onlyoffice/collaboration-revisions/{revision_id}/content")
async def get_internal_collaboration_revision_content(
revision_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
) -> FileResponse:
expected_url = onlyoffice_service.onlyoffice_content_url("collaboration_revision", revision_id)
_authorize_internal_file_request(request, expected_url)
revision = await db.get(CollaborationRevision, revision_id)
file_path = Path(revision.file_uri) if revision else None
if not revision or getattr(revision, "deleted_at", None) is not None or not file_path or not file_path.exists():
raise onlyoffice_service.onlyoffice_error(
"COLLABORATION_REVISION_NOT_FOUND", "协作修订不存在", status.HTTP_404_NOT_FOUND
)
return FileResponse(
path=str(file_path),
media_type=revision.mime_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(revision.original_filename)},
)
+3 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice
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
api_router = APIRouter()
@@ -30,6 +30,8 @@ api_router.include_router(material_equipments.router, prefix="/studies/{study_id
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
api_router.include_router(collaboration.router, prefix="/studies/{study_id}/collaboration", tags=["collaboration"])
api_router.include_router(collaboration.public_router, prefix="/collaboration/shares", tags=["collaboration-shares"])
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
+43
View File
@@ -643,6 +643,43 @@ API_ENDPOINT_PERMISSIONS = {
"description": "删除文档",
"default_roles": ["PM"],
},
# 共享库在线协作(文档级邀请权限在接口权限之后继续校验)
"collaboration:create": {
"module": "shared_library",
"action": "write",
"description": "创建在线协作文件",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"collaboration:read": {
"module": "shared_library",
"action": "read",
"description": "查看在线协作文件",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"collaboration:edit": {
"module": "shared_library",
"action": "write",
"description": "编辑受邀在线协作文件",
"default_roles": ["PM", "CRA", "PV", "QA", "CTA"],
},
"collaboration:manage": {
"module": "shared_library",
"action": "write",
"description": "管理在线协作文件成员与目录",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"collaboration:export": {
"module": "shared_library",
"action": "export",
"description": "另存为在线协作文件副本",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
"collaboration:delete": {
"module": "shared_library",
"action": "write",
"description": "移入或恢复在线协作文件",
"default_roles": ["PM", "CRA", "PV", "QA"],
},
}
def _operation_read_candidates(operation_key: str) -> list[str]:
@@ -843,6 +880,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"precautions:read",
"faq:read",
"faq_category:read",
"collaboration:read",
],
"write": [
"precautions:create",
@@ -858,6 +896,11 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"faq_reply:create",
"faq_reply:delete",
"faq_attachments:delete",
"collaboration:create",
"collaboration:edit",
"collaboration:manage",
"collaboration:export",
"collaboration:delete",
],
},
}
+1
View File
@@ -57,6 +57,7 @@ class Settings(BaseSettings):
ONLYOFFICE_STORAGE_BASE_URL: str = "http://backend:8000"
ONLYOFFICE_INSTANCE_ID: Optional[str] = None
ONLYOFFICE_CONFIG_TTL_SECONDS: int = Field(default=300, ge=60, le=900)
COLLABORATION_MAX_FILE_BYTES: int = Field(default=50 * 1024 * 1024, ge=1024, le=500 * 1024 * 1024)
@lru_cache
+11
View File
@@ -10,6 +10,16 @@ from app.models.audit_log import AuditLog # noqa: F401
from app.models.etmf import EtmfNode # noqa: F401
from app.models.document import Document # noqa: F401
from app.models.document_version import DocumentVersion # noqa: F401
from app.models.collaboration import ( # noqa: F401
CollaborationCallbackReceipt,
CollaborationEditRequest,
CollaborationFile,
CollaborationFolder,
CollaborationMember,
CollaborationRevision,
CollaborationSession,
CollaborationShareLink,
)
from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # noqa: F401
from app.models.milestone import Milestone # noqa: F401
@@ -48,4 +58,5 @@ from app.models.desktop_notification import ( # noqa: F401
DesktopNotificationDelivery,
DesktopNotificationSubscription,
)
from app.models.notification import Notification # noqa: F401
from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401
+2
View File
@@ -12,6 +12,7 @@ from sqlalchemy import text
from app.api.v1.router import api_router
from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_router
from app.api.v1.collaboration import internal_router as collaboration_internal_router
from app.core.config import get_cors_allowed_origins, settings, validate_onlyoffice_configuration
from app.core.exceptions import register_exception_handlers
from app.core.login_crypto import validate_login_crypto_configuration
@@ -300,6 +301,7 @@ def create_app() -> FastAPI:
app.include_router(api_router, prefix="/api/v1")
app.include_router(onlyoffice_internal_router)
app.include_router(collaboration_internal_router)
return app
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class CollaborationFolder(Base):
__tablename__ = "collaboration_folders"
__table_args__ = (
Index("ix_collaboration_folders_study_parent", "study_id", "parent_id"),
)
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)
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationFile(Base):
__tablename__ = "collaboration_files"
__table_args__ = (
Index("ix_collaboration_files_study_folder", "study_id", "folder_id"),
Index("ix_collaboration_files_study_status", "study_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
file_type: Mapped[str] = mapped_column(String(16), nullable=False)
extension: Mapped[str] = mapped_column(String(16), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE")
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
current_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
)
generation: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
allow_export: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
allow_edit_request: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
allow_sheet_structure_edit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
sheet_structure_protection_backup: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationMember(Base):
__tablename__ = "collaboration_members"
__table_args__ = (
UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"),
Index("ix_collaboration_members_user", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False)
invited_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationEditRequest(Base):
__tablename__ = "collaboration_edit_requests"
__table_args__ = (
Index("ix_collaboration_edit_requests_file_status", "file_id", "status"),
Index(
"uq_collaboration_edit_requests_pending_user",
"file_id",
"requester_id",
unique=True,
postgresql_where=text("status = 'PENDING'"),
),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
requester_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="PENDING", server_default="PENDING")
resolved_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationShareLink(Base):
__tablename__ = "collaboration_share_links"
__table_args__ = (
UniqueConstraint("file_id", name="uq_collaboration_share_link_file"),
Index("ix_collaboration_share_links_enabled_expiry", "enabled", "expires_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
access_mode: Mapped[str] = mapped_column(String(12), nullable=False, default="VIEW", server_default="VIEW")
expiry_policy: Mapped[str] = mapped_column(
String(16), nullable=False, default="SEVEN_DAYS", server_default="SEVEN_DAYS"
)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
token_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
failed_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
last_failed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
updated_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class CollaborationRevision(Base):
__tablename__ = "collaboration_revisions"
__table_args__ = (
UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"),
Index("ix_collaboration_revisions_file_created", "file_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
revision_no: Mapped[int] = mapped_column(Integer, nullable=False)
parent_revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
)
file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
source: Mapped[str] = mapped_column(String(24), nullable=False)
change_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
class CollaborationSession(Base):
__tablename__ = "collaboration_sessions"
__table_args__ = (
UniqueConstraint("document_key", name="uq_collaboration_session_document_key"),
Index("ix_collaboration_sessions_file_status", "file_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
file_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False
)
base_revision_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id"), nullable=False
)
document_key: Mapped[str] = mapped_column(String(128), nullable=False)
generation: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE", server_default="ACTIVE")
started_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
active_users: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
last_callback_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class CollaborationCallbackReceipt(Base):
__tablename__ = "collaboration_callback_receipts"
__table_args__ = (
UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False
)
fingerprint: Mapped[str] = mapped_column(String(128), nullable=False)
callback_status: Mapped[int] = mapped_column(Integer, nullable=False)
result: Mapped[str] = mapped_column(String(24), nullable=False)
revision_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("collaboration_revisions.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Notification(Base):
__tablename__ = "notifications"
__table_args__ = (
UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"),
Index(
"ix_notifications_recipient_study_state",
"recipient_id",
"study_id",
"resolved_at",
"read_at",
"created_at",
),
Index("ix_notifications_source", "source_type", "source_id"),
)
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", ondelete="CASCADE"), nullable=False
)
recipient_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
category: Mapped[str] = mapped_column(String(64), nullable=False)
priority: Mapped[str] = mapped_column(String(16), nullable=False, default="NORMAL", server_default="NORMAL")
title: Mapped[str] = mapped_column(String(180), nullable=False)
message: Mapped[str] = mapped_column(String(500), nullable=False)
action_path: Mapped[str | None] = mapped_column(Text, nullable=True)
source_type: Mapped[str] = mapped_column(String(64), nullable=False)
source_id: Mapped[str] = mapped_column(String(100), nullable=False)
source_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
dedupe_key: Mapped[str] = mapped_column(String(255), nullable=False)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+285
View File
@@ -0,0 +1,285 @@
import uuid
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
CollaborationFileType = Literal["word", "cell", "slide"]
CollaborationMemberRole = Literal["EDITOR", "MANAGER"]
CollaborationFileStatus = Literal["ACTIVE", "ARCHIVED", "DELETED"]
CollaborationShareAccessMode = Literal["VIEW", "EDIT"]
CollaborationShareExpiryPolicy = Literal["ONE_DAY", "SEVEN_DAYS", "THIRTY_DAYS", "PERMANENT"]
CollaborationEditRequestStatus = Literal["PENDING", "APPROVED", "REJECTED"]
class CollaborationFolderCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
parent_id: Optional[uuid.UUID] = None
sort_order: int = 0
@field_validator("name")
@classmethod
def normalize_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("文件夹名称不能为空")
return value
class CollaborationFolderUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=120)
parent_id: Optional[uuid.UUID] = None
sort_order: Optional[int] = None
class CollaborationFolderRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
parent_id: Optional[uuid.UUID]
name: str
sort_order: int
created_by: uuid.UUID
deleted_at: Optional[datetime]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class CollaborationFileCreate(BaseModel):
title: str = Field(min_length=1, max_length=240)
file_type: CollaborationFileType
folder_id: Optional[uuid.UUID] = None
class CollaborationFileUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=240)
folder_id: Optional[uuid.UUID] = None
status: Optional[Literal["ACTIVE", "ARCHIVED"]] = None
allow_export: Optional[bool] = None
allow_edit_request: Optional[bool] = None
allow_sheet_structure_edit: Optional[bool] = None
class CollaborationRevisionRead(BaseModel):
id: uuid.UUID
file_id: uuid.UUID
revision_no: int
parent_revision_id: Optional[uuid.UUID]
original_filename: str
file_hash: str
file_size: int
mime_type: str
source: str
change_summary: Optional[str]
created_by: Optional[uuid.UUID]
created_by_name: Optional[str] = None
created_by_avatar_url: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class CollaborationRevisionUpdate(BaseModel):
change_summary: str = Field(min_length=1, max_length=240)
@field_validator("change_summary")
@classmethod
def normalize_change_summary(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("版本名称不能为空")
return value
class CollaborationRevisionCopyRequest(BaseModel):
title: str = Field(min_length=1, max_length=240)
folder_id: Optional[uuid.UUID] = None
@field_validator("title")
@classmethod
def normalize_title(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("文件名不能为空")
return value
class CollaborationFileCollaboratorRead(BaseModel):
user_id: uuid.UUID
full_name: str
role: CollaborationMemberRole
avatar_url: Optional[str] = None
class CollaborationFileRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
folder_id: Optional[uuid.UUID]
title: str
file_type: CollaborationFileType
extension: str
status: CollaborationFileStatus
owner_id: uuid.UUID
current_revision_id: Optional[uuid.UUID]
generation: int
allow_export: bool = False
allow_edit_request: bool = False
allow_sheet_structure_edit: bool = True
deleted_at: Optional[datetime]
created_at: datetime
updated_at: datetime
owner_name: Optional[str] = None
folder_name: Optional[str] = None
current_revision_no: Optional[int] = None
current_revision_file_size: Optional[int] = None
current_revision_mime_type: Optional[str] = None
current_revision_created_at: Optional[datetime] = None
collaboration_role: Optional[CollaborationMemberRole] = None
collaborators: list[CollaborationFileCollaboratorRead] = Field(default_factory=list)
can_edit: bool = False
can_manage: bool = False
can_export: bool = False
can_request_edit: bool = False
edit_request_status: Optional[CollaborationEditRequestStatus] = None
can_transfer_ownership: bool = False
model_config = ConfigDict(from_attributes=True)
class CollaborationMemberUpsert(BaseModel):
user_id: uuid.UUID
role: CollaborationMemberRole
class CollaborationMemberRead(BaseModel):
id: uuid.UUID
file_id: uuid.UUID
user_id: uuid.UUID
role: CollaborationMemberRole
invited_by: uuid.UUID
full_name: str
email: str
created_at: datetime
class CollaborationEditRequestRead(BaseModel):
id: uuid.UUID
file_id: uuid.UUID
requester_id: uuid.UUID
requester_name: str
requester_email: str
status: CollaborationEditRequestStatus
resolved_by: Optional[uuid.UUID]
resolved_at: Optional[datetime]
created_at: datetime
class CollaborationEditRequestResolve(BaseModel):
status: Literal["APPROVED", "REJECTED"]
class CollaborationOwnershipTransferRequest(BaseModel):
new_owner_id: uuid.UUID
class CollaborationShareLinkUpdate(BaseModel):
enabled: bool
access_mode: CollaborationShareAccessMode = "VIEW"
expiry_policy: CollaborationShareExpiryPolicy = "SEVEN_DAYS"
password_mode: Literal["KEEP", "SET", "CLEAR"] = "KEEP"
password: Optional[str] = Field(default=None, min_length=4, max_length=64)
@model_validator(mode="after")
def validate_password_change(self):
if self.password_mode == "SET" and not self.password:
raise ValueError("设置链接密码时必须提供密码")
if self.password_mode != "SET" and self.password is not None:
raise ValueError("仅在设置链接密码时允许提交密码")
return self
class CollaborationShareLinkRead(BaseModel):
id: uuid.UUID
file_id: uuid.UUID
enabled: bool
access_mode: CollaborationShareAccessMode
expiry_policy: CollaborationShareExpiryPolicy
expires_at: Optional[datetime]
has_password: bool
share_path: str = "/collaboration/share"
share_token: Optional[str] = None
created_at: datetime
updated_at: datetime
class CollaborationPublicShareMetadata(BaseModel):
file_name: str
file_type: CollaborationFileType
access_mode: Literal["view", "edit"]
allow_export: bool
requires_password: bool
expires_at: Optional[datetime]
class CollaborationSharePasswordRequest(BaseModel):
password: str = Field(min_length=1, max_length=64)
class CollaborationShareAccessGrant(BaseModel):
access_token: str
expires_at: datetime
class CollaborationPublicEditorConfigRequest(BaseModel):
access_token: Optional[str] = Field(default=None, max_length=2048)
client_id: str = Field(min_length=8, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
display_name: str = Field(default="链接访客", min_length=1, max_length=40)
@field_validator("display_name")
@classmethod
def normalize_display_name(cls, value: str) -> str:
return value.strip() or "链接访客"
class CollaborationCandidateRead(BaseModel):
user_id: uuid.UUID
full_name: str
email: str
role_in_study: str
can_be_editor: bool
can_be_manager: bool
class CollaborationEditorConfigRead(BaseModel):
file_id: uuid.UUID
file_name: str
access_mode: Literal["view", "edit"]
can_save_as: bool = False
can_download: bool = False
can_request_edit: bool = False
host_path: str = "/onlyoffice-host.html"
expires_at: datetime
config: dict
class CollaborationExportRecord(BaseModel):
file_type: str = Field(min_length=1, max_length=16, pattern=r"^[a-z0-9]+$")
class CollaborationCallbackPayload(BaseModel):
key: str
status: int
url: Optional[str] = None
changesurl: Optional[str] = None
filetype: Optional[str] = None
forcesavetype: Optional[int] = None
userdata: Optional[str] = None
users: list[str] = Field(default_factory=list)
actions: list[dict] = Field(default_factory=list)
history: Optional[dict] = None
class CollaborationRestoreRequest(BaseModel):
change_summary: Optional[str] = Field(default=None, max_length=500)
+22
View File
@@ -46,3 +46,25 @@ class DesktopNotificationClaimResponse(BaseModel):
class DesktopNotificationAckRequest(BaseModel):
claim_token: uuid.UUID
delivered_ids: list[uuid.UUID]
class GeneralNotificationRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
recipient_id: uuid.UUID
category: str
priority: str
title: str
message: str
action_path: str | None = None
source_type: str
source_id: str
read_at: datetime | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class GeneralNotificationFeed(BaseModel):
unread_count: int
items: list[GeneralNotificationRead]
+1 -1
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel
class OnlyOfficePreviewConfigRead(BaseModel):
resource_type: Literal["attachment", "version"]
resource_type: Literal["attachment", "version", "collaboration_revision"]
resource_id: uuid.UUID
file_name: str
host_path: str = "/onlyoffice-host.html"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import uuid
from datetime import datetime, timedelta, timezone
from anyio import to_thread
from fastapi import HTTPException, status
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import hash_password, verify_password
from app.models.collaboration import CollaborationFile, CollaborationShareLink
from app.schemas.collaboration import (
CollaborationPublicShareMetadata,
CollaborationShareAccessGrant,
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
)
from app.services import collaboration_service
SHARE_PATH = "/collaboration/share"
SHARE_ACCESS_TTL_SECONDS = 30 * 60
PASSWORD_FAILURE_WINDOW = timedelta(minutes=15)
PASSWORD_LOCK_DURATION = timedelta(minutes=15)
PASSWORD_FAILURE_LIMIT = 5
_ACCESS_PURPOSE = "ctms-collaboration-share-access"
_EXPIRY_DURATIONS = {
"ONE_DAY": timedelta(days=1),
"SEVEN_DAYS": timedelta(days=7),
"THIRTY_DAYS": timedelta(days=30),
"PERMANENT": None,
}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _signing_key() -> bytes:
return hmac.new(
settings.JWT_SECRET_KEY.encode("utf-8"),
b"ctms-collaboration-share-v1",
hashlib.sha256,
).digest()
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _b64decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii"))
def share_token(link: CollaborationShareLink) -> str:
payload = f"{link.id}.{link.token_version}".encode("ascii")
signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
return f"{_b64encode(payload)}.{_b64encode(signature)}"
def _decode_share_token(value: str | None) -> tuple[uuid.UUID, int]:
token = (value or "").strip()
if not token or len(token) > 256 or token.count(".") != 1:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
encoded_payload, encoded_signature = token.split(".", 1)
try:
payload = _b64decode(encoded_payload)
actual_signature = _b64decode(encoded_signature)
if (
not hmac.compare_digest(_b64encode(payload), encoded_payload)
or not hmac.compare_digest(_b64encode(actual_signature), encoded_signature)
):
raise ValueError("non-canonical token encoding")
expected_signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
if not hmac.compare_digest(actual_signature, expected_signature):
raise ValueError("signature mismatch")
raw_id, raw_version = payload.decode("ascii").split(".", 1)
return uuid.UUID(raw_id), int(raw_version)
except (ValueError, UnicodeError, TypeError, binascii.Error) as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") from exc
def _expiry_for_policy(policy: str, now: datetime) -> datetime | None:
duration = _EXPIRY_DURATIONS[policy]
return now + duration if duration else None
def _is_expired(link: CollaborationShareLink, now: datetime | None = None) -> bool:
return bool(link.expires_at and link.expires_at <= (now or _now()))
async def _link_for_file(
db: AsyncSession,
item: CollaborationFile,
user,
*,
create: bool,
lock: bool = False,
) -> CollaborationShareLink | None:
await collaboration_service.require_file_manager(db, item, user)
statement = select(CollaborationShareLink).where(CollaborationShareLink.file_id == item.id)
if lock:
statement = statement.with_for_update()
link = await db.scalar(statement)
if link or not create:
return link
now = _now()
link = CollaborationShareLink(
file_id=item.id,
enabled=False,
access_mode="VIEW",
expiry_policy="SEVEN_DAYS",
expires_at=now + timedelta(days=7),
created_by=user.id,
updated_by=user.id,
)
db.add(link)
await db.flush()
return link
def share_link_read(link: CollaborationShareLink) -> CollaborationShareLinkRead:
return CollaborationShareLinkRead(
id=link.id,
file_id=link.file_id,
enabled=link.enabled,
access_mode=link.access_mode,
expiry_policy=link.expiry_policy,
expires_at=link.expires_at,
has_password=bool(link.password_hash),
share_path=SHARE_PATH,
share_token=share_token(link) if link.enabled else None,
created_at=link.created_at,
updated_at=link.updated_at,
)
async def get_share_link(
db: AsyncSession, item: CollaborationFile, user
) -> CollaborationShareLinkRead:
link = await _link_for_file(db, item, user, create=True)
assert link is not None
await db.commit()
await db.refresh(link)
return share_link_read(link)
async def update_share_link(
db: AsyncSession,
item: CollaborationFile,
payload: CollaborationShareLinkUpdate,
user,
) -> CollaborationShareLinkRead:
link = await _link_for_file(db, item, user, create=True, lock=True)
assert link is not None
now = _now()
link.enabled = payload.enabled
link.access_mode = payload.access_mode
link.expiry_policy = payload.expiry_policy
link.expires_at = _expiry_for_policy(payload.expiry_policy, now)
link.updated_by = user.id
if payload.password_mode == "SET":
link.password_hash = await to_thread.run_sync(hash_password, payload.password or "")
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
elif payload.password_mode == "CLEAR":
link.password_hash = None
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
await collaboration_service._audit(
db,
item,
"COLLABORATION_SHARE_LINK_UPDATED",
user,
{
"enabled": link.enabled,
"access_mode": link.access_mode,
"file_allow_export": item.allow_export,
"expiry_policy": link.expiry_policy,
"has_password": bool(link.password_hash),
},
)
await db.commit()
await db.refresh(link)
return share_link_read(link)
async def resolve_active_share(
db: AsyncSession,
token: str | None,
*,
lock: bool = False,
) -> tuple[CollaborationShareLink, CollaborationFile]:
link_id, version = _decode_share_token(token)
statement = select(CollaborationShareLink).where(CollaborationShareLink.id == link_id)
if lock:
statement = statement.with_for_update()
link = await db.scalar(statement)
if not link or link.token_version != version or not link.enabled:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
if _is_expired(link):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="共享链接已过期")
item = await db.get(CollaborationFile, link.file_id)
if not item or item.deleted_at or item.status != "ACTIVE":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件不存在或已停止共享")
return link, item
async def public_metadata(
db: AsyncSession, token: str | None
) -> CollaborationPublicShareMetadata:
link, item = await resolve_active_share(db, token)
return CollaborationPublicShareMetadata(
file_name=item.title,
file_type=item.file_type,
access_mode="edit" if link.access_mode == "EDIT" else "view",
allow_export=item.allow_export,
requires_password=bool(link.password_hash),
expires_at=link.expires_at,
)
def _grant_token(link: CollaborationShareLink) -> CollaborationShareAccessGrant:
now = _now()
expires_at = now + timedelta(seconds=SHARE_ACCESS_TTL_SECONDS)
if link.expires_at and link.expires_at < expires_at:
expires_at = link.expires_at
value = jwt.encode(
{
"purpose": _ACCESS_PURPOSE,
"sub": str(link.id),
"ver": link.token_version,
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp()),
},
_signing_key().hex(),
algorithm="HS256",
)
return CollaborationShareAccessGrant(access_token=value, expires_at=expires_at)
async def verify_share_password(
db: AsyncSession,
token: str | None,
password: str,
) -> CollaborationShareAccessGrant:
link, _ = await resolve_active_share(db, token, lock=True)
if not link.password_hash:
return _grant_token(link)
now = _now()
if link.locked_until and link.locked_until > now:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="密码尝试次数过多,请稍后再试",
)
if link.last_failed_at and now - link.last_failed_at > PASSWORD_FAILURE_WINDOW:
link.failed_attempts = 0
valid = await to_thread.run_sync(verify_password, password, link.password_hash)
if not valid:
link.failed_attempts += 1
link.last_failed_at = now
if link.failed_attempts >= PASSWORD_FAILURE_LIMIT:
link.locked_until = now + PASSWORD_LOCK_DURATION
await db.commit()
if link.locked_until:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="密码尝试次数过多,请稍后再试",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接密码不正确")
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
await db.commit()
return _grant_token(link)
def validate_access_grant(link: CollaborationShareLink, value: str | None) -> None:
if not link.password_hash:
return
if not value:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请输入链接密码")
try:
payload = jwt.decode(value, _signing_key().hex(), algorithms=["HS256"])
if (
payload.get("purpose") != _ACCESS_PURPOSE
or not hmac.compare_digest(str(payload.get("sub") or ""), str(link.id))
or payload.get("ver") != link.token_version
):
raise JWTError("share grant mismatch")
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接访问凭证已失效") from exc
@@ -0,0 +1,186 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Iterable
from fastapi import HTTPException, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
from app.schemas.notification import GeneralNotificationFeed
async def create_recipient_notifications(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_ids: Iterable[uuid.UUID],
category: str,
priority: str,
title: str,
message: str,
action_path: str | None,
source_type: str,
source_id: str,
dedupe_key: str,
) -> None:
recipients = set(recipient_ids)
if not recipients:
return
existing = set((await db.scalars(
select(Notification.recipient_id).where(
Notification.recipient_id.in_(recipients),
Notification.dedupe_key == dedupe_key,
)
)).all())
for recipient_id in recipients - existing:
db.add(Notification(
study_id=study_id,
recipient_id=recipient_id,
category=category,
priority=priority,
title=title,
message=message,
action_path=action_path,
source_type=source_type,
source_id=source_id,
dedupe_key=dedupe_key,
))
async def sync_aggregate_notification(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
category: str,
priority: str,
title: str,
message: str,
action_path: str,
source_type: str,
source_id: str,
count: int,
) -> None:
dedupe_key = f"aggregate:{source_type}:{source_id}"
item = await db.scalar(select(Notification).where(
Notification.recipient_id == recipient_id,
Notification.dedupe_key == dedupe_key,
))
now = datetime.now(timezone.utc)
if count <= 0:
if item and item.resolved_at is None:
item.resolved_at = now
item.read_at = item.read_at or now
return
version = str(count)
if item is None:
db.add(Notification(
study_id=study_id,
recipient_id=recipient_id,
category=category,
priority=priority,
title=title,
message=message,
action_path=action_path,
source_type=source_type,
source_id=source_id,
source_version=version,
dedupe_key=dedupe_key,
))
return
previous_count = int(item.source_version or 0)
item.priority = priority
item.title = title
item.message = message
item.action_path = action_path
item.source_version = version
if item.resolved_at is not None or count > previous_count:
item.read_at = None
item.created_at = now
item.resolved_at = None
async def resolve_source_notifications(
db: AsyncSession,
*,
source_type: str,
source_id: str,
) -> None:
now = datetime.now(timezone.utc)
await db.execute(
update(Notification)
.where(
Notification.source_type == source_type,
Notification.source_id == source_id,
Notification.resolved_at.is_(None),
)
.values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now))
)
async def list_feed(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
limit: int = 10,
) -> GeneralNotificationFeed:
active_filter = (
Notification.study_id == study_id,
Notification.recipient_id == recipient_id,
Notification.resolved_at.is_(None),
)
unread_count = int(await db.scalar(
select(func.count(Notification.id)).where(*active_filter, Notification.read_at.is_(None))
) or 0)
items = (await db.scalars(
select(Notification)
.where(*active_filter)
.order_by(Notification.read_at.is_not(None), Notification.created_at.desc())
.limit(max(1, min(limit, 50)))
)).all()
return GeneralNotificationFeed(unread_count=unread_count, items=list(items))
async def mark_read(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
notification_id: uuid.UUID,
) -> Notification:
item = await db.scalar(select(Notification).where(
Notification.id == notification_id,
Notification.study_id == study_id,
Notification.recipient_id == recipient_id,
Notification.resolved_at.is_(None),
))
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
if item.read_at is None:
item.read_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(item)
return item
async def mark_all_read(
db: AsyncSession,
*,
study_id: uuid.UUID,
recipient_id: uuid.UUID,
) -> None:
await db.execute(
update(Notification)
.where(
Notification.study_id == study_id,
Notification.recipient_id == recipient_id,
Notification.resolved_at.is_(None),
Notification.read_at.is_(None),
)
.values(read_at=datetime.now(timezone.utc))
)
await db.commit()
@@ -0,0 +1,422 @@
from __future__ import annotations
import hashlib
import hmac
import json
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import httpx
from fastapi import HTTPException, Request, status
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.collaboration import (
CollaborationCallbackReceipt,
CollaborationFile,
CollaborationRevision,
CollaborationSession,
CollaborationShareLink,
)
from app.models.user import User
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
from app.services import collaboration_service, onlyoffice_service
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
fingerprint = f"{settings.ONLYOFFICE_INSTANCE_ID or ''}:collaboration:{file_id}:{generation}"
return f"ctms-collab-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}"
def _content_url(session_id: uuid.UUID) -> str:
return (
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
f"/internal/onlyoffice/collaboration/sessions/{session_id}/content"
)
def _callback_url(session_id: uuid.UUID) -> str:
return (
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
f"/internal/onlyoffice/collaboration/sessions/{session_id}/callback"
)
async def _active_session(
db: AsyncSession, item: CollaborationFile, user_id: uuid.UUID
) -> CollaborationSession:
session = await db.scalar(
select(CollaborationSession).where(
CollaborationSession.file_id == item.id,
CollaborationSession.generation == item.generation,
).order_by(CollaborationSession.created_at.desc())
)
if session:
if session.status != "ACTIVE":
session.status = "ACTIVE"
session.closed_at = None
await db.commit()
await db.refresh(session)
return session
if not item.current_revision_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
session = CollaborationSession(
file_id=item.id,
base_revision_id=item.current_revision_id,
document_key=collaboration_document_key(item.id, item.generation),
generation=item.generation,
started_by=user_id,
)
db.add(session)
await db.commit()
await db.refresh(session)
return session
async def build_editor_config(
db: AsyncSession, item: CollaborationFile, user
) -> CollaborationEditorConfigRead:
await onlyoffice_service.ensure_onlyoffice_available()
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
can_edit = await collaboration_service.can_edit_file(db, item, user)
can_request_edit = await collaboration_service.can_request_edit_file(db, item, user)
can_download = await collaboration_service.can_export_file(db, item, user)
can_save_as = can_download and await collaboration_service.can_create_file(db, item, user)
session = await _active_session(db, item, user.id)
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
config: dict[str, Any] = {
"type": "desktop",
"documentType": item.file_type,
"document": {
"fileType": item.extension,
"key": session.document_key,
"title": item.title,
"url": _content_url(session.id),
"permissions": {
"chat": False,
"copy": can_download,
"comment": can_edit,
"download": can_download,
# In view mode ONLYOFFICE displays "Edit current file" only
# when edit=true and onRequestEditRights is registered. CTMS
# handles that event as an approval request, not an escalation.
"edit": can_edit or can_request_edit,
"fillForms": False,
"modifyContentControl": can_edit,
"modifyFilter": can_edit,
"print": can_download,
"protect": False,
"review": False,
},
},
"editorConfig": {
"callbackUrl": _callback_url(session.id),
"coEditing": {"mode": "fast", "change": False},
"customization": {
"autosave": True,
"chat": False,
"comments": can_edit,
"forcesave": can_edit,
"help": False,
"plugins": False,
},
"lang": "zh-CN",
"mode": "edit" if can_edit else "view",
"user": {"id": str(user.id), "name": user.full_name},
},
}
config["token"] = jwt.encode(
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
settings.ONLYOFFICE_JWT_SECRET or "",
algorithm="HS256",
)
return CollaborationEditorConfigRead(
file_id=item.id,
file_name=item.title,
access_mode="edit" if can_edit else "view",
can_save_as=can_save_as,
can_download=can_download,
can_request_edit=can_request_edit,
expires_at=expires_at,
config=config,
)
async def build_shared_editor_config(
db: AsyncSession,
item: CollaborationFile,
link: CollaborationShareLink,
*,
client_id: str,
display_name: str,
) -> CollaborationEditorConfigRead:
await onlyoffice_service.ensure_onlyoffice_available()
revision = await db.get(CollaborationRevision, item.current_revision_id)
if not revision or not Path(revision.file_uri).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件内容不存在")
can_edit = link.access_mode == "EDIT"
session = await _active_session(db, item, item.owner_id)
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
if link.expires_at and link.expires_at < expires_at:
expires_at = link.expires_at
external_user_id = f"share-{link.id.hex[:12]}-{client_id[:32]}"
config: dict[str, Any] = {
"type": "desktop",
"documentType": item.file_type,
"document": {
"fileType": item.extension,
"key": session.document_key,
"title": item.title,
"url": _content_url(session.id),
"permissions": {
"chat": False,
"copy": item.allow_export,
"comment": can_edit,
"download": item.allow_export,
"edit": can_edit,
"fillForms": False,
"modifyContentControl": can_edit,
"modifyFilter": can_edit,
"print": item.allow_export,
"protect": False,
"review": False,
},
},
"editorConfig": {
"callbackUrl": _callback_url(session.id),
"coEditing": {"mode": "fast", "change": False},
"customization": {
"autosave": can_edit,
"chat": False,
"comments": can_edit,
"forcesave": can_edit,
"help": False,
"plugins": False,
},
"lang": "zh-CN",
"mode": "edit" if can_edit else "view",
"user": {"id": external_user_id, "name": display_name},
},
}
config["token"] = jwt.encode(
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
settings.ONLYOFFICE_JWT_SECRET or "",
algorithm="HS256",
)
return CollaborationEditorConfigRead(
file_id=item.id,
file_name=item.title,
access_mode="edit" if can_edit else "view",
can_save_as=False,
can_download=item.allow_export,
expires_at=expires_at,
config=config,
)
async def get_session_content(
db: AsyncSession, session_id: uuid.UUID, authorization: str | None
) -> tuple[CollaborationRevision, CollaborationFile]:
session = await db.get(CollaborationSession, session_id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
onlyoffice_service.validate_outbox_token(authorization, _content_url(session_id))
revision = await db.get(CollaborationRevision, session.base_revision_id)
item = await db.get(CollaborationFile, session.file_id)
if not revision or not item or not Path(revision.file_uri).exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
return revision, item
def validate_callback_token(token: str | None, payload: CollaborationCallbackPayload) -> dict[str, Any]:
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少 ONLYOFFICE 回调签名")
value = token.strip()
if " " in value:
scheme, credential = value.split(" ", 1)
if scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名格式无效")
value = credential.strip()
try:
decoded = onlyoffice_service.decode_onlyoffice_token(value)
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名无效") from exc
signed = decoded.get("payload") if isinstance(decoded.get("payload"), dict) else decoded
if not isinstance(signed, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调载荷无效")
signed_key = signed.get("key")
signed_status = signed.get("status")
if not isinstance(signed_key, str) or not hmac.compare_digest(signed_key, payload.key):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调 key 不匹配")
if not isinstance(signed_status, int) or signed_status != payload.status:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调状态不匹配")
if payload.url:
signed_url = signed.get("url")
if not isinstance(signed_url, str) or not hmac.compare_digest(signed_url, payload.url):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调文件地址不匹配")
return decoded
def _callback_fingerprint(payload: CollaborationCallbackPayload) -> str:
normalized = payload.model_dump(mode="json", exclude_none=True)
return hashlib.sha256(json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _url_origin_matches(actual, expected) -> bool:
actual_port = actual.port or (443 if actual.scheme == "https" else 80)
expected_port = expected.port or (443 if expected.scheme == "https" else 80)
return (
actual.scheme == expected.scheme
and actual.hostname
and actual.hostname.lower() == (expected.hostname or "").lower()
and actual_port == expected_port
)
def _validate_result_url(url: str) -> str:
actual = urlsplit(url)
if (
actual.scheme not in {"http", "https"}
or actual.username
or actual.password
or actual.fragment
or not actual.hostname
):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
internal = urlsplit(settings.ONLYOFFICE_INTERNAL_URL.rstrip("/"))
if _url_origin_matches(actual, internal):
return urlunsplit((internal.scheme, internal.netloc, actual.path, actual.query, ""))
public = urlsplit(settings.FRONTEND_PUBLIC_URL.rstrip("/"))
proxy_prefix = "/onlyoffice/"
if not _url_origin_matches(actual, public) or not actual.path.startswith(proxy_prefix):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
internal_path = f"{internal.path.rstrip('/')}/{actual.path[len(proxy_prefix):]}"
return urlunsplit((internal.scheme, internal.netloc, internal_path, actual.query, ""))
async def _download_result(url: str) -> bytes:
download_url = _validate_result_url(url)
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
async with client.stream("GET", download_url) as response:
if response.status_code != status.HTTP_200_OK:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败")
content = bytearray()
async for chunk in response.aiter_bytes():
content.extend(chunk)
if len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="ONLYOFFICE 保存文件超出限制")
except httpx.HTTPError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败") from exc
return bytes(content)
async def _callback_user(
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
) -> User | None:
has_public_share_user = False
for value in payload.users:
if value.startswith("share-"):
has_public_share_user = True
continue
try:
user = await db.get(User, uuid.UUID(value))
except (ValueError, TypeError):
user = None
if user:
return user
if has_public_share_user:
return None
user = await db.get(User, session.started_by)
if not user:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话用户不存在")
return user
async def process_callback(
db: AsyncSession,
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
) -> dict[str, int]:
session = await db.scalar(
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
if not hmac.compare_digest(session.document_key, payload.key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话 key 不匹配")
fingerprint = _callback_fingerprint(payload)
duplicate = await db.scalar(select(CollaborationCallbackReceipt.id).where(
CollaborationCallbackReceipt.session_id == session.id,
CollaborationCallbackReceipt.fingerprint == fingerprint,
))
if duplicate:
return {"error": 0}
item = await db.get(CollaborationFile, session.file_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
session.last_callback_at = datetime.now(timezone.utc)
session.active_users = json.dumps(payload.users, ensure_ascii=True)
result = "ACKNOWLEDGED"
saved_revision_id = None
if payload.status in {2, 6}:
if not payload.url:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
if session.generation != item.generation:
result = "STALE"
else:
content = await _download_result(payload.url)
actor = await _callback_user(db, payload, session)
source = "SESSION_CLOSE" if payload.status == 2 else "FORCE_SAVE"
if actor is None:
source = "SHARE_SESSION_CLOSE" if payload.status == 2 else "SHARE_FORCE_SAVE"
revision, created = await collaboration_service.append_revision(
db, item, content, source=source, created_by=actor.id if actor else None
)
saved_revision_id = revision.id
result = "SAVED" if created else "UNCHANGED"
if payload.status == 2:
item.generation += 1
session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc)
else:
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document
# Server 缓存重建时仍从最近一次持久化内容恢复。
session.base_revision_id = revision.id
if created and actor:
await collaboration_service._audit(
db, item, "COLLABORATION_REVISION_SAVED", actor,
{"revision_no": revision.revision_no, "source": source},
)
elif payload.status == 4:
session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc)
result = "UNCHANGED"
elif payload.status in {3, 7}:
session.status = "ERROR"
result = "ERROR"
db.add(CollaborationCallbackReceipt(
session_id=session.id,
fingerprint=fingerprint,
callback_status=payload.status,
result=result,
revision_id=saved_revision_id,
))
await db.commit()
# ONLYOFFICE 要求回调处理器在接收并记录状态后固定确认成功。
# status 3/7 表示文档服务自身保存失败,不应通过 error=1 制造重试环。
return {"error": 0}
+19 -11
View File
@@ -18,7 +18,7 @@ from app.core.exceptions import AppException
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
OnlyOfficeDocumentType = Literal["word", "cell", "slide"]
OnlyOfficeResourceType = Literal["attachment", "version"]
OnlyOfficeResourceType = Literal["attachment", "version", "collaboration_revision"]
WORD_FORMATS = frozenset({
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt",
@@ -52,7 +52,11 @@ def office_format_for_filename(filename: str) -> tuple[str, OnlyOfficeDocumentTy
def onlyoffice_content_url(resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID) -> str:
plural = "attachments" if resource_type == "attachment" else "versions"
plural = {
"attachment": "attachments",
"version": "versions",
"collaboration_revision": "collaboration-revisions",
}[resource_type]
return (
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
f"/internal/onlyoffice/{plural}/{resource_id}/content"
@@ -200,15 +204,7 @@ def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any
)
token = credential.strip()
try:
header = jwt.get_unverified_header(token)
if header.get("alg") != "HS256":
raise JWTError("unexpected algorithm")
payload = jwt.decode(
token,
settings.ONLYOFFICE_JWT_SECRET or "",
algorithms=["HS256"],
options={"verify_aud": False},
)
payload = decode_onlyoffice_token(token)
except JWTError as exc:
raise onlyoffice_error(
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
@@ -225,3 +221,15 @@ def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any
status.HTTP_401_UNAUTHORIZED,
)
return payload
def decode_onlyoffice_token(token: str) -> dict[str, Any]:
header = jwt.get_unverified_header(token)
if header.get("alg") != "HS256":
raise JWTError("unexpected algorithm")
return jwt.decode(
token,
settings.ONLYOFFICE_JWT_SECRET or "",
algorithms=["HS256"],
options={"verify_aud": False},
)
@@ -0,0 +1,72 @@
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, is_system_admin
from app.core.project_permissions import role_has_api_permission
from app.crud import ae as ae_crud
from app.crud import member as member_crud
from app.crud import monitoring_visit_issue as monitoring_issue_crud
from app.services import notification_service
async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) -> None:
membership = await member_crud.get_member(db, study_id, user.id)
if not is_system_admin(user) and (not membership or not membership.is_active):
return
role = membership.role_in_study if membership else ""
can_read_aes = is_system_admin(user) or await role_has_api_permission(
db, study_id, role, "subject_aes:read"
)
can_read_monitoring = is_system_admin(user) or await role_has_api_permission(
db, study_id, role, "monitoring_issues:read"
)
overdue_aes = 0
if can_read_aes:
cra_scope = await get_cra_site_scope(db, study_id, user)
items = await ae_crud.list_ae(
db,
study_id,
overdue=True,
site_ids=cra_scope[0] if cra_scope else None,
)
overdue_aes = len(items)
await notification_service.sync_aggregate_notification(
db,
study_id=study_id,
recipient_id=user.id,
category="RISK_OVERDUE_AE",
priority="HIGH",
title="逾期 AE 待处理",
message=f"当前有 {overdue_aes} 条逾期 AE 需要跟进",
action_path="/risk-issues/sae",
source_type="RISK_OVERDUE_AE",
source_id=str(study_id),
count=overdue_aes if can_read_aes else 0,
)
overdue_monitoring = 0
if can_read_monitoring:
overdue_monitoring = len(await monitoring_issue_crud.list_issues(
db,
study_id,
overdue=True,
limit=2000,
))
await notification_service.sync_aggregate_notification(
db,
study_id=study_id,
recipient_id=user.id,
category="RISK_OVERDUE_MONITORING",
priority="HIGH",
title="监查问题已逾期",
message=f"当前有 {overdue_monitoring} 条监查问题已超过计划解决日期",
action_path="/risk-issues/monitoring-visits",
source_type="RISK_OVERDUE_MONITORING",
source_id=str(study_id),
count=overdue_monitoring if can_read_monitoring else 0,
)
await db.commit()
+12
View File
@@ -272,6 +272,18 @@ def test_faq_and_precautions_are_shared_library_sibling_sections():
assert API_ENDPOINT_PERMISSIONS["precautions:read"]["module"] == "shared_library"
def test_collaboration_is_an_independent_shared_library_section():
shared_library_keys = OPERATION_TO_ENDPOINTS["shared_library"]
assert "collaboration:read" in shared_library_keys["read"]
assert "collaboration:create" in shared_library_keys["write"]
assert "collaboration:edit" in shared_library_keys["write"]
assert "collaboration:manage" in shared_library_keys["write"]
assert "collaboration:export" in shared_library_keys["write"]
assert "collaboration:delete" in shared_library_keys["write"]
assert API_ENDPOINT_PERMISSIONS["collaboration:read"]["module"] == "shared_library"
assert API_ENDPOINT_PERMISSIONS["collaboration:read"]["default_roles"] == ["PM", "CRA", "PV", "QA", "CTA"]
def test_precautions_runtime_code_uses_business_entity_names():
"""运行时代码不应继续使用 knowledge_note 作为注意事项实体命名。"""
backend_root = Path(__file__).resolve().parents[1] / "app"
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
import uuid
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from app.models.notification import Notification
from app.models.collaboration import CollaborationEditRequest
from app.services import collaboration_service, notification_service, project_reminder_service
def test_generic_notification_table_has_recipient_dedupe_and_state_indexes():
table = Notification.__table__
assert table.name == "notifications"
assert {"study_id", "recipient_id", "category", "action_path", "source_type", "source_id", "read_at", "resolved_at"} <= set(table.columns.keys())
assert any(constraint.name == "uq_notifications_recipient_dedupe" for constraint in table.constraints)
assert {index.name for index in table.indexes} >= {
"ix_notifications_recipient_study_state",
"ix_notifications_source",
}
@pytest.mark.asyncio
async def test_recipient_notification_creation_is_deduplicated_per_recipient():
existing_id = uuid.uuid4()
new_id = uuid.uuid4()
result = SimpleNamespace(all=lambda: [existing_id])
db = SimpleNamespace(scalars=AsyncMock(return_value=result), add=Mock())
await notification_service.create_recipient_notifications(
db,
study_id=uuid.uuid4(),
recipient_ids=[existing_id, new_id, new_id],
category="COLLABORATION_EDIT_REQUEST",
priority="NORMAL",
title="新的编辑权限申请",
message="申请编辑文件",
action_path="/knowledge/collaboration?editRequestFile=file-id",
source_type="COLLABORATION_EDIT_REQUEST",
source_id="request-id",
dedupe_key="collaboration-edit-request:request-id",
)
db.add.assert_called_once()
created = db.add.call_args.args[0]
assert created.recipient_id == new_id
assert created.source_id == "request-id"
@pytest.mark.asyncio
async def test_resolving_a_source_closes_every_recipient_notification():
db = SimpleNamespace(execute=AsyncMock())
await notification_service.resolve_source_notifications(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id="request-id",
)
db.execute.assert_awaited_once()
statement = str(db.execute.await_args.args[0])
assert "UPDATE notifications" in statement
assert "source_type" in statement
assert "source_id" in statement
@pytest.mark.asyncio
async def test_project_risks_are_materialized_into_the_generic_notification_table(monkeypatch):
study_id = uuid.uuid4()
user = SimpleNamespace(id=uuid.uuid4(), is_admin=False)
membership = SimpleNamespace(is_active=True, role_in_study="PM")
db = SimpleNamespace(commit=AsyncMock())
sync = AsyncMock()
monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=membership))
monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True))
monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None))
monkeypatch.setattr(project_reminder_service.ae_crud, "list_ae", AsyncMock(return_value=[object(), object()]))
monkeypatch.setattr(project_reminder_service.monitoring_issue_crud, "list_issues", AsyncMock(return_value=[object()]))
monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync)
await project_reminder_service.sync_project_reminders(db, study_id, user)
assert sync.await_count == 2
assert sync.await_args_list[0].kwargs["count"] == 2
assert sync.await_args_list[1].kwargs["count"] == 1
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypatch):
study_id = uuid.uuid4()
file_id = uuid.uuid4()
owner_id = uuid.uuid4()
manager_id = uuid.uuid4()
request_id = uuid.uuid4()
item = SimpleNamespace(
id=file_id,
study_id=study_id,
owner_id=owner_id,
title="PK_示例.xlsx",
allow_edit_request=True,
)
user = SimpleNamespace(id=uuid.uuid4(), full_name="周成成", email="member@example.com")
added = []
def add(value):
added.append(value)
async def flush():
request = next(value for value in added if isinstance(value, CollaborationEditRequest))
request.id = request_id
request.status = "PENDING"
request.resolved_by = None
request.resolved_at = None
request.created_at = datetime.now(timezone.utc)
db = SimpleNamespace(
scalar=AsyncMock(return_value=None),
scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: [owner_id, manager_id])),
add=Mock(side_effect=add),
flush=AsyncMock(side_effect=flush),
commit=AsyncMock(),
refresh=AsyncMock(),
)
notify = AsyncMock()
monkeypatch.setattr(collaboration_service, "can_edit_file", AsyncMock(return_value=False))
monkeypatch.setattr(
collaboration_service.member_crud,
"get_member",
AsyncMock(return_value=SimpleNamespace(is_active=True)),
)
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify)
result = await collaboration_service.create_edit_request(db, item, user)
assert result.id == request_id
assert set(notify.await_args.kwargs["recipient_ids"]) == {owner_id, manager_id}
assert notify.await_args.kwargs["action_path"] == f"/knowledge/collaboration?editRequestFile={file_id}"
assert notify.await_args.kwargs["source_id"] == str(request_id)
+1
View File
@@ -52,6 +52,7 @@ services:
ONLYOFFICE_STORAGE_BASE_URL: ${ONLYOFFICE_STORAGE_BASE_URL:-http://backend:8000}
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:-}
ONLYOFFICE_CONFIG_TTL_SECONDS: ${ONLYOFFICE_CONFIG_TTL_SECONDS:-300}
COLLABORATION_MAX_FILE_BYTES: ${COLLABORATION_MAX_FILE_BYTES:-52428800}
depends_on:
db:
condition: service_healthy
+76
View File
@@ -0,0 +1,76 @@
# ONLYOFFICE 在线协作模块
## 范围
在线协作是共享库下的独立业务模块,不复用附件、文档管理、文件版本管理或 eTMF 的数据表和文件标识。当前支持新建或导入 `DOCX``XLSX``PPTX`,通过 ONLYOFFICE 进行多人共同编辑,并把保存结果固化为不可变修订。
原有附件与文档版本仍保持只读预览流程,不会自动进入协作空间,也不会因协作回调被覆盖。
## 数据与权限边界
- `collaboration_files` 保存协作文件元数据、当前修订指针和统一的内容操作权限设置。
- `collaboration_revisions` 保存不可变文件修订;每次有效保存或历史恢复都会创建新修订。
- `collaboration_members` 保存文件级编辑者和管理者。
- `collaboration_edit_requests` 保存系统内项目成员发起的编辑权限申请及审批结果;同一成员、同一文件同时只允许存在一条待处理申请。
- `notifications` 是收件人级通用通知表,统一保存通知类别、优先级、内部操作路径、业务来源、去重键、已读和业务关闭状态。编辑权限申请只通知当前有效的文件所有者和文件管理者;申请处理后,同一业务来源的所有收件人通知一并关闭。
- `collaboration_sessions` 使用稳定的文件代次生成 ONLYOFFICE `document.key`,同一代次的用户进入同一共同编辑会话。
- `collaboration_callback_receipts` 对回调做幂等确认。
- `collaboration_share_links` 保存单文件公开链接的启停状态、查看/编辑权限、有效期策略和密码哈希;不保存明文密码或共享令牌。
- 项目接口权限只控制在线协作模块入口、项目级新建/导入/文件夹操作,以及账号能否被授予文件角色;文件创建后的编辑、管理、导出和所有权转让统一由文件身份判定,项目角色不再隐式覆盖文件角色。
- 文件所有者始终拥有编辑、管理、导出和转让能力;文件管理者始终拥有编辑、管理和导出能力,但不能转让所有权;文件编辑者可以编辑,导出能力由文件“权限设置”开关控制。系统管理员继续保留平台级完整访问能力。
- 授予编辑者、管理者或转让所有权时,目标账号必须是当前项目的有效成员,并具备对应项目角色资格;不合格账号在联系人列表中不可选择,后端同时拒绝绕过界面的授权请求。
- 文件管理者可开启“允许申请编辑权限”。只读项目成员可从 CTMS 顶栏或 ONLYOFFICE 的“请求编辑”入口提交申请;批准后写入文件级编辑者授权,拒绝或重复处理均有明确状态和审计记录。匿名链接访问者不能申请系统账号权限。
- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”。原逾期 AE 和逾期监查问题也先同步为通用通知,再由同一 Feed 返回,不再由网页和桌面布局分别临时汇总。
- Excel 文件可通过“允许所有协作者添加、删除工作表”控制工作簿结构。关闭时 CTMS 在新的不可变修订中启用工作簿结构保护并推进文件代次;重新开启时恢复文件原有的工作簿保护状态。该开关作用于所有协作者且不影响单元格内容编辑权限。
- 仅文档所有者和系统管理员可转让所有权。目标联系人必须具备文件管理者资格;转让后目标联系人升级为文件管理者,原所有者保留管理者身份,所有权变更写入审计。
- 文件编辑器“下载为”由文件导出规则判定:所有者和管理者始终允许,编辑者受文件开关控制,并由 CTMS 网页/桌面文件运行时保存到本机且记录下载审计。“另存为…”还会在项目工作区创建独立协作文件,因此额外要求项目级 `collaboration:create` 权限。
## 公开分享链接
- 文件管理者可开启链接,并选择只读或协作编辑、1 天/7 天/30 天/永久有效及可选访问密码;设置修改后自动保存,无需额外提交整张表单。
- 文件操作菜单以一个“访问与权限”弹窗统一管理账号授权、匿名访问和“权限设置”;下载、打印、另存和复制不在两种访问方式下重复配置。
- “权限设置”中的内容操作开关只作用于已授权编辑者和匿名访问者。所有者、文件管理者和系统管理员始终保留导出能力;外部链接不具备向项目工作区另存副本的权限。
- 共享令牌只放在网页 URL 的 fragment`#...`)中,不进入服务器请求路径、查询参数或本地缓存;前端调用公开 API 时通过会被审计层脱敏的请求头传递。
- 每个文件首次创建分享记录后使用固定共享地址;修改权限、密码、有效期或关闭后重新开启都不会改变 URL。关闭期间同一地址暂停访问,重新开启后恢复访问。
- 链接密码仅保存 bcrypt 哈希;验证成功后签发短时、链接记录绑定的内存访问凭证。连续错误达到阈值后链接密码验证会临时锁定。
- 外部共享页不依赖 CTMS 登录状态,不提供工作区“另存为”;开启允许导出后可下载到访问者本机,编辑链接仍通过现有 ONLYOFFICE 会话写入不可变修订。
- `JWT_SECRET_KEY` 轮换属于平台级全局失效操作,会使现有公开分享链接和短时访问凭证失效;生产环境必须按安全变更流程评估影响并提前通知链接使用者。
## 保存流程
1. 浏览器请求 `/api/v1/studies/{study_id}/collaboration/files/{file_id}/editor-config`
2. 后端生成带 JWT 的编辑配置、内部内容地址和回调地址。
3. Document Server 从内部内容接口读取当前修订。
4. 共同编辑期间使用 fast 模式;强制保存产生新修订并更新会话恢复基线。
5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`
6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。
内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。
## 本地开发
执行:
```bash
bash scripts/onlyoffice-dev-up.sh
```
脚本会生成或复用独立的开发 JWT 密钥,启用 `office` Profile,并验证只读预览及在线协作的后端路由。协作修订保存在现有后端上传卷下的独立 `collaboration/` 目录。
关键配置:
- `ONLYOFFICE_ENABLED`
- `ONLYOFFICE_JWT_SECRET`
- `ONLYOFFICE_INTERNAL_URL`
- `ONLYOFFICE_STORAGE_BASE_URL`
- `ONLYOFFICE_INSTANCE_ID`
- `ONLYOFFICE_CONFIG_TTL_SECONDS`
- `COLLABORATION_MAX_FILE_BYTES`
生产环境仍默认关闭 ONLYOFFICE;启用前必须按既有发布要求确认镜像许可、JWT 密钥、内部网络、备份与监控。
参考:
- [ONLYOFFICE 共同编辑模式](https://api.onlyoffice.com/docs/docs-api/get-started/how-it-works/co-editing/)
- [ONLYOFFICE 回调处理器](https://api.onlyoffice.com/docs/docs-api/usage-api/callback-handler/)
- [ONLYOFFICE 文档权限](https://api.onlyoffice.com/docs/docs-api/usage-api/config/document/permissions/)
+102 -7
View File
@@ -7,8 +7,15 @@
DOCUMENT_READY: "ctms.onlyoffice.document-ready",
WARNING: "ctms.onlyoffice.warning",
ERROR: "ctms.onlyoffice.error",
DOCUMENT_STATE_CHANGE: "ctms.onlyoffice.document-state-change",
SAVE_AS: "ctms.onlyoffice.save-as",
SAVE_AS_ERROR: "ctms.onlyoffice.save-as-error",
DOWNLOAD: "ctms.onlyoffice.download",
DOWNLOAD_ERROR: "ctms.onlyoffice.download-error",
REQUEST_EDIT_RIGHTS: "ctms.onlyoffice.request-edit-rights",
});
const API_SCRIPT_PATH = "/onlyoffice/web-apps/apps/api/documents/api.js";
const MAX_SAVE_AS_BYTES = 64 * 1024 * 1024;
const ALLOWED_TAURI_ORIGINS = new Set([
"tauri://localhost",
"http://tauri.localhost",
@@ -21,6 +28,7 @@
let apiScriptPromise = null;
let readyTimer = null;
let readyDeadlineTimer = null;
let documentTitle = "download";
const isLoopbackOrigin = (origin) => {
try {
@@ -34,9 +42,9 @@
const isAllowedParentOrigin = (origin) =>
origin === window.location.origin || ALLOWED_TAURI_ORIGINS.has(origin) || isLoopbackOrigin(origin);
const postToParent = (type, detail) => {
const postToParent = (type, detail, transfer = []) => {
if (!parentOrigin || !requestNonce) return;
window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin);
window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin, transfer);
};
const safeEventDetail = (event) => {
@@ -67,6 +75,83 @@
return apiScriptPromise;
};
const saveAsEventDetail = (event) => {
const raw = event && typeof event === "object" && "data" in event ? event.data : null;
if (!raw || typeof raw !== "object") throw new Error("另存为数据格式不正确");
const fileType = typeof raw.fileType === "string" ? raw.fileType.toLowerCase() : "";
const title = typeof raw.title === "string" ? raw.title.trim().slice(0, 240) : "";
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || typeof raw.url !== "string") {
throw new Error("另存为文件信息不完整");
}
const url = new URL(raw.url, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.startsWith("/onlyoffice/")) {
throw new Error("另存为文件地址不可信");
}
return { fileType, title, url };
};
const handleRequestSaveAs = async (event) => {
try {
const { fileType, title, url } = saveAsEventDetail(event);
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
});
if (!response.ok) throw new Error("另存为文件生成失败");
const declaredSize = Number(response.headers.get("content-length") || "0");
if (declaredSize > MAX_SAVE_AS_BYTES) throw new Error("另存为文件超出大小限制");
const data = await response.arrayBuffer();
if (!data.byteLength || data.byteLength > MAX_SAVE_AS_BYTES) throw new Error("另存为文件内容无效");
const mimeType = (response.headers.get("content-type") || "application/octet-stream").slice(0, 120);
postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data]);
} catch (error) {
postToParent(MESSAGE.SAVE_AS_ERROR, {
message: error instanceof Error ? error.message.slice(0, 200) : "另存为失败",
});
}
};
const downloadEventDetail = (event) => {
const raw = event && typeof event === "object" && "data" in event ? event.data : null;
if (!raw || typeof raw !== "object") throw new Error("下载文件数据格式不正确");
const fileType = typeof raw.fileType === "string" ? raw.fileType.toLowerCase() : "";
if (!/^[a-z0-9]{1,16}$/.test(fileType) || typeof raw.url !== "string") {
throw new Error("下载文件信息不完整");
}
const url = new URL(raw.url, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.startsWith("/onlyoffice/")) {
throw new Error("下载文件地址不可信");
}
const suffix = `.${fileType}`;
const stem = documentTitle.toLowerCase().endsWith(suffix)
? documentTitle.slice(0, -suffix.length)
: documentTitle.replace(/\.[^.]+$/, "");
return { fileType, title: `${stem}${suffix}`, url };
};
const handleDownloadAs = async (event) => {
try {
const { fileType, title, url } = downloadEventDetail(event);
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
});
if (!response.ok) throw new Error("下载文件生成失败");
const declaredSize = Number(response.headers.get("content-length") || "0");
if (declaredSize > MAX_SAVE_AS_BYTES) throw new Error("下载文件超出大小限制");
const data = await response.arrayBuffer();
if (!data.byteLength || data.byteLength > MAX_SAVE_AS_BYTES) throw new Error("下载文件内容无效");
const mimeType = (response.headers.get("content-type") || "application/octet-stream").slice(0, 120);
postToParent(MESSAGE.DOWNLOAD, { fileType, title, mimeType, data }, [data]);
} catch (error) {
postToParent(MESSAGE.DOWNLOAD_ERROR, {
message: error instanceof Error ? error.message.slice(0, 200) : "下载失败",
});
}
};
const destroyEditor = () => {
if (editor && typeof editor.destroyEditor === "function") {
try {
@@ -95,14 +180,24 @@
throw new Error("预览配置格式不正确");
}
requestNonce = message.nonce;
documentTitle = typeof config.document?.title === "string" && config.document.title.trim()
? config.document.title.trim().slice(0, 240)
: "download";
await loadOnlyOfficeApi();
const events = {
onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY),
onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)),
onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)),
onDocumentStateChange: (event) => postToParent(MESSAGE.DOCUMENT_STATE_CHANGE, {
changed: Boolean(event && typeof event === "object" && "data" in event ? event.data : event),
}),
onRequestEditRights: () => postToParent(MESSAGE.REQUEST_EDIT_RIGHTS),
};
if (message?.allowSaveAs === true) events.onRequestSaveAs = handleRequestSaveAs;
if (message?.allowDownload === true) events.onDownloadAs = handleDownloadAs;
const editorConfig = {
...config,
events: {
onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY),
onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)),
onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)),
},
events,
};
editor = new window.DocsAPI.DocEditor("onlyoffice-editor", editorConfig);
};
@@ -206,6 +206,11 @@ const verifyOnlyOfficeBoundary = async () => {
assert(hostSource.includes("!isAllowedParentOrigin(event.origin)"), "ONLYOFFICE host must validate the parent origin.");
assert(hostSource.includes("initialized ||"), "ONLYOFFICE host must only accept one initialization.");
assert(hostSource.includes("message?.nonce"), "ONLYOFFICE host must require the in-memory nonce.");
assert(hostSource.includes("url.origin !== window.location.origin"), "ONLYOFFICE save-as URL must remain same-origin.");
assert(hostSource.includes('url.pathname.startsWith("/onlyoffice/")'), "ONLYOFFICE save-as URL must remain under the fixed proxy path.");
assert(hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data])"), "ONLYOFFICE save-as bridge must transfer validated file bytes.");
assert(!hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, url"), "ONLYOFFICE save-as bridge must not expose the signed result URL to business pages.");
assert(viewerSource.includes("allowSaveAs: props.allowSaveAs"), "ONLYOFFICE save-as must stay behind an explicit server capability.");
assert(!/\b(?:localStorage|sessionStorage|console\.)\b/.test(hostSource), "ONLYOFFICE host must not persist or log signed configuration.");
assert(apiSource.includes("cache: false"), "ONLYOFFICE signed config must not enter the desktop data cache.");
assert(apiSource.includes("disableRequestDedupe: true"), "ONLYOFFICE signed config requests must not be deduplicated.");
@@ -290,22 +295,31 @@ const verifyRustBoundary = async () => {
"updates::desktop_update_install",
"desktop_menu_set_shortcuts",
"desktop_window_set_theme",
"desktop_window_get_fullscreen",
"desktop_window_set_fullscreen",
];
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
const missing = allowedCommands.filter((command) => !commands.includes(command));
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
assert(libSource.includes("window.is_fullscreen()"), "Desktop fullscreen state must be read from the native window.");
assert(libSource.includes(".set_fullscreen(fullscreen)"), "Desktop fullscreen changes must target the native window.");
assert(libSource.includes("DESKTOP_FULLSCREEN_CHANGED_EVENT"), "Native fullscreen state must be emitted back to the WebView.");
};
const verifyDesktopUiNativeSync = async () => {
const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8");
const preferencesSource = await readFile(resolve(sourceDir, "runtime/desktopUiPreferences.ts"), "utf8");
const fullscreenSource = await readFile(resolve(sourceDir, "runtime/webFullscreen.ts"), "utf8");
const appSource = await readFile(resolve(sourceDir, "App.vue"), "utf8");
assert(menuSource.includes('invoke("desktop_menu_set_shortcuts"'), "Desktop shortcuts must sync through the controlled Tauri command.");
assert(menuSource.includes("DESKTOP_SHORTCUTS_CHANGED_EVENT"), "Native menu shortcuts must track preference changes.");
assert(preferencesSource.includes('"system" | "light" | "dark"'), "Desktop theme must support following the system appearance.");
assert(preferencesSource.includes('invoke("desktop_window_set_theme"'), "Desktop window theme must sync through the controlled Tauri command.");
assert(fullscreenSource.includes('invoke<boolean>("desktop_window_get_fullscreen"'), "Desktop fullscreen state must use the controlled Tauri query command.");
assert(fullscreenSource.includes('invoke<boolean>("desktop_window_set_fullscreen"'), "Desktop fullscreen changes must use the controlled Tauri mutation command.");
assert(fullscreenSource.includes("ctms:desktop-fullscreen-changed"), "Desktop fullscreen changes must synchronize native window state back to the WebView.");
assert(preferencesSource.includes('matchMedia("(prefers-color-scheme: dark)")'), "System theme changes must update the WebView appearance.");
assert(appSource.includes("initializeDesktopThemePreference()"), "Desktop theme synchronization must initialize at app startup.");
assert(appSource.includes("initializeDesktopMenuShortcutSync()"), "Native menu shortcut synchronization must initialize at app startup.");
+9 -1
View File
@@ -9,6 +9,14 @@ const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
const violations = [];
const toPosixPath = (path) => path.split("\\").join("/");
const platformSource = await readFile(resolve(runtimeDir, "platform.ts"), "utf8");
if (!platformSource.includes("runtimeGlobal.isTauri")) {
violations.push("src/runtime/platform.ts: Tauri v2 runtime detection must use the official isTauri marker");
}
if (platformSource.includes("__TAURI")) {
violations.push("src/runtime/platform.ts: Tauri runtime detection must not depend on legacy or internal markers");
}
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
return (
@@ -28,7 +36,7 @@ for (const path of await walk(sourceDir)) {
const source = await readFile(path, "utf8");
const file = toPosixPath(relative(frontendDir, path));
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
if (source.includes("@tauri-apps/") || source.includes("__TAURI") || /(?:globalThis|window)\.isTauri/.test(source)) {
violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`);
}
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
+40 -6
View File
@@ -7,9 +7,9 @@ use serde::Deserialize;
use tauri::menu::{
AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
};
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{Emitter, Manager, Runtime, Theme, WebviewWindow, WebviewWindowBuilder};
use tauri::{
Emitter, Manager, RunEvent, Runtime, Theme, WebviewWindow, WebviewWindowBuilder, WindowEvent,
};
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
const VIEW_MENU_ID: &str = "ctms.menu.view";
@@ -18,6 +18,7 @@ const REFRESH_COMMAND_ID: &str = "ctms.desktop.refresh";
const TOGGLE_SIDEBAR_COMMAND_ID: &str = "ctms.desktop.toggleSidebar";
const BACK_COMMAND_ID: &str = "ctms.desktop.back";
const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward";
const DESKTOP_FULLSCREEN_CHANGED_EVENT: &str = "ctms:desktop-fullscreen-changed";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -343,6 +344,25 @@ fn desktop_window_set_theme<R: Runtime>(
.map_err(|error| format!("更新桌面窗口主题失败:{error}"))
}
#[tauri::command]
fn desktop_window_get_fullscreen<R: Runtime>(window: WebviewWindow<R>) -> Result<bool, String> {
window
.is_fullscreen()
.map_err(|error| format!("读取桌面窗口全屏状态失败:{error}"))
}
#[tauri::command]
fn desktop_window_set_fullscreen<R: Runtime>(
window: WebviewWindow<R>,
fullscreen: bool,
) -> Result<bool, String> {
window
.set_fullscreen(fullscreen)
.map_err(|error| format!("切换桌面窗口全屏状态失败:{error}"))?;
let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen);
Ok(fullscreen)
}
pub fn run() {
let app = tauri::Builder::default()
.menu(desktop_menu)
@@ -372,19 +392,33 @@ pub fn run() {
updates::desktop_update_install,
desktop_menu_set_shortcuts,
desktop_window_set_theme,
desktop_window_get_fullscreen,
desktop_window_set_fullscreen,
])
.build(tauri::generate_context!())
.expect("error while building CTMS desktop application");
app.run(|app, event| {
#[cfg(target_os = "macos")]
if let RunEvent::Reopen { .. } = event {
if let RunEvent::Reopen { .. } = &event {
if let Err(error) = restore_main_window(app) {
eprintln!("{error}");
}
}
#[cfg(not(target_os = "macos"))]
let _ = (app, event);
if let RunEvent::WindowEvent {
label,
event: WindowEvent::Resized(_),
..
} = &event
{
if label == "main" {
if let Some(window) = app.get_webview_window(label) {
if let Ok(fullscreen) = window.is_fullscreen() {
let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen);
}
}
}
}
});
}
+24
View File
@@ -110,6 +110,30 @@ describe("api axios retry", () => {
expect(adapter).toHaveBeenCalledTimes(2);
});
it("does not attach the CTMS login token to public share requests", async () => {
const auth = await import("../utils/auth");
vi.mocked(auth.getToken).mockReturnValue("private-login-token");
const api = (await import("./axios")).default;
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await api.get("/api/v1/collaboration/shares/metadata", {
adapter,
publicRequest: true,
headers: { "X-CTMS-Share-Token": "share-token" },
} as any);
const config = adapter.mock.calls[0]?.[0] as InternalAxiosRequestConfig;
expect(config.headers.Authorization).toBeUndefined();
expect(config.headers["X-CTMS-Share-Token"]).toBe("share-token");
vi.mocked(auth.getToken).mockReturnValue(null);
});
it("keeps request headers in the dedupe key when they can affect the response", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
+4 -2
View File
@@ -20,6 +20,7 @@ const DEFAULT_GET_CACHE_TTL_MS = 30_000;
const API_RESPONSE_CACHE_SCHEMA_VERSION = 1;
export type ApiRequestConfig = AxiosRequestConfig & {
publicRequest?: boolean;
suppressErrorMessage?: boolean;
_retry?: boolean;
_networkRetryCount?: number;
@@ -225,7 +226,7 @@ instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiReque
config.headers = config.headers || {};
Object.assign(config.headers, getAppMetadataHeaders());
const token = getToken();
if (token) {
if (token && !config.publicRequest) {
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
}
return config;
@@ -237,12 +238,13 @@ instance.interceptors.response.use(
const status = error.response?.status;
const data = error.response?.data;
const reqUrl = error.config?.url || "";
const isPublicRequest = Boolean((error.config as ApiRequestConfig | undefined)?.publicRequest);
const isAuthEndpoint =
reqUrl.includes("/api/v1/auth/login") ||
reqUrl.includes("/api/v1/auth/register") ||
reqUrl.includes("/api/v1/auth/password-reset") ||
reqUrl.includes("/api/v1/auth/extend");
if (isAuthEndpoint) {
if (isAuthEndpoint || isPublicRequest) {
// 认证相关的错误由具体页面自行处理,避免重复提示
return Promise.reject(error);
}
+174
View File
@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPut = vi.fn();
const apiPatch = vi.fn();
const apiDelete = vi.fn();
vi.mock("./axios", () => ({ apiGet, apiPost, apiPut, apiPatch, apiDelete }));
describe("collaboration API", () => {
beforeEach(() => vi.clearAllMocks());
it("keeps editor configuration outside request dedupe and desktop cache", async () => {
const { fetchCollaborationEditorConfig } = await import("./collaboration");
fetchCollaborationEditorConfig("study-1", "file-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/editor-config",
{
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
},
);
});
it("uses an independent collaboration namespace", async () => {
const { createCollaborationFile, fetchCollaborationFiles } = await import("./collaboration");
createCollaborationFile("study-1", { title: "方案", file_type: "word" });
fetchCollaborationFiles("study-1", { keyword: "方案" });
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files",
{ title: "方案", file_type: "word" },
);
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files",
{ params: { keyword: "方案" } },
);
});
it("records a completed Save As operation for audit", async () => {
const { recordCollaborationExport } = await import("./collaboration");
recordCollaborationExport("study-1", "file-1", "xlsx");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/exports",
{ file_type: "xlsx" },
);
});
it("records a completed local download separately from workspace Save As", async () => {
const { recordCollaborationDownload } = await import("./collaboration");
recordCollaborationDownload("study-1", "file-1", "xlsx");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/downloads",
{ file_type: "xlsx" },
);
});
it("copies and downloads collaboration files through authenticated endpoints", async () => {
const { copyCollaborationFile, downloadCollaborationFile } = await import("./collaboration");
copyCollaborationFile("study-1", "file-1");
downloadCollaborationFile("study-1", "file-1");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/copy",
);
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/download",
{
responseType: "blob",
cache: false,
suppressErrorMessage: true,
},
);
});
it("always reloads revision history without cache or pending-request reuse", async () => {
const { fetchCollaborationRevisions } = await import("./collaboration");
fetchCollaborationRevisions("study-1", "file-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions",
{ cache: false, disableRequestDedupe: true },
);
});
it("supports naming, copying, and deleting a selected historical revision", async () => {
const { copyCollaborationRevision, deleteCollaborationRevision, updateCollaborationRevision } = await import("./collaboration");
updateCollaborationRevision("study-1", "file-1", "revision-2", { change_summary: "送审版" });
copyCollaborationRevision("study-1", "file-1", "revision-2", { title: "方案-R2.docx", folder_id: "folder-2" });
deleteCollaborationRevision("study-1", "file-1", "revision-2");
expect(apiPatch).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2",
{ change_summary: "送审版" },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2/copy",
{ title: "方案-R2.docx", folder_id: "folder-2" },
);
expect(apiDelete).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2",
);
});
it("keeps share tokens in a redacted request header and public requests outside auth recovery", async () => {
const {
fetchCollaborationShareLink,
fetchPublicCollaborationShareMetadata,
updateCollaborationShareLink,
verifyPublicCollaborationSharePassword,
} = await import("./collaboration");
fetchCollaborationShareLink("study-1", "file-1");
updateCollaborationShareLink("study-1", "file-1", {
enabled: true,
access_mode: "EDIT",
expiry_policy: "SEVEN_DAYS",
password_mode: "SET",
password: "1234",
});
fetchPublicCollaborationShareMetadata("share-secret");
verifyPublicCollaborationSharePassword("share-secret", "1234");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/share-link",
{ cache: false, disableRequestDedupe: true },
);
expect(apiPut).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/share-link",
expect.objectContaining({ enabled: true, expiry_policy: "SEVEN_DAYS", password: "1234" }),
);
const publicConfig = expect.objectContaining({
headers: { "X-CTMS-Share-Token": "share-secret" },
publicRequest: true,
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
invalidateCache: false,
});
expect(apiGet).toHaveBeenCalledWith("/api/v1/collaboration/shares/metadata", publicConfig);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/collaboration/shares/access",
{ password: "1234" },
publicConfig,
);
});
it("supports edit-request approval and contact ownership transfer", async () => {
const {
createCollaborationEditRequest,
fetchCollaborationEditRequests,
resolveCollaborationEditRequest,
transferCollaborationOwnership,
} = await import("./collaboration");
createCollaborationEditRequest("study-1", "file-1");
fetchCollaborationEditRequests("study-1", "file-1");
resolveCollaborationEditRequest("study-1", "file-1", "request-1", "APPROVED");
transferCollaborationOwnership("study-1", "file-1", "user-2");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/edit-requests",
);
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/edit-requests",
{ cache: false, disableRequestDedupe: true },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/edit-requests/request-1/resolve",
{ status: "APPROVED" },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/transfer-ownership",
{ new_owner_id: "user-2" },
);
});
});
+198
View File
@@ -0,0 +1,198 @@
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./axios";
import type {
CollaborationCandidate,
CollaborationEditorConfig,
CollaborationEditRequest,
CollaborationFile,
CollaborationFileType,
CollaborationFolder,
CollaborationMember,
CollaborationMemberRole,
CollaborationPublicShareMetadata,
CollaborationRevision,
CollaborationShareAccessGrant,
CollaborationShareAccessMode,
CollaborationShareExpiryPolicy,
CollaborationShareLink,
} from "../types/collaboration";
const baseUrl = (studyId: string) => `/api/v1/studies/${studyId}/collaboration`;
export const fetchCollaborationFolders = (studyId: string) =>
apiGet<CollaborationFolder[]>(`${baseUrl(studyId)}/folders`);
export const createCollaborationFolder = (studyId: string, payload: { name: string; parent_id?: string | null }) =>
apiPost<CollaborationFolder>(`${baseUrl(studyId)}/folders`, payload);
export const updateCollaborationFolder = (studyId: string, folderId: string, payload: Record<string, unknown>) =>
apiPatch<CollaborationFolder>(`${baseUrl(studyId)}/folders/${folderId}`, payload);
export const deleteCollaborationFolder = (studyId: string, folderId: string) =>
apiDelete(`${baseUrl(studyId)}/folders/${folderId}`);
export const fetchCollaborationFiles = (
studyId: string,
params?: { folder_id?: string; keyword?: string; deleted?: boolean },
) => apiGet<CollaborationFile[]>(`${baseUrl(studyId)}/files`, { params });
export const fetchCollaborationFile = (studyId: string, fileId: string) =>
apiGet<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}`, {
cache: false,
disableRequestDedupe: true,
});
export const createCollaborationFile = (
studyId: string,
payload: { title: string; file_type: CollaborationFileType; folder_id?: string | null },
) => apiPost<CollaborationFile>(`${baseUrl(studyId)}/files`, payload);
export const importCollaborationFile = (studyId: string, payload: FormData) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/import`, payload, {
headers: { "Content-Type": "multipart/form-data" },
});
export const copyCollaborationFile = (studyId: string, fileId: string) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/copy`);
export const downloadCollaborationFile = (studyId: string, fileId: string) =>
apiGet<Blob>(`${baseUrl(studyId)}/files/${fileId}/download`, {
responseType: "blob",
cache: false,
suppressErrorMessage: true,
});
export const updateCollaborationFile = (studyId: string, fileId: string, payload: Record<string, unknown>) =>
apiPatch<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}`, payload);
export const trashCollaborationFile = (studyId: string, fileId: string) =>
apiDelete(`${baseUrl(studyId)}/files/${fileId}`);
export const restoreCollaborationFile = (studyId: string, fileId: string) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/restore`);
export const fetchCollaborationMembers = (studyId: string, fileId: string) =>
apiGet<CollaborationMember[]>(`${baseUrl(studyId)}/files/${fileId}/members`);
export const fetchCollaborationCandidates = (studyId: string) =>
apiGet<CollaborationCandidate[]>(`${baseUrl(studyId)}/member-candidates`);
export const upsertCollaborationMember = (
studyId: string,
fileId: string,
payload: { user_id: string; role: CollaborationMemberRole },
) => apiPut<CollaborationMember>(`${baseUrl(studyId)}/files/${fileId}/members`, payload);
export const removeCollaborationMember = (studyId: string, fileId: string, userId: string) =>
apiDelete(`${baseUrl(studyId)}/files/${fileId}/members/${userId}`);
export const createCollaborationEditRequest = (studyId: string, fileId: string) =>
apiPost<CollaborationEditRequest>(`${baseUrl(studyId)}/files/${fileId}/edit-requests`);
export const fetchCollaborationEditRequests = (studyId: string, fileId: string) =>
apiGet<CollaborationEditRequest[]>(`${baseUrl(studyId)}/files/${fileId}/edit-requests`, {
cache: false,
disableRequestDedupe: true,
});
export const resolveCollaborationEditRequest = (
studyId: string,
fileId: string,
requestId: string,
status: "APPROVED" | "REJECTED",
) => apiPost<CollaborationEditRequest>(
`${baseUrl(studyId)}/files/${fileId}/edit-requests/${requestId}/resolve`,
{ status },
);
export const transferCollaborationOwnership = (studyId: string, fileId: string, newOwnerId: string) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/transfer-ownership`, {
new_owner_id: newOwnerId,
});
export const fetchCollaborationShareLink = (studyId: string, fileId: string) =>
apiGet<CollaborationShareLink>(`${baseUrl(studyId)}/files/${fileId}/share-link`, {
cache: false,
disableRequestDedupe: true,
});
export const updateCollaborationShareLink = (
studyId: string,
fileId: string,
payload: {
enabled: boolean;
access_mode: CollaborationShareAccessMode;
expiry_policy: CollaborationShareExpiryPolicy;
password_mode: "KEEP" | "SET" | "CLEAR";
password?: string;
},
) => apiPut<CollaborationShareLink>(`${baseUrl(studyId)}/files/${fileId}/share-link`, payload);
export const fetchCollaborationRevisions = (studyId: string, fileId: string) =>
apiGet<CollaborationRevision[]>(`${baseUrl(studyId)}/files/${fileId}/revisions`, {
cache: false,
disableRequestDedupe: true,
});
export const restoreCollaborationRevision = (studyId: string, fileId: string, revisionId: string) =>
apiPost<CollaborationRevision>(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}/restore`, {});
export const updateCollaborationRevision = (
studyId: string,
fileId: string,
revisionId: string,
payload: { change_summary: string },
) => apiPatch<CollaborationRevision>(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}`, payload);
export const deleteCollaborationRevision = (studyId: string, fileId: string, revisionId: string) =>
apiDelete(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}`);
export const copyCollaborationRevision = (
studyId: string,
fileId: string,
revisionId: string,
payload: { title: string; folder_id?: string | null },
) => apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}/copy`, payload);
export const fetchCollaborationEditorConfig = (studyId: string, fileId: string) =>
apiGet<CollaborationEditorConfig>(`${baseUrl(studyId)}/files/${fileId}/editor-config`, {
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
});
export const recordCollaborationExport = (studyId: string, fileId: string, fileType: string) =>
apiPost(`${baseUrl(studyId)}/files/${fileId}/exports`, { file_type: fileType });
export const recordCollaborationDownload = (studyId: string, fileId: string, fileType: string) =>
apiPost(`${baseUrl(studyId)}/files/${fileId}/downloads`, { file_type: fileType });
const publicShareHeaders = (shareToken: string) => ({ "X-CTMS-Share-Token": shareToken });
const publicShareRequestConfig = (shareToken: string) => ({
headers: publicShareHeaders(shareToken),
publicRequest: true,
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
invalidateCache: false,
});
export const fetchPublicCollaborationShareMetadata = (shareToken: string) =>
apiGet<CollaborationPublicShareMetadata>("/api/v1/collaboration/shares/metadata", publicShareRequestConfig(shareToken));
export const verifyPublicCollaborationSharePassword = (shareToken: string, password: string) =>
apiPost<CollaborationShareAccessGrant>(
"/api/v1/collaboration/shares/access",
{ password },
publicShareRequestConfig(shareToken),
);
export const fetchPublicCollaborationEditorConfig = (
shareToken: string,
payload: { access_token?: string; client_id: string; display_name?: string },
) => apiPost<CollaborationEditorConfig>(
"/api/v1/collaboration/shares/editor-config",
payload,
publicShareRequestConfig(shareToken),
);
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
vi.mock("./axios", () => ({ apiGet, apiPost }));
describe("general notification API", () => {
beforeEach(() => vi.clearAllMocks());
it("loads an uncached recipient feed and updates read state", async () => {
const {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
} = await import("./notifications");
listGeneralNotifications("study-1", 8);
markGeneralNotificationRead("study-1", "notification-1");
markAllGeneralNotificationsRead("study-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/feed",
{ params: { limit: 8 }, cache: false, disableRequestDedupe: true },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/notification-1/read",
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/read-all",
);
});
});
+17 -2
View File
@@ -1,5 +1,20 @@
import { apiGet } from "./axios";
import type { NotificationItem } from "../types/notifications";
import { apiGet, apiPost } from "./axios";
import type { GeneralNotificationFeed, GeneralNotificationItem, NotificationItem } from "../types/notifications";
export const listNotifications = (studyId: string, params?: Record<string, any>) =>
apiGet<NotificationItem[]>(`/api/v1/studies/${studyId}/notifications`, { params });
export const listGeneralNotifications = (studyId: string, limit = 10) =>
apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
params: { limit },
cache: false,
disableRequestDedupe: true,
});
export const markGeneralNotificationRead = (studyId: string, notificationId: string) =>
apiPost<GeneralNotificationItem>(
`/api/v1/studies/${studyId}/notifications/${notificationId}/read`,
);
export const markAllGeneralNotificationsRead = (studyId: string) =>
apiPost<void>(`/api/v1/studies/${studyId}/notifications/read-all`);
+9
View File
@@ -13,3 +13,12 @@ export const fetchAttachmentOnlyOfficeConfig = (attachmentId: string) =>
export const fetchVersionOnlyOfficeConfig = (versionId: string) =>
apiGet<OnlyOfficePreviewConfig>(`/api/v1/onlyoffice/versions/${versionId}/config`, noStoreConfig);
export const fetchCollaborationRevisionOnlyOfficeConfig = (
studyId: string,
fileId: string,
revisionId: string,
) => apiGet<OnlyOfficePreviewConfig>(
`/api/v1/studies/${studyId}/collaboration/files/${fileId}/revisions/${revisionId}/preview-config`,
noStoreConfig,
);
@@ -384,12 +384,13 @@ const SECTION_LABELS: Record<string, string> = {
faq_category: "医学咨询/FAQ",
faq_reply: "医学咨询/FAQ",
faq_attachments: "医学咨询/FAQ",
collaboration: "在线协作",
};
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
shared_library: ["医学咨询/FAQ", "注意事项"],
shared_library: ["医学咨询/FAQ", "注意事项", "在线协作"],
};
const getOperationSection = (row: Operation): string => {
+52 -82
View File
@@ -4,10 +4,11 @@
:class="{
'is-sidebar-hidden': !desktopSidebarVisible,
'is-macos': desktopMetadata.platform === 'macos',
'is-immersive-workspace': route.meta.immersiveWorkspace,
}"
@click="closeWorkspaceTabMenu"
>
<aside class="desktop-sidebar">
<aside v-if="!route.meta.immersiveWorkspace" class="desktop-sidebar">
<header class="sidebar-head">
<div class="sidebar-title-row">
<div class="sidebar-app-brand">
@@ -122,7 +123,7 @@
</aside>
<section class="desktop-main">
<header class="desktop-toolbar">
<header v-if="!route.meta.immersiveWorkspace" class="desktop-toolbar">
<div class="toolbar-left" data-tauri-drag-region>
<button
class="toolbar-nav-button sidebar-toggle-button"
@@ -254,7 +255,12 @@
</div>
</el-popover>
<el-dropdown trigger="click" placement="bottom-end" @command="handleReminderCommand">
<el-dropdown
trigger="click"
placement="bottom-end"
@command="handleReminderCommand"
@visible-change="handleReminderVisibilityChange"
>
<button class="icon-button reminder-button" type="button" title="项目提醒">
<el-badge v-if="headerReminderTotal > 0" :value="headerReminderBadgeValue" class="reminder-badge">
<el-icon><Bell /></el-icon>
@@ -277,13 +283,14 @@
:key="item.key"
:command="item.command"
class="reminder-item"
:class="{ 'is-read': item.isRead }"
>
<span class="reminder-mark" :class="`is-${item.tone}`"></span>
<span class="reminder-body">
<span>{{ item.title }}</span>
<small>{{ item.description }}</small>
</span>
<strong>{{ item.count }}</strong>
<strong>{{ item.timeLabel }}</strong>
</el-dropdown-item>
</template>
<div v-else class="reminder-empty">
@@ -326,7 +333,7 @@
</div>
</header>
<div v-if="workspaceTabs.length > 1" class="workspace-tabs">
<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">
<div
class="workspace-tabs-list"
:style="{ gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))` }"
@@ -532,9 +539,8 @@ import {
import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import { useProjectNotifications } from "../composables/useProjectNotifications";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
import {
@@ -657,8 +663,17 @@ const workspaceTabMenu = ref<{
});
const expandedNavigationGroups = ref<Set<string>>(new Set());
const collapsedNavigationGroups = ref<Set<string>>(new Set());
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
const {
headerRemindersLoading,
headerReminderItems,
headerReminderTotal,
headerReminderBadgeValue,
loadHeaderReminders,
handleReminderCommand,
handleReminderVisibilityChange,
startHeaderNotificationPolling,
stopHeaderNotificationPolling,
} = useProjectNotifications();
let desktopMenuUnlisten: (() => void) | undefined;
let desktopUpdateStatusUnlisten: (() => void) | undefined;
let desktopActivityUnlisten: (() => void) | undefined;
@@ -802,8 +817,8 @@ const currentDesktopRoutePreference = computed(() => {
if (!item && route.meta.transientWorkspaceTask) {
return {
path: route.path,
title: currentWorkspaceContext.value?.pageTitle || String(route.meta.title || "文档预览"),
group: "文档预览",
title: currentWorkspaceContext.value?.pageTitle || String(route.meta.title || "工作区任务"),
group: String(route.meta.workspaceTaskGroup || "工作区任务"),
};
}
if (!item) return null;
@@ -888,41 +903,6 @@ const activeWorkspaceTabHistory = computed(() => workspaceTabHistories.value[act
const canNavigateWorkspaceTabBack = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1));
const canNavigateWorkspaceTabForward = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1));
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value);
const toHeaderNumber = (value: unknown) => {
const num = typeof value === "number" ? value : Number(value);
return Number.isFinite(num) && num > 0 ? num : 0;
};
const headerReminderItems = computed(() => [
canReadRiskIssueAes.value && headerReminderStats.value.overdueAes > 0 ? {
key: "overdueAes",
command: "overdueAes",
title: TEXT.common.labels.overdueAes,
description: TEXT.common.labels.overdueAesDesc,
count: headerReminderStats.value.overdueAes,
tone: "danger",
} : null,
canReadMonitoringIssues.value && headerReminderStats.value.overdueMonitoringIssues > 0 ? {
key: "overdueMonitoringIssues",
command: "overdueMonitoringIssues",
title: TEXT.common.labels.overdueMonitoringIssues,
description: TEXT.common.labels.overdueMonitoringIssuesDesc,
count: headerReminderStats.value.overdueMonitoringIssues,
tone: "warning",
} : null,
].filter(Boolean) as Array<{
key: string;
command: string;
title: string;
description: string;
count: number;
tone: "danger" | "warning";
}>);
const headerReminderTotal = computed(() => headerReminderItems.value.reduce((sum, item) => sum + item.count, 0));
const headerReminderBadgeValue = computed(() => (headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value));
const refreshDesktopRoutePreferences = () => {
const allowedPaths = new Set(desktopNavigationItems.value.map((item) => item.path));
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) => allowedPaths.has(item.path));
@@ -1328,42 +1308,6 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
];
});
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || isAdminContext.value || !hasAnyRiskReminderAccess.value) {
headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 };
return;
}
headerRemindersLoading.value = true;
try {
const [aesRes, monitoringRes] = await Promise.allSettled([
canReadRiskIssueAes.value ? fetchOverdueAesCount(studyId) : Promise.resolve(null),
canReadMonitoringIssues.value ? listMonitoringVisitIssues(studyId, { overdue: true, limit: 500 }) : Promise.resolve(null),
]);
if (study.currentStudy?.id !== studyId || isAdminContext.value) return;
headerReminderStats.value = {
overdueAes: aesRes.status === "fulfilled" && aesRes.value ? toHeaderNumber((aesRes.value.data as any)?.total) : 0,
overdueMonitoringIssues: monitoringRes.status === "fulfilled" && monitoringRes.value && Array.isArray(monitoringRes.value.data)
? monitoringRes.value.data.length
: 0,
};
} finally {
if (study.currentStudy?.id === studyId) {
headerRemindersLoading.value = false;
}
}
};
const handleReminderCommand = (cmd: string) => {
if (cmd === "overdueAes" && canReadRiskIssueAes.value) {
router.push("/risk-issues/sae");
return;
}
if (cmd === "overdueMonitoringIssues" && canReadMonitoringIssues.value) {
router.push("/risk-issues/monitoring-visits");
}
};
const logoutImmediately = async () => {
await auth.logout();
study.clearCurrentStudy();
@@ -1521,6 +1465,7 @@ onMounted(async () => {
}
}
loadHeaderReminders();
startHeaderNotificationPolling();
});
onBeforeUnmount(() => {
@@ -1529,6 +1474,7 @@ onBeforeUnmount(() => {
desktopMenuUnlisten?.();
desktopUpdateStatusUnlisten?.();
desktopActivityUnlisten?.();
stopHeaderNotificationPolling();
});
useDesktopShortcuts(
@@ -1575,6 +1521,18 @@ useDesktopShortcuts(
grid-template-columns: 0 minmax(0, 1fr);
}
.desktop-workbench.is-immersive-workspace {
grid-template-columns: minmax(0, 1fr);
}
.desktop-workbench.is-immersive-workspace .desktop-main {
grid-template-rows: minmax(0, 1fr);
}
.desktop-workbench.is-immersive-workspace .desktop-content {
grid-row: 1;
}
.desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {
padding-left: 88px;
}
@@ -1823,6 +1781,7 @@ useDesktopShortcuts(
}
.desktop-toolbar {
grid-row: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -2132,6 +2091,7 @@ useDesktopShortcuts(
padding: 8px 7px !important;
border-radius: 7px;
}
:global(.reminder-item.is-read) { opacity: 0.62 !important; }
.reminder-mark {
width: 7px;
@@ -2146,6 +2106,14 @@ useDesktopShortcuts(
.reminder-mark.is-warning {
background: #c58b2a;
}
.reminder-mark.is-info { background: var(--ctms-primary); }
.reminder-item > strong {
color: #7b8da3;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.reminder-body {
display: inline-flex;
@@ -2353,6 +2321,7 @@ useDesktopShortcuts(
}
.workspace-tabs {
grid-row: 2;
display: flex;
align-items: center;
min-width: 0;
@@ -2726,6 +2695,7 @@ useDesktopShortcuts(
}
.desktop-content {
grid-row: 3;
min-width: 0;
min-height: 0;
overflow: auto;
+24 -3
View File
@@ -76,7 +76,8 @@ describe("desktop layout shell", () => {
it("keeps web project switching routed through the workbench entry", () => {
const webLayout = readWebLayoutSource();
expect(webLayout).toContain('v-if="hasProjectContext" class="workbench-return-button"');
expect(webLayout).not.toContain("workbench-return-button");
expect(webLayout).toContain('<el-dropdown-item command="workbench">');
expect(webLayout).toContain('await router.push("/workbench")');
expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);");
expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"');
@@ -218,6 +219,18 @@ describe("desktop layout shell", () => {
expect(layout).toContain("pointer-events: none;");
});
it("uses the collaboration route immersive mode in the desktop shell", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain("'is-immersive-workspace': route.meta.immersiveWorkspace");
expect(layout).toContain('<aside v-if="!route.meta.immersiveWorkspace" class="desktop-sidebar">');
expect(layout).toContain('<header v-if="!route.meta.immersiveWorkspace" class="desktop-toolbar">');
expect(layout).toContain('v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1"');
expect(layout).toContain(".desktop-workbench.is-immersive-workspace {");
expect(layout).toContain(".desktop-workbench.is-immersive-workspace .desktop-main {");
expect(layout).toContain(".desktop-workbench.is-immersive-workspace .desktop-content {");
});
it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
@@ -302,7 +315,9 @@ describe("desktop layout shell", () => {
it("keeps workspace tabs stable and draggable like browser tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
const tabsStart = source.indexOf(
'<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">',
);
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
@@ -378,6 +393,7 @@ describe("desktop layout shell", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("const workspaceTaskOrigins = ref<Record<string, string>>({});");
expect(source).toContain('group: String(route.meta.workspaceTaskGroup || "工作区任务")');
expect(source).toContain("if (route.meta.transientWorkspaceTask && previousTabPath && previousTabPath !== current.path)");
expect(source).toContain("workspaceTabs.value.find((tab) => tab.path === originPath)");
expect(source).toContain("provide(workspaceTaskControllerKey, { closeTransientTask: closeTransientWorkspaceTask });");
@@ -406,7 +422,9 @@ describe("desktop layout shell", () => {
it("shows the four most recently opened tasks with a searchable all-tasks menu after five tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
const tabsStart = source.indexOf(
'<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">',
);
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
@@ -590,6 +608,9 @@ describe("desktop layout shell", () => {
it("keeps routed desktop pages stretched to the content boundary", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain(".desktop-toolbar {\n grid-row: 1;");
expect(layout).toContain(".workspace-tabs {\n grid-row: 2;");
expect(layout).toContain(".desktop-content {\n grid-row: 3;");
expect(layout).toContain(".desktop-route-shell {");
expect(layout).toContain("width: 100%;");
expect(layout).toContain("max-width: 100%;");
@@ -1,6 +1,10 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isProxy, reactive } from "vue";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
vi.mock("../runtime", () => ({
resolveOnlyOfficeHostUrl: () => new URL("https://ctms.example/onlyoffice-host.html"),
@@ -102,10 +106,102 @@ describe("OnlyOfficeViewer bridge", () => {
wrapper.unmount();
});
it("enables clipboard only for the explicit collaboration editor mode", () => {
const preview = mount(OnlyOfficeViewer, { props: { config: {} } });
expect(preview.get("iframe").attributes("allow")).toContain("'none'");
preview.unmount();
const editor = mount(OnlyOfficeViewer, { props: { config: {}, allowClipboard: true } });
expect(editor.get("iframe").attributes("allow")).toBe("clipboard-read; clipboard-write");
editor.unmount();
});
it("permits iframe downloads only when the document capability allows them", () => {
const restricted = mount(OnlyOfficeViewer, { props: { config: {} } });
expect(restricted.get("iframe").attributes("sandbox")).not.toContain("allow-downloads");
restricted.unmount();
const downloadable = mount(OnlyOfficeViewer, {
props: { config: {}, allowDownload: true },
});
expect(downloadable.get("iframe").attributes("sandbox")).toContain("allow-downloads");
downloadable.unmount();
});
it("reports host and document timeouts without falling back to a download", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
vi.advanceTimersByTime(15_000);
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_LOAD_TIMEOUT" });
wrapper.unmount();
});
it("forwards a nonce-bound edit-right request from ONLYOFFICE", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
const initMessage = postMessage.mock.calls[0][0] as { nonce: string };
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.request-edit-rights",
nonce: initMessage.nonce,
});
expect(wrapper.emitted("requestEditRights")).toHaveLength(1);
wrapper.unmount();
});
it("forwards independent Save As and download capabilities and accepts only nonce-bound file bytes", () => {
const wrapper = mount(OnlyOfficeViewer, {
props: { config: {}, allowSaveAs: true, allowDownload: true },
});
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
const initMessage = postMessage.mock.calls[0][0] as {
nonce: string;
allowSaveAs: boolean;
allowDownload: boolean;
};
expect(initMessage.allowSaveAs).toBe(true);
expect(initMessage.allowDownload).toBe(true);
const data = new ArrayBuffer(8);
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.save-as",
nonce: initMessage.nonce,
detail: { fileType: "xlsx", title: "副本.xlsx", mimeType: "application/octet-stream", data },
});
expect(wrapper.emitted("saveAs")?.[0]?.[0]).toMatchObject({ fileType: "xlsx", title: "副本.xlsx", data });
const downloadData = new ArrayBuffer(12);
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.download",
nonce: initMessage.nonce,
detail: {
fileType: "xlsx",
title: "原始文件.xlsx",
mimeType: "application/octet-stream",
data: downloadData,
},
});
expect(wrapper.emitted("download")?.[0]?.[0]).toMatchObject({
fileType: "xlsx",
title: "原始文件.xlsx",
data: downloadData,
});
wrapper.unmount();
});
it("keeps Save As URLs inside the isolated host and only posts validated bytes to the parent", () => {
const hostSource = readFileSync(resolve(__dirname, "../../public/onlyoffice-host.js"), "utf8");
expect(hostSource).toContain("events.onRequestSaveAs = handleRequestSaveAs");
expect(hostSource).toContain("events.onDownloadAs = handleDownloadAs");
expect(hostSource).toContain('url.origin !== window.location.origin');
expect(hostSource).toContain('url.pathname.startsWith("/onlyoffice/")');
expect(hostSource).toContain("postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data])");
expect(hostSource).toContain("postToParent(MESSAGE.DOWNLOAD, { fileType, title, mimeType, data }, [data])");
expect(hostSource).not.toContain("postToParent(MESSAGE.SAVE_AS, { fileType, title, url");
});
});
+76 -7
View File
@@ -5,9 +5,9 @@
ref="frameRef"
class="onlyoffice-viewer__frame"
:src="hostUrl.href"
title="ONLYOFFICE 文档查看器"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
allow="clipboard-read 'none'; clipboard-write 'none'"
:title="frameTitle"
:sandbox="sandboxPolicy"
:allow="allowPolicy"
referrerpolicy="no-referrer"
@load="handleFrameLoad"
/>
@@ -15,27 +15,60 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, toRaw } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, toRaw } from "vue";
import { resolveOnlyOfficeHostUrl } from "../runtime";
import type { OnlyOfficeHostMessage } from "../types/onlyoffice";
import type { OnlyOfficeHostMessage, OnlyOfficeSaveAsPayload } from "../types/onlyoffice";
const HOST_READY = "ctms.onlyoffice.host-ready";
const INIT = "ctms.onlyoffice.init";
const DOCUMENT_READY = "ctms.onlyoffice.document-ready";
const WARNING = "ctms.onlyoffice.warning";
const ERROR = "ctms.onlyoffice.error";
const SAVE_AS = "ctms.onlyoffice.save-as";
const SAVE_AS_ERROR = "ctms.onlyoffice.save-as-error";
const DOWNLOAD = "ctms.onlyoffice.download";
const DOWNLOAD_ERROR = "ctms.onlyoffice.download-error";
const REQUEST_EDIT_RIGHTS = "ctms.onlyoffice.request-edit-rights";
const HOST_LOAD_TIMEOUT_MS = 15_000;
const DOCUMENT_LOAD_TIMEOUT_MS = 120_000;
const props = defineProps<{ config: Record<string, unknown> }>();
const props = withDefaults(defineProps<{
config: Record<string, unknown>;
allowClipboard?: boolean;
allowSaveAs?: boolean;
allowDownload?: boolean;
frameTitle?: string;
}>(), {
allowClipboard: false,
allowSaveAs: false,
allowDownload: false,
frameTitle: "ONLYOFFICE 文档查看器",
});
const emit = defineEmits<{
ready: [];
warning: [detail: Record<string, unknown>];
error: [detail: Record<string, unknown>];
stateChange: [changed: boolean];
saveAs: [payload: OnlyOfficeSaveAsPayload];
saveAsError: [message: string];
download: [payload: OnlyOfficeSaveAsPayload];
downloadError: [message: string];
requestEditRights: [];
}>();
const frameRef = ref<HTMLIFrameElement | null>(null);
const hostUrl = resolveOnlyOfficeHostUrl();
const allowPolicy = props.allowClipboard
? "clipboard-read; clipboard-write"
: "clipboard-read 'none'; clipboard-write 'none'";
const sandboxPolicy = computed(() => [
"allow-scripts",
"allow-same-origin",
"allow-forms",
"allow-popups",
"allow-modals",
...(props.allowDownload ? ["allow-downloads"] : []),
].join(" "));
const createNonce = () => {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, "0")).join("");
@@ -67,7 +100,13 @@ const initializeHost = () => {
// Vue Proxy Proxy structured clone
// iframeJSON ONLYOFFICE JSON
const config = JSON.parse(JSON.stringify(toRaw(props.config))) as Record<string, unknown>;
frameWindow.postMessage({ type: INIT, nonce, config }, hostUrl.origin);
frameWindow.postMessage({
type: INIT,
nonce,
config,
allowSaveAs: props.allowSaveAs,
allowDownload: props.allowDownload,
}, hostUrl.origin);
} catch {
emitTimeout("HOST_INIT_FAILED", "预览配置初始化失败,请重新加载");
return;
@@ -107,6 +146,36 @@ const handleMessage = (event: MessageEvent<OnlyOfficeHostMessage>) => {
} else if (message.type === ERROR) {
clearTimers();
emit("error", message.detail || {});
} else if (message.type === "ctms.onlyoffice.document-state-change") {
emit("stateChange", Boolean(message.detail?.changed));
} else if (message.type === SAVE_AS) {
const detail = message.detail;
const fileType = typeof detail?.fileType === "string" ? detail.fileType : "";
const title = typeof detail?.title === "string" ? detail.title : "";
const mimeType = typeof detail?.mimeType === "string" ? detail.mimeType : undefined;
const data = detail?.data;
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || !(data instanceof ArrayBuffer)) {
emit("saveAsError", "另存为文件数据无效");
return;
}
emit("saveAs", { fileType, title, mimeType, data });
} else if (message.type === SAVE_AS_ERROR) {
emit("saveAsError", typeof message.detail?.message === "string" ? message.detail.message : "另存为失败");
} else if (message.type === DOWNLOAD) {
const detail = message.detail;
const fileType = typeof detail?.fileType === "string" ? detail.fileType : "";
const title = typeof detail?.title === "string" ? detail.title : "";
const mimeType = typeof detail?.mimeType === "string" ? detail.mimeType : undefined;
const data = detail?.data;
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || !(data instanceof ArrayBuffer)) {
emit("downloadError", "下载文件数据无效");
return;
}
emit("download", { fileType, title, mimeType, data });
} else if (message.type === DOWNLOAD_ERROR) {
emit("downloadError", typeof message.detail?.message === "string" ? message.detail.message : "下载失败");
} else if (message.type === REQUEST_EDIT_RIGHTS) {
emit("requestEditRights");
}
};
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
describe("web layout fullscreen control", () => {
it("offers an accessible browser fullscreen toggle and synchronizes external exits", () => {
const source = readWebLayoutSource();
expect(source).toContain('v-if="webFullscreenAvailable"');
expect(source).toContain(':aria-label="webFullscreenLabel"');
expect(source).toContain(':aria-pressed="webFullscreenActive"');
expect(source).toContain("toggleWebFullscreen");
expect(source).toContain("listenWebFullscreenChange(syncWebFullscreenState)");
expect(source).toContain("webFullscreenUnlisten?.()");
});
it("removes the application chrome for immersive workspace routes", () => {
const source = readWebLayoutSource();
expect(source).toContain("'is-immersive-workspace': route.meta.immersiveWorkspace");
expect(source).toContain('v-if="!route.meta.immersiveWorkspace"');
expect(source).toContain('v-if="isMobileNavOpen && !route.meta.immersiveWorkspace"');
});
});
+89 -152
View File
@@ -1,10 +1,11 @@
<template>
<el-container
class="layout-container web-layout-container"
:class="{ 'mobile-nav-open': isMobileNavOpen, 'mobile-layout': isMobileViewport }"
:class="{ 'mobile-nav-open': isMobileNavOpen, 'mobile-layout': isMobileViewport, 'is-immersive-workspace': route.meta.immersiveWorkspace }"
@keydown.esc="closeMobileNav"
>
<el-aside
v-if="!route.meta.immersiveWorkspace"
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
class="layout-aside"
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
@@ -121,7 +122,7 @@
<el-icon><Flag /></el-icon>
<span>{{ TEXT.menu.etmf }}</span>
</el-menu-item>
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/precautions', '/knowledge/support-files', '/knowledge/instruction-files'])" index="knowledge">
<el-sub-menu v-if="canAccessAnyProjectPath(['/knowledge/medical-consult', '/knowledge/precautions', '/knowledge/support-files', '/knowledge/instruction-files', '/knowledge/collaboration'])" index="knowledge">
<template #title>
<el-icon><Notebook /></el-icon>
<span>{{ TEXT.menu.sharedLibrary }}</span>
@@ -130,13 +131,14 @@
<el-menu-item v-if="canAccessProjectPath('/knowledge/precautions')" index="/knowledge/precautions">{{ TEXT.menu.knowledgeNotes }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/support-files')" index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/instruction-files')" index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
<el-menu-item v-if="canAccessProjectPath('/knowledge/collaboration')" index="/knowledge/collaboration">{{ TEXT.menu.knowledgeCollaboration }}</el-menu-item>
</el-sub-menu>
</el-menu-item-group>
</el-menu>
</el-aside>
<el-container class="main-container">
<el-header class="layout-header">
<el-header v-if="!route.meta.immersiveWorkspace" class="layout-header">
<div class="header-left">
<el-button
class="collapse-btn"
@@ -189,11 +191,6 @@
</div>
<div class="header-right">
<button v-if="hasProjectContext" class="workbench-return-button" type="button" @click="openWorkbenchEntry">
<el-icon><Suitcase /></el-icon>
<span>工作台</span>
</button>
<button v-if="isDesktop" class="desktop-command-trigger" type="button" @click="openCommandPalette">
<el-icon><Search /></el-icon>
<span>搜索命令</span>
@@ -233,7 +230,25 @@
</button>
</el-tooltip>
<el-dropdown trigger="click" placement="bottom-end" class="header-reminder-dropdown" @command="handleReminderCommand">
<el-tooltip v-if="webFullscreenAvailable" :content="webFullscreenLabel" placement="bottom">
<button
class="header-fullscreen-button"
type="button"
:aria-label="webFullscreenLabel"
:aria-pressed="webFullscreenActive"
@click="handleWebFullscreenToggle"
>
<el-icon><ScaleToOriginal v-if="webFullscreenActive" /><FullScreen v-else /></el-icon>
</button>
</el-tooltip>
<el-dropdown
trigger="click"
placement="bottom-end"
class="header-reminder-dropdown"
@command="handleReminderCommand"
@visible-change="handleReminderVisibilityChange"
>
<button class="header-reminder-button" :aria-label="TEXT.common.labels.projectReminders" type="button">
<el-badge
v-if="headerReminderTotal > 0"
@@ -261,27 +276,20 @@
:key="item.key"
:command="item.command"
class="header-reminder-item"
:class="{ 'is-read': item.isRead }"
>
<span class="header-reminder-item-mark" :class="`is-${item.tone}`"></span>
<span class="header-reminder-item-main">
<span class="header-reminder-item-title">{{ item.title }}</span>
<span class="header-reminder-item-desc">{{ item.description }}</span>
</span>
<span class="header-reminder-item-count">{{ item.count }}</span>
<span class="header-reminder-item-count">{{ item.timeLabel }}</span>
</el-dropdown-item>
</template>
<div v-else class="header-reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<el-dropdown-item
v-if="hasAnyRiskReminderAccess"
command="riskIssues"
divided
class="header-reminder-footer"
>
{{ TEXT.common.actions.view }}{{ TEXT.menu.riskIssues }}
</el-dropdown-item>
</div>
</el-dropdown-menu>
</template>
@@ -344,7 +352,7 @@
</el-container>
<button
v-if="isMobileNavOpen"
v-if="isMobileNavOpen && !route.meta.immersiveWorkspace"
type="button"
class="mobile-nav-backdrop"
aria-label="关闭导航菜单"
@@ -396,15 +404,13 @@ import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchStudies } from "../api/studies";
import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales";
import {
User, Suitcase, House, Calendar, Flag,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, UserFilled, Bell, Setting,
Search, Star, StarFilled, Clock, Monitor
Search, Star, StarFilled, Clock, Monitor, FullScreen, ScaleToOriginal
} from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
@@ -415,9 +421,14 @@ import {
getAppMetadata,
getDesktopServerUrl,
isTauriRuntime,
isWebFullscreenActive,
isWebFullscreenAvailable,
listenWebFullscreenChange,
refreshWebFullscreenState,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
toggleDesktopFavoriteRoute,
toggleWebFullscreen,
type DesktopRoutePreference,
} from "../runtime";
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
@@ -428,6 +439,7 @@ import AccountConnectionStatus from "./AccountConnectionStatus.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
import DesktopPreferences from "../views/DesktopPreferences.vue";
import { getActiveLayoutPath } from "./layout/navigation";
import { useProjectNotifications } from "../composables/useProjectNotifications";
const auth = useAuthStore();
const study = useStudyStore();
@@ -459,14 +471,23 @@ const profileDialogVisible = ref(false);
const profileDialogDirty = ref(false);
const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false);
const webFullscreenAvailable = ref(false);
const webFullscreenActive = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerRemindersLoading = ref(false);
const headerReminderStats = ref({
overdueAes: 0,
overdueMonitoringIssues: 0,
});
const {
headerRemindersLoading,
headerReminderItems,
headerReminderTotal,
headerReminderBadgeValue,
loadHeaderReminders,
handleReminderCommand,
handleReminderVisibilityChange,
startHeaderNotificationPolling,
stopHeaderNotificationPolling,
} = useProjectNotifications();
let desktopMenuUnlisten: (() => void) | undefined;
let webFullscreenUnlisten: (() => void) | undefined;
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
const isAdminContext = computed(() => route.path.startsWith("/admin"));
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
@@ -492,76 +513,6 @@ const userDisplayInitial = computed(() => {
});
const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value);
const canReadRiskIssueAes = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => hasProjectContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
const hasAnyRiskReminderAccess = computed(() => canReadRiskIssueAes.value || canReadMonitoringIssues.value);
const toHeaderNumber = (value: unknown) => {
const num = typeof value === "number" ? value : Number(value);
return Number.isFinite(num) && num > 0 ? num : 0;
};
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || !hasProjectContext.value || !hasAnyRiskReminderAccess.value) {
headerReminderStats.value = { overdueAes: 0, overdueMonitoringIssues: 0 };
return;
}
headerRemindersLoading.value = true;
try {
const [aesRes, monitoringRes] = await Promise.allSettled([
canReadRiskIssueAes.value ? fetchOverdueAesCount(studyId) : Promise.resolve(null),
canReadMonitoringIssues.value ? listMonitoringVisitIssues(studyId, { overdue: true, limit: 500 }) : Promise.resolve(null),
]);
if (study.currentStudy?.id !== studyId || isAdminContext.value) return;
headerReminderStats.value = {
overdueAes: aesRes.status === "fulfilled" && aesRes.value ? toHeaderNumber((aesRes.value.data as any)?.total) : 0,
overdueMonitoringIssues: monitoringRes.status === "fulfilled" && monitoringRes.value && Array.isArray(monitoringRes.value.data)
? monitoringRes.value.data.length
: 0,
};
} finally {
if (study.currentStudy?.id === studyId) {
headerRemindersLoading.value = false;
}
}
};
const headerReminderItems = computed(() => [
canReadRiskIssueAes.value && headerReminderStats.value.overdueAes > 0 ? {
key: "overdueAes",
command: "overdueAes",
title: TEXT.common.labels.overdueAes,
description: TEXT.common.labels.overdueAesDesc,
count: headerReminderStats.value.overdueAes,
tone: "danger",
} : null,
canReadMonitoringIssues.value && headerReminderStats.value.overdueMonitoringIssues > 0 ? {
key: "overdueMonitoringIssues",
command: "overdueMonitoringIssues",
title: TEXT.common.labels.overdueMonitoringIssues,
description: TEXT.common.labels.overdueMonitoringIssuesDesc,
count: headerReminderStats.value.overdueMonitoringIssues,
tone: "warning",
} : null,
].filter(Boolean) as Array<{
key: string;
command: string;
title: string;
description: string;
count: number;
tone: "danger" | "warning";
}>);
const headerReminderTotal = computed(() =>
headerReminderItems.value.reduce((sum, item) => sum + item.count, 0)
);
const headerReminderBadgeValue = computed(() =>
headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value
);
const accountProjectRoleLabel = computed(() => {
if (!hasProjectContext.value || !study.currentStudy) return "";
const roleCode = (projectRole.value || "").toUpperCase();
@@ -615,6 +566,7 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "note"] },
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "support"] },
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "instruction"] },
{ label: TEXT.menu.knowledgeCollaboration, path: "/knowledge/collaboration", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "collaboration", "onlyoffice", "协作"] },
];
items.push(...projectItems.filter((item) => canAccessProjectPath(item.path)));
}
@@ -851,6 +803,7 @@ const breadcrumbs = computed(() => {
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions" },
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files" },
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files" },
{ label: TEXT.menu.knowledgeCollaboration, path: "/knowledge/collaboration" },
]},
];
@@ -940,25 +893,6 @@ const handleMenuSelect = (index: string) => {
router.push(index);
};
const handleReminderCommand = (cmd: string) => {
if (cmd === "overdueAes" && canReadRiskIssueAes.value) {
router.push("/risk-issues/sae");
return;
}
if (cmd === "overdueMonitoringIssues" && canReadMonitoringIssues.value) {
router.push("/risk-issues/monitoring-visits");
return;
}
if (cmd === "riskIssues") {
const path = canReadRiskIssueAes.value
? "/risk-issues/sae"
: canReadMonitoringIssues.value
? "/risk-issues/monitoring-visits"
: "";
if (path) router.push(path);
}
};
watch(() => route.path, () => {
closeMobileNav();
study.setViewContext(null);
@@ -987,6 +921,28 @@ const closeMobileNav = () => {
isMobileNavOpen.value = false;
};
const webFullscreenLabel = computed(() => webFullscreenActive.value ? "退出全屏" : "进入全屏");
const syncWebFullscreenState = () => {
webFullscreenAvailable.value = isWebFullscreenAvailable();
webFullscreenActive.value = isWebFullscreenActive();
};
const handleWebFullscreenToggle = async () => {
try {
await toggleWebFullscreen();
syncWebFullscreenState();
} catch (error) {
const message = isTauriRuntime()
? "无法切换桌面全屏,请使用“显示 > 进入全屏”重试"
: typeof error === "object" && error !== null && "name" in error && error.name === "NotAllowedError"
? "浏览器未允许进入全屏,请再次点击或检查站点权限"
: "无法切换全屏,请使用浏览器菜单重试";
ElMessage.warning(message);
syncWebFullscreenState();
}
};
const syncMobileViewport = (matches: boolean) => {
isMobileViewport.value = matches;
if (!matches) closeMobileNav();
@@ -1036,6 +992,9 @@ const onLogout = async () => {
};
onMounted(async () => {
syncWebFullscreenState();
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
if (typeof window.matchMedia === "function") {
mobileViewportMediaQuery = window.matchMedia("(max-width: 767px)");
syncMobileViewport(mobileViewportMediaQuery.matches);
@@ -1059,14 +1018,17 @@ onMounted(async () => {
}
loadStudies();
loadHeaderReminders();
startHeaderNotificationPolling();
});
onBeforeUnmount(() => {
webFullscreenUnlisten?.();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
}
desktopMenuUnlisten?.();
stopHeaderNotificationPolling();
});
const onCommand = (cmd: string) => {
@@ -1572,26 +1534,6 @@ useDesktopShortcuts(
flex-shrink: 0;
}
.workbench-return-button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 10px;
border: 1px solid #c7d7ec;
border-radius: 8px;
background: #eff6ff;
color: #1d4ed8;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.workbench-return-button:hover {
border-color: #93c5fd;
background: #dbeafe;
}
.desktop-command-trigger {
display: inline-grid;
grid-template-columns: auto minmax(0, auto) auto;
@@ -1743,6 +1685,7 @@ useDesktopShortcuts(
align-items: center;
}
.header-fullscreen-button,
.header-reminder-button {
display: inline-flex;
align-items: center;
@@ -1758,6 +1701,7 @@ useDesktopShortcuts(
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.header-fullscreen-button:hover,
.header-reminder-button:hover {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.14);
@@ -1765,6 +1709,7 @@ useDesktopShortcuts(
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.header-fullscreen-button .el-icon,
.header-reminder-button .el-icon {
font-size: 17px;
}
@@ -1835,6 +1780,7 @@ useDesktopShortcuts(
padding: 8px 7px !important;
border-radius: 7px;
}
:global(.header-reminder-item.is-read) { opacity: 0.62 !important; }
.header-reminder-item :deep(.el-dropdown-menu__item) {
width: 100%;
@@ -1854,6 +1800,7 @@ useDesktopShortcuts(
.header-reminder-item-mark.is-warning {
background: #f59e0b;
}
.header-reminder-item-mark.is-info { background: var(--ctms-primary); }
.header-reminder-item-main {
display: inline-flex;
@@ -1881,9 +1828,10 @@ useDesktopShortcuts(
}
.header-reminder-item-count {
color: #0f172a;
font-size: 16px;
font-weight: 750;
color: #7c8da3;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.header-reminder-empty {
@@ -2111,17 +2059,6 @@ useDesktopShortcuts(
gap: 3px;
}
.workbench-return-button {
width: 32px;
padding: 0;
font-size: 0;
}
.workbench-return-button .el-icon {
margin: 0;
font-size: 16px;
}
.header-account-role,
.user-profile .username,
.user-profile .el-icon,
@@ -2161,9 +2098,9 @@ useDesktopShortcuts(
max-width: 42vw;
}
.header-fullscreen-button,
.header-reminder-button,
.collapse-btn,
.workbench-return-button,
.dropdown-trigger.compact {
width: 30px;
height: 30px;
@@ -0,0 +1,101 @@
<template>
<el-dialog
:model-value="modelValue"
width="420px"
class="collaboration-download-dialog"
append-to-body
destroy-on-close
:show-close="!saving"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="collaboration-download-dialog__title">下载文件</div>
</template>
<div class="collaboration-download-dialog__content">
<div class="collaboration-download-dialog__icon" aria-hidden="true">
<el-icon><Download /></el-icon>
</div>
<div class="collaboration-download-dialog__body">
<div class="collaboration-download-dialog__name" :title="fileName">{{ fileName }}</div>
<p>文件已生成请选择本机保存位置</p>
</div>
</div>
<template #footer>
<el-button :disabled="saving" @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="emit('confirm')">选择保存位置</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Download } from "@element-plus/icons-vue";
defineProps<{
modelValue: boolean;
fileName: string;
saving?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [];
}>();
</script>
<style>
.collaboration-download-dialog {
border-radius: 12px;
}
.collaboration-download-dialog .el-dialog__header {
margin-right: 0;
padding: 18px 20px 8px;
}
.collaboration-download-dialog .el-dialog__body {
padding: 14px 20px 18px;
}
.collaboration-download-dialog .el-dialog__footer {
padding: 0 20px 18px;
}
.collaboration-download-dialog__title {
color: #172033;
font-size: 17px;
font-weight: 650;
}
.collaboration-download-dialog__content {
display: flex;
align-items: center;
gap: 14px;
}
.collaboration-download-dialog__icon {
display: grid;
flex: 0 0 42px;
width: 42px;
height: 42px;
place-items: center;
border-radius: 10px;
color: #3f6783;
background: #edf3f7;
font-size: 21px;
}
.collaboration-download-dialog__body {
min-width: 0;
}
.collaboration-download-dialog__name {
overflow: hidden;
color: #24364a;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.collaboration-download-dialog__body p {
margin: 5px 0 0;
color: #718096;
font-size: 13px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,237 @@
<template>
<el-dialog
:model-value="modelValue"
width="900px"
append-to-body
class="collaboration-action-dialog revision-save-as-dialog"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header><strong class="revision-save-as__title">另存为</strong></template>
<div class="revision-save-as">
<aside class="revision-save-as__sidebar">
<div>
<span class="revision-save-as__sidebar-label">保存位置</span>
<button type="button" class="is-active" @click="selectedFolderId = null">
<el-icon><FolderOpened /></el-icon>
<span>项目工作区</span>
</button>
</div>
</aside>
<div class="revision-save-as__browser">
<div class="revision-save-as__toolbar">
<div class="revision-save-as__breadcrumbs">
<button type="button" @click="selectedFolderId = null">项目工作区</button>
<template v-for="folder in breadcrumbs" :key="folder.id">
<span>/</span>
<button type="button" @click="selectedFolderId = folder.id">{{ folder.name }}</button>
</template>
</div>
<el-input v-model="query" clearable placeholder="搜索文件" class="revision-save-as__search" />
</div>
<div v-loading="loadingWorkspace" class="revision-save-as__items">
<button
v-for="folder in visibleFolders"
:key="folder.id"
type="button"
class="revision-save-as__folder"
@click="selectedFolderId = folder.id"
>
<el-icon><Folder /></el-icon>
<span>{{ folder.name }}</span>
</button>
<div v-for="file in visibleFiles" :key="file.id" class="revision-save-as__file" aria-disabled="true">
<span class="revision-save-as__file-badge" :class="`is-${file.file_type}`">{{ file.file_type === "word" ? "W" : file.file_type === "cell" ? "X" : "P" }}</span>
<span>{{ file.title }}</span>
</div>
<el-empty
v-if="!loadingWorkspace && !visibleFolders.length && !visibleFiles.length"
description="当前目录暂无内容"
:image-size="52"
/>
</div>
<div class="revision-save-as__bottom">
<label class="revision-save-as__filename">
<span>文件名称</span>
<el-input v-model="title" maxlength="240" @keyup.enter="submit" />
</label>
<div class="revision-save-as__actions">
<el-button v-if="canCreateFolder" :icon="Plus" text class="revision-save-as__new-folder" @click="createFolder">新建文件夹</el-button>
<span class="revision-save-as__actions-spacer" />
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">另存</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Folder, FolderOpened, Plus } from "@element-plus/icons-vue";
import {
createCollaborationFolder,
fetchCollaborationFiles,
fetchCollaborationFolders,
} from "../../api/collaboration";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import type { CollaborationFile, CollaborationFolder } from "../../types/collaboration";
const props = withDefaults(defineProps<{
modelValue: boolean;
studyId: string;
initialTitle: string;
initialFolderId?: string | null;
saving?: boolean;
canCreateFolder?: boolean;
}>(), {
initialFolderId: null,
saving: false,
canCreateFolder: false,
});
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [payload: { title: string; folderId: string | null }];
}>();
const folders = ref<CollaborationFolder[]>([]);
const files = ref<CollaborationFile[]>([]);
const selectedFolderId = ref<string | null>(null);
const title = ref("");
const query = ref("");
const loadingWorkspace = ref(false);
const breadcrumbs = computed(() => {
const result: CollaborationFolder[] = [];
const seen = new Set<string>();
let folderId = selectedFolderId.value;
while (folderId && !seen.has(folderId)) {
seen.add(folderId);
const folder = folders.value.find((item) => item.id === folderId);
if (!folder) break;
result.unshift(folder);
folderId = folder.parent_id || null;
}
return result;
});
const visibleFolders = computed(() => {
const keyword = query.value.trim().toLocaleLowerCase("zh-CN");
if (keyword) return folders.value.filter((item) => item.name.toLocaleLowerCase("zh-CN").includes(keyword));
return folders.value.filter((item) => (item.parent_id || null) === selectedFolderId.value);
});
const visibleFiles = computed(() => {
const keyword = query.value.trim().toLocaleLowerCase("zh-CN");
return files.value.filter((item) =>
item.status !== "DELETED" &&
(item.folder_id || null) === selectedFolderId.value &&
(!keyword || item.title.toLocaleLowerCase("zh-CN").includes(keyword)),
);
});
const loadWorkspace = async () => {
if (!props.studyId) return;
loadingWorkspace.value = true;
try {
const [folderResponse, fileResponse] = await Promise.all([
fetchCollaborationFolders(props.studyId),
fetchCollaborationFiles(props.studyId),
]);
folders.value = folderResponse.data;
files.value = fileResponse.data;
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "工作区加载失败"));
} finally {
loadingWorkspace.value = false;
}
};
const createFolder = async () => {
try {
const { value } = await ElMessageBox.prompt("请输入文件夹名称", "新建文件夹", {
inputPlaceholder: "文件夹名称",
inputValidator: (input) => Boolean(String(input || "").trim()) || "文件夹名称不能为空",
confirmButtonText: "新建",
});
const { data } = await createCollaborationFolder(props.studyId, {
name: value.trim(),
parent_id: selectedFolderId.value,
});
folders.value = [...folders.value, data];
selectedFolderId.value = data.id;
ElMessage.success(`文件夹“${data.name}”已创建`);
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "文件夹创建失败"));
}
};
const submit = () => {
const normalized = title.value.trim();
if (!normalized) {
ElMessage.warning("请输入文件名称");
return;
}
emit("confirm", { title: normalized, folderId: selectedFolderId.value });
};
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
title.value = props.initialTitle;
selectedFolderId.value = props.initialFolderId || null;
query.value = "";
void loadWorkspace();
},
);
</script>
<style scoped>
.revision-save-as { display: grid; height: min(560px, calc(100vh - 112px)); min-height: 430px; overflow: hidden; grid-template-columns: 180px minmax(0, 1fr); background: var(--ctms-bg-card); }
.revision-save-as__title { position: relative; z-index: 1; color: var(--ctms-text-main); font-size: 15px; font-weight: 650; }
.revision-save-as__sidebar { display: flex; min-height: 0; padding: 26px 14px 18px; flex-direction: column; background: color-mix(in srgb, var(--ctms-bg-muted) 72%, var(--ctms-bg-card)); }
.revision-save-as__sidebar-label { display: block; margin: 0 12px 10px; color: var(--ctms-text-disabled); font-size: 11px; letter-spacing: 0.04em; }
.revision-save-as__sidebar button { display: flex; width: 100%; height: 42px; align-items: center; gap: 9px; padding: 0 13px; border: 0; border-radius: 8px; color: var(--ctms-text-regular); background: transparent; font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
.revision-save-as__sidebar button .el-icon { font-size: 17px; }
.revision-save-as__sidebar button.is-active { color: var(--ctms-primary); background: color-mix(in srgb, var(--ctms-primary) 11%, var(--ctms-bg-card)); font-weight: 650; }
.revision-save-as__browser { display: grid; min-width: 0; min-height: 0; grid-template-rows: 66px minmax(0, 1fr) auto; }
.revision-save-as__toolbar { display: flex; min-width: 0; align-items: center; gap: 16px; padding: 12px 18px; }
.revision-save-as__breadcrumbs { display: flex; min-width: 0; align-items: center; gap: 6px; flex: 1; overflow: hidden; color: var(--ctms-text-disabled); }
.revision-save-as__breadcrumbs button { max-width: 150px; overflow: hidden; padding: 3px 2px; border: 0; color: var(--ctms-text-main); background: transparent; font: inherit; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.revision-save-as__breadcrumbs button:hover { color: var(--ctms-primary); }
.revision-save-as__search { width: 220px; }
.revision-save-as__search :deep(.el-input__wrapper) { min-height: 36px; border-radius: 8px; }
.revision-save-as__items { min-height: 0; padding: 16px 22px; overflow: auto; }
.revision-save-as__folder,
.revision-save-as__file { display: flex; width: 100%; min-width: 0; height: 42px; align-items: center; gap: 10px; padding: 0 8px; border: 0; border-radius: 6px; background: transparent; font: inherit; font-size: 13px; text-align: left; }
.revision-save-as__folder { color: var(--ctms-text-main); cursor: pointer; }
.revision-save-as__folder:hover { background: var(--ctms-bg-muted); }
.revision-save-as__folder .el-icon { flex: 0 0 auto; color: var(--ctms-primary); font-size: 21px; }
.revision-save-as__folder span,
.revision-save-as__file > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.revision-save-as__file { color: var(--ctms-text-disabled); cursor: default; }
.revision-save-as__file-badge { display: inline-flex; width: 22px; height: 22px; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 4px; color: #fff; font-size: 9px; font-weight: 750; opacity: 0.42; }
.revision-save-as__file-badge.is-word { background: #4472c4; }
.revision-save-as__file-badge.is-cell { background: #2e8b57; }
.revision-save-as__file-badge.is-slide { background: #d05a35; }
.revision-save-as__items > :deep(.el-empty) { padding-top: 62px; }
.revision-save-as__items > :deep(.el-empty__description) { margin-top: 10px; }
.revision-save-as__items > :deep(.el-empty__description p) { color: var(--ctms-text-disabled); font-size: 12px; }
.revision-save-as__bottom { display: grid; gap: 12px; padding: 14px 18px 16px; background: var(--ctms-bg-card); }
.revision-save-as__filename { display: flex; min-width: 0; align-items: center; gap: 12px; }
.revision-save-as__filename > span { flex: 0 0 auto; color: var(--ctms-text-main); font-size: 13px; }
.revision-save-as__filename .el-input { flex: 1; }
.revision-save-as__filename :deep(.el-input__wrapper) { min-height: 36px; border-radius: 8px; }
.revision-save-as__actions { display: flex; min-width: 0; align-items: center; gap: 10px; }
.revision-save-as__actions > .el-button { min-width: 70px; height: 34px; margin-left: 0; border-radius: 7px; }
.revision-save-as__new-folder.el-button { min-width: auto; padding-left: 0; color: var(--ctms-text-main); }
.revision-save-as__actions-spacer { flex: 1; }
:global(.revision-save-as-dialog.el-dialog) { max-width: calc(100vw - 32px); overflow: hidden; padding: 0; border-radius: 12px; box-shadow: 0 20px 54px rgb(15 23 42 / 20%); }
:global(.revision-save-as-dialog.el-dialog .el-dialog__header) { min-height: 50px; margin: 0; padding: 15px 18px; border-bottom: 0 !important; background: linear-gradient(to right, color-mix(in srgb, var(--ctms-bg-muted) 72%, var(--ctms-bg-card)) 0 180px, var(--ctms-bg-card) 180px 100%); box-shadow: none !important; }
:global(.revision-save-as-dialog.el-dialog .el-dialog__headerbtn) { top: 6px; right: 9px; width: 38px; height: 38px; }
:global(.revision-save-as-dialog.el-dialog .el-dialog__body) { padding: 0; }
</style>
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildAdminNavigationItems } from "./navigation";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { buildAdminNavigationItems, buildProjectNavigationItems } from "./navigation";
const labels = (items: ReturnType<typeof buildAdminNavigationItems>) => items.map((item) => item.label);
const webLayoutSource = readFileSync(resolve(__dirname, "../WebLayout.vue"), "utf8");
describe("system management navigation", () => {
it("exposes project management, audit logs, and permission management to PMs", () => {
@@ -26,3 +29,22 @@ describe("system management navigation", () => {
expect(labels(items)).toEqual(["项目管理"]);
});
});
describe("project shared-library navigation", () => {
it("registers online collaboration as an independent shared-library entry", () => {
const groups = buildProjectNavigationItems({
hasCurrentStudy: true,
hasAnyProjectModuleAccess: true,
canAccessProjectPath: () => true,
});
const entries = groups.flatMap((group) => group.children || []);
expect(entries.some((item) => item.path === "/knowledge/collaboration" && item.label === "在线协作")).toBe(true);
});
it("keeps the web sidebar, command navigation, and breadcrumb tree in sync", () => {
expect(webLayoutSource).toContain('index="/knowledge/collaboration"');
expect(webLayoutSource.match(/path: "\/knowledge\/collaboration"/g)?.length).toBeGreaterThanOrEqual(2);
expect(webLayoutSource.match(/TEXT\.menu\.knowledgeCollaboration/g)?.length).toBeGreaterThanOrEqual(3);
});
});
@@ -47,6 +47,8 @@ export const getActiveLayoutPath = (path: string) => {
if (path.startsWith("/knowledge/precautions")) return "/knowledge/precautions";
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
if (/^\/knowledge\/collaboration\/[^/]+$/.test(path)) return path;
if (path.startsWith("/knowledge/collaboration")) return "/knowledge/collaboration";
if (path.startsWith("/projects/")) return "/admin/projects";
if (path.startsWith("/admin/projects/")) return "/admin/projects";
if (path.startsWith("/admin/system-monitoring") || path.startsWith("/admin/permission-monitoring")) return "/admin/system-monitoring";
@@ -292,6 +294,13 @@ export const buildProjectNavigationItems = (options: {
icon: "notebook",
keywords: ["knowledge", "instruction"],
},
{
label: TEXT.menu.knowledgeCollaboration,
path: "/knowledge/collaboration",
group: TEXT.menu.sharedLibrary,
icon: "document",
keywords: ["knowledge", "collaboration", "onlyoffice", "协作"],
},
],
},
];
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./useProjectNotifications.ts"), "utf8");
const webLayout = readFileSync(resolve(__dirname, "../components/WebLayout.vue"), "utf8");
const desktopLayout = readFileSync(resolve(__dirname, "../components/DesktopLayout.vue"), "utf8");
describe("project notification feed contract", () => {
it("uses recipient notifications for both web and desktop reminder bells", () => {
expect(source).toContain("listGeneralNotifications");
expect(source).toContain("markGeneralNotificationRead");
expect(source).toContain("POLL_INTERVAL_MS = 60_000");
expect(source).toContain("PROJECT_NOTIFICATIONS_CHANGED_EVENT");
expect(webLayout).toContain("useProjectNotifications()");
expect(desktopLayout).toContain("useProjectNotifications()");
expect(webLayout).not.toContain("fetchOverdueAesCount");
expect(desktopLayout).not.toContain("fetchOverdueAesCount");
});
});
@@ -0,0 +1,116 @@
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { listGeneralNotifications, markGeneralNotificationRead } from "../api/notifications";
import { useStudyStore } from "../store/study";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
export const PROJECT_NOTIFICATIONS_CHANGED_EVENT = "ctms:project-notifications-changed";
const POLL_INTERVAL_MS = 60_000;
export const notifyProjectNotificationsChanged = () => {
if (typeof window !== "undefined") window.dispatchEvent(new Event(PROJECT_NOTIFICATIONS_CHANGED_EVENT));
};
const formatNotificationTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const elapsed = Date.now() - date.getTime();
if (elapsed < 60_000) return "刚刚";
if (elapsed < 3_600_000) return `${Math.max(1, Math.floor(elapsed / 60_000))} 分钟前`;
if (elapsed < 86_400_000) return `${Math.max(1, Math.floor(elapsed / 3_600_000))} 小时前`;
return `${date.getMonth() + 1}-${String(date.getDate()).padStart(2, "0")}`;
};
const notificationTone = (item: GeneralNotificationItem): "danger" | "warning" | "info" => {
if (item.priority === "URGENT") return "danger";
if (item.priority === "HIGH") return "warning";
return "info";
};
export const useProjectNotifications = () => {
const study = useStudyStore();
const route = useRoute();
const router = useRouter();
const headerRemindersLoading = ref(false);
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, items: [] });
let requestId = 0;
let pollTimer: number | undefined;
const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id;
const currentRequest = ++requestId;
if (!studyId || route.path.startsWith("/admin")) {
feed.value = { unread_count: 0, items: [] };
return;
}
headerRemindersLoading.value = true;
try {
const { data } = await listGeneralNotifications(studyId, 10);
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
} catch {
// 短时网络异常时保留当前内存中的提醒,避免角标在轮询失败时闪烁消失。
} finally {
if (currentRequest === requestId) headerRemindersLoading.value = false;
}
};
const headerReminderItems = computed(() => feed.value.items.map((item) => ({
key: item.id,
command: `notification:${item.id}`,
title: item.title,
description: item.message,
timeLabel: formatNotificationTime(item.created_at),
tone: notificationTone(item),
isRead: Boolean(item.read_at),
})));
const headerReminderTotal = computed(() => feed.value.unread_count);
const headerReminderBadgeValue = computed(() => headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value);
const handleReminderCommand = async (command: string) => {
if (!command.startsWith("notification:")) return;
const notificationId = command.slice("notification:".length);
const item = feed.value.items.find((candidate) => candidate.id === notificationId);
const studyId = study.currentStudy?.id;
if (!item || !studyId) return;
if (!item.read_at) {
item.read_at = new Date().toISOString();
feed.value.unread_count = Math.max(0, feed.value.unread_count - 1);
await markGeneralNotificationRead(studyId, item.id).catch(() => {
item.read_at = null;
feed.value.unread_count += 1;
});
}
if (item.action_path?.startsWith("/")) await router.push(item.action_path);
};
const handleReminderVisibilityChange = (visible: boolean) => {
if (visible) void loadHeaderReminders();
};
const handleWindowFocus = () => { void loadHeaderReminders(); };
const startHeaderNotificationPolling = () => {
if (pollTimer !== undefined || typeof window === "undefined") return;
pollTimer = window.setInterval(() => { void loadHeaderReminders(); }, POLL_INTERVAL_MS);
window.addEventListener("focus", handleWindowFocus);
window.addEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleWindowFocus);
};
const stopHeaderNotificationPolling = () => {
if (pollTimer !== undefined) window.clearInterval(pollTimer);
pollTimer = undefined;
if (typeof window !== "undefined") {
window.removeEventListener("focus", handleWindowFocus);
window.removeEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleWindowFocus);
}
};
return {
headerRemindersLoading,
headerReminderItems,
headerReminderTotal,
headerReminderBadgeValue,
loadHeaderReminders,
handleReminderCommand,
handleReminderVisibilityChange,
startHeaderNotificationPolling,
stopHeaderNotificationPolling,
};
};
+14 -2
View File
@@ -98,8 +98,8 @@ export const TEXT = {
plannedEnrollment: "入组",
dataUpdatedAt: "数据",
projectReminders: "提醒",
projectRemindersSubtitle: "逾期与风险提示",
projectRemindersEmpty: "暂无逾期风险",
projectRemindersSubtitle: "业务通知与待办",
projectRemindersEmpty: "暂无未处理通知",
overdueAes: "逾期 AE",
overdueAesDesc: "需关注安全性事件处理时效",
overdueMonitoringIssues: "逾期监查问题",
@@ -306,6 +306,7 @@ export const TEXT = {
knowledgeNotes: "注意事项",
knowledgeSupportFiles: "支持性文件",
knowledgeInstructionFiles: "说明文件",
knowledgeCollaboration: "在线协作",
profile: "个人中心",
logout: "退出登录",
},
@@ -733,6 +734,17 @@ export const TEXT = {
listTitle: "说明文件列表",
emptyDescription: "说明文件功能正在搭建基础能力,敬请期待。",
},
knowledgeCollaboration: {
title: "在线协作",
newFile: "新建协作文件",
importFile: "导入 Office 文件",
newFolder: "新建文件夹",
allFiles: "全部文件",
recycleBin: "回收站",
empty: "暂无协作文件",
loadFailed: "协作文件加载失败",
editorLoadFailed: "在线协作编辑器加载失败",
},
etmf: {
title: "eTMF",
subtitle: "按 TMF 目录归档项目级与中心级文件",
+26 -2
View File
@@ -12,11 +12,35 @@ describe("admin project route permissions", () => {
expect(source).toContain('name: "OfficeAttachmentPreview"');
expect(source).toContain('path: "office-preview/version/:id"');
expect(source).toContain('name: "OfficeVersionPreview"');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(2);
expect(source).toContain('path: "office-preview/collaboration/:fileId/revision/:id"');
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(3);
expect(source.match(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
expect(source.match(/transientWorkspaceTask: true/g)?.length).toBeGreaterThanOrEqual(2);
});
it("registers the independent collaboration library and transient editor workspace", () => {
const source = readRouter();
expect(source).toContain('path: "knowledge/collaboration"');
expect(source).toContain('name: "KnowledgeCollaboration"');
expect(source).toContain('meta: { title: TEXT.menu.knowledgeCollaboration, requiresStudy: true, fullBleed: true }');
expect(source).toContain('path: "knowledge/collaboration/:fileId"');
expect(source).toContain('name: "KnowledgeCollaborationWorkspace"');
expect(source).toContain("component: CollaborationWorkspace");
expect(source).toContain("immersiveWorkspace: true");
expect(source).toContain("workspaceTaskGroup: TEXT.menu.knowledgeCollaboration");
});
it("registers the collaboration share page outside authenticated project layout", () => {
const source = readRouter();
expect(source).toContain('path: "/collaboration/share"');
expect(source).toContain('name: "CollaborationPublicShare"');
expect(source).toContain("component: CollaborationShareWorkspace");
expect(source).toContain('meta: { public: true, title: "共享协作文件" }');
expect(source).toContain("!to.meta.public && !auth.user");
});
it("lets any authorized project role open the project management detail page", () => {
const source = readRouter();
@@ -138,7 +162,7 @@ describe("admin project route permissions", () => {
it("validates restored session tokens through /me before entering protected routes", () => {
const source = readRouter();
const restoreGuardIndex = source.indexOf("if (!auth.user && getToken() && !isDesktopSessionRestoreRoute)");
const restoreGuardIndex = source.indexOf("if (!to.meta.public && !auth.user && getToken() && !isDesktopSessionRestoreRoute)");
const fetchMeIndex = source.indexOf("await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true })", restoreGuardIndex);
const preserveIndex = source.indexOf("shouldPreserveDesktopSessionOnAuthCheckFailure(error)", fetchMeIndex);
const restoreRedirectIndex = source.indexOf("next({ path: DESKTOP_SESSION_RESTORE_PATH", preserveIndex);
+35 -1
View File
@@ -47,6 +47,9 @@ import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
import Precautions from "../views/ia/Precautions.vue";
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
import CollaborationLibrary from "../views/knowledge/CollaborationLibrary.vue";
import CollaborationWorkspace from "../views/knowledge/CollaborationWorkspace.vue";
import CollaborationShareWorkspace from "../views/knowledge/CollaborationShareWorkspace.vue";
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
import EthicsForm from "../views/startup/EthicsForm.vue";
@@ -93,6 +96,12 @@ const routes: RouteRecordRaw[] = [
component: ForgotPassword,
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
},
{
path: "/collaboration/share",
name: "CollaborationPublicShare",
component: CollaborationShareWorkspace,
meta: { public: true, title: "共享协作文件" },
},
{
path: "/desktop/server-settings",
name: "DesktopServerSettings",
@@ -206,6 +215,12 @@ const routes: RouteRecordRaw[] = [
component: OfficePreviewWorkspace,
meta: { title: "Office 文件预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
},
{
path: "office-preview/collaboration/:fileId/revision/:id",
name: "OfficeCollaborationRevisionPreview",
component: OfficePreviewWorkspace,
meta: { title: "协作历史版本预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
},
{
path: "startup/feasibility-ethics",
name: "StartupFeasibilityEthics",
@@ -372,6 +387,25 @@ const routes: RouteRecordRaw[] = [
component: KnowledgeInstructionFiles,
meta: { title: TEXT.menu.knowledgeInstructionFiles, requiresStudy: true },
},
{
path: "knowledge/collaboration",
name: "KnowledgeCollaboration",
component: CollaborationLibrary,
meta: { title: TEXT.menu.knowledgeCollaboration, requiresStudy: true, fullBleed: true },
},
{
path: "knowledge/collaboration/:fileId",
name: "KnowledgeCollaborationWorkspace",
component: CollaborationWorkspace,
meta: {
title: TEXT.menu.knowledgeCollaboration,
requiresStudy: true,
fullBleed: true,
immersiveWorkspace: true,
transientWorkspaceTask: true,
workspaceTaskGroup: TEXT.menu.knowledgeCollaboration,
},
},
],
},
{
@@ -575,7 +609,7 @@ router.beforeEach(async (to, _from, next) => {
const getToken = () => auth.token;
let token = getToken();
const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH;
if (!auth.user && getToken() && !isDesktopSessionRestoreRoute) {
if (!to.meta.public && !auth.user && getToken() && !isDesktopSessionRestoreRoute) {
try {
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
} catch (error) {
+3 -3
View File
@@ -4,7 +4,7 @@ import { clientRuntime } from "./clientRuntime";
import { getAppMetadata, getAppMetadataHeaders } from "./appMetadata";
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
vi.unstubAllEnvs();
});
@@ -28,7 +28,7 @@ describe("client runtime", () => {
});
it("exposes only the implemented desktop capability", () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
expect(getAppMetadata().clientType).toBe("desktop");
expect(getAppMetadata().clientSource).toBe("ctms-desktop");
@@ -44,7 +44,7 @@ describe("client runtime", () => {
"X-CTMS-Client-Platform": "web",
});
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
expect(getAppMetadataHeaders()).toMatchObject({
"X-CTMS-Client-Type": "desktop",
"X-CTMS-Client-Source": "ctms-desktop",
+2 -2
View File
@@ -10,7 +10,7 @@ vi.mock("@tauri-apps/api/core", () => ({
afterEach(() => {
invokeMock.mockReset();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
});
describe("desktop native menu", () => {
@@ -21,7 +21,7 @@ describe("desktop native menu", () => {
});
it("synchronizes validated shortcut preferences through the controlled command", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
await syncDesktopMenuShortcuts(DEFAULT_DESKTOP_SHORTCUTS);
@@ -10,9 +10,9 @@ import {
const setTauriRuntime = (enabled: boolean) => {
if (enabled) {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
} else {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
}
};
@@ -49,7 +49,7 @@ describe("desktop UI preferences", () => {
});
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
vi.unstubAllGlobals();
});
@@ -131,7 +131,7 @@ describe("desktop UI preferences", () => {
});
it("synchronizes the selected theme through the controlled desktop command", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
setDesktopThemePreference("system");
+13
View File
@@ -47,4 +47,17 @@ describe("web file saving", () => {
await expect(saveFile({ suggestedName: "protocol.pdf", data: "content" }, destination)).resolves.toBe("saved");
expect(click).toHaveBeenCalledOnce();
});
it("downloads directly to the runtime default destination without opening a save picker", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
(URL as any).createObjectURL = vi.fn().mockReturnValue("blob:download");
(URL as any).revokeObjectURL = vi.fn();
(window as any).showSaveFilePicker = vi.fn();
const { downloadFile } = await import("./files");
await downloadFile({ suggestedName: "研究方案.xlsx", data: "content" });
expect(click).toHaveBeenCalledOnce();
expect((window as any).showSaveFilePicker).not.toHaveBeenCalled();
});
});
+12
View File
@@ -158,6 +158,18 @@ export const saveFile = async (
return "saved";
};
export const downloadFile = async (output: FileOutput): Promise<void> => {
const url = URL.createObjectURL(toBlob(output));
const link = document.createElement("a");
link.href = url;
link.download = sanitizeFileName(output.suggestedName);
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
};
export const openFile = async (output: FileOutput): Promise<void> => {
if (!isTauriRuntime()) {
const url = URL.createObjectURL(toBlob(output));
+8
View File
@@ -62,6 +62,7 @@ export {
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
export {
cleanupTemporaryFiles,
downloadFile,
openFile,
pickFiles,
prepareSaveFile,
@@ -79,6 +80,13 @@ export {
type NotificationPermissionState,
} from "./notifications";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
export {
isWebFullscreenActive,
isWebFullscreenAvailable,
listenWebFullscreenChange,
refreshWebFullscreenState,
toggleWebFullscreen,
} from "./webFullscreen";
export { ONLYOFFICE_HOST_PATH, resolveOnlyOfficeHostUrl } from "./onlyoffice";
export {
clearSessionToken,
+2 -2
View File
@@ -12,7 +12,7 @@ vi.mock("@tauri-apps/plugin-notification", () => ({
}));
const enableTauriRuntime = () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
};
const setBrowserNotificationPermission = (permission: NotificationPermission) => {
@@ -27,7 +27,7 @@ const setBrowserNotificationPermission = (permission: NotificationPermission) =>
};
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
Reflect.deleteProperty(window, "Notification");
vi.clearAllMocks();
});
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it } from "vitest";
import { isTauriRuntime } from "./platform";
const clearRuntimeMarkers = () => {
Reflect.deleteProperty(window, "isTauri");
};
afterEach(clearRuntimeMarkers);
describe("runtime platform detection", () => {
it("detects the official Tauri v2 runtime marker", () => {
window.isTauri = true;
expect(isTauriRuntime()).toBe(true);
});
it("does not classify an ordinary browser as desktop", () => {
clearRuntimeMarkers();
expect(isTauriRuntime()).toBe(false);
});
});
+10 -4
View File
@@ -2,13 +2,19 @@ export type RuntimePlatform = "web" | "macos" | "windows" | "linux";
declare global {
interface Window {
__TAURI__?: unknown;
__TAURI_INTERNALS__?: unknown;
isTauri?: boolean;
}
}
export const isTauriRuntime = (): boolean =>
typeof window !== "undefined" && Boolean(window.__TAURI__ || window.__TAURI_INTERNALS__);
type TauriRuntimeGlobal = typeof globalThis & {
isTauri?: boolean;
};
export const isTauriRuntime = (): boolean => {
if (typeof globalThis === "undefined") return false;
const runtimeGlobal = globalThis as TauriRuntimeGlobal;
return runtimeGlobal.isTauri === true;
};
export const getRuntimePlatform = (): RuntimePlatform => {
if (!isTauriRuntime() || typeof navigator === "undefined") return "web";
@@ -37,7 +37,7 @@ describe("saved login credentials", () => {
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
localStorage.clear();
invokeMock.mockReset();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
Reflect.deleteProperty(window, "PasswordCredential");
Reflect.deleteProperty(navigator, "credentials");
});
@@ -45,13 +45,13 @@ describe("saved login credentials", () => {
afterEach(() => {
vi.useRealTimers();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
Reflect.deleteProperty(window, "PasswordCredential");
Reflect.deleteProperty(navigator, "credentials");
});
it("stores desktop remembered passwords in the system credential store", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
let stored = "";
invokeMock.mockImplementation(async (command: string, args: any) => {
@@ -88,7 +88,7 @@ describe("saved login credentials", () => {
});
it("deletes malformed desktop remembered credentials", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
invokeMock.mockImplementation(async (command: string) => {
if (command === "login_credential_get") return JSON.stringify({ password: "missing-email" });
@@ -47,7 +47,7 @@ describe("secure session storage", () => {
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
localStorage.clear();
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
invokeMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
@@ -56,7 +56,7 @@ describe("secure session storage", () => {
vi.useRealTimers();
resetSecureSessionStorageForTests();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "isTauri");
});
it("stores desktop tokens as a 30 day secure session record", async () => {
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { invokeMock, listenMock, unlistenMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
listenMock: vi.fn(),
unlistenMock: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock }));
describe("desktop fullscreen runtime", () => {
let nativeListener: ((event: { payload: boolean }) => void) | undefined;
beforeEach(() => {
vi.resetModules();
window.isTauri = true;
nativeListener = undefined;
invokeMock.mockReset();
listenMock.mockReset();
unlistenMock.mockReset();
listenMock.mockImplementation(async (_event: string, listener: (event: { payload: boolean }) => void) => {
nativeListener = listener;
return unlistenMock;
});
});
afterEach(() => {
Reflect.deleteProperty(window, "isTauri");
vi.restoreAllMocks();
});
it("uses controlled native commands and tracks window-driven fullscreen changes", async () => {
let fullscreen = false;
invokeMock.mockImplementation(async (command: string, args?: { fullscreen?: boolean }) => {
if (command === "desktop_window_get_fullscreen") return fullscreen;
if (command === "desktop_window_set_fullscreen") {
fullscreen = Boolean(args?.fullscreen);
return fullscreen;
}
throw new Error(`unexpected command: ${command}`);
});
const runtime = await import("./webFullscreen");
const listener = vi.fn();
expect(runtime.isWebFullscreenAvailable()).toBe(true);
const unlisten = runtime.listenWebFullscreenChange(listener);
await vi.waitFor(() => expect(invokeMock).toHaveBeenCalledWith("desktop_window_get_fullscreen"));
await runtime.toggleWebFullscreen();
expect(invokeMock).toHaveBeenCalledWith("desktop_window_set_fullscreen", { fullscreen: true });
expect(runtime.isWebFullscreenActive()).toBe(true);
nativeListener?.({ payload: false });
expect(runtime.isWebFullscreenActive()).toBe(false);
expect(listener).toHaveBeenCalled();
unlisten();
expect(unlistenMock).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isWebFullscreenActive,
isWebFullscreenAvailable,
listenWebFullscreenChange,
toggleWebFullscreen,
} from "./webFullscreen";
const configureFullscreenDocument = () => {
let fullscreenElement: Element | null = null;
const requestFullscreen = vi.fn(async () => {
fullscreenElement = document.documentElement;
document.dispatchEvent(new Event("fullscreenchange"));
});
const exitFullscreen = vi.fn(async () => {
fullscreenElement = null;
document.dispatchEvent(new Event("fullscreenchange"));
});
Object.defineProperty(document, "fullscreenEnabled", { value: true, configurable: true });
Object.defineProperty(document, "fullscreenElement", {
get: () => fullscreenElement,
configurable: true,
});
Object.defineProperty(document, "exitFullscreen", { value: exitFullscreen, configurable: true });
Object.defineProperty(document.documentElement, "requestFullscreen", {
value: requestFullscreen,
configurable: true,
});
return { exitFullscreen, requestFullscreen };
};
afterEach(() => {
Reflect.deleteProperty(document, "fullscreenEnabled");
Reflect.deleteProperty(document, "fullscreenElement");
Reflect.deleteProperty(document, "exitFullscreen");
Reflect.deleteProperty(document.documentElement, "requestFullscreen");
vi.restoreAllMocks();
});
describe("web fullscreen runtime", () => {
it("reports fullscreen as unavailable when the browser does not expose the API", () => {
expect(isWebFullscreenAvailable()).toBe(false);
expect(isWebFullscreenActive()).toBe(false);
});
it("enters and exits fullscreen while requesting hidden browser navigation", async () => {
const { exitFullscreen, requestFullscreen } = configureFullscreenDocument();
expect(isWebFullscreenAvailable()).toBe(true);
await toggleWebFullscreen();
expect(requestFullscreen).toHaveBeenCalledWith({ navigationUI: "hide" });
expect(isWebFullscreenActive()).toBe(true);
await toggleWebFullscreen();
expect(exitFullscreen).toHaveBeenCalledOnce();
expect(isWebFullscreenActive()).toBe(false);
});
it("tracks browser-driven fullscreen changes and removes the listener", async () => {
configureFullscreenDocument();
const listener = vi.fn();
const unlisten = listenWebFullscreenChange(listener);
await toggleWebFullscreen();
expect(listener).toHaveBeenCalledOnce();
unlisten();
await toggleWebFullscreen();
expect(listener).toHaveBeenCalledOnce();
});
it("rechecks fullscreen state when the browser window changes outside the API", () => {
configureFullscreenDocument();
const listener = vi.fn();
const unlisten = listenWebFullscreenChange(listener);
window.dispatchEvent(new Event("resize"));
window.dispatchEvent(new Event("focus"));
document.dispatchEvent(new Event("visibilitychange"));
expect(listener).toHaveBeenCalledTimes(3);
unlisten();
window.dispatchEvent(new Event("resize"));
window.dispatchEvent(new Event("focus"));
document.dispatchEvent(new Event("visibilitychange"));
expect(listener).toHaveBeenCalledTimes(3);
});
it("rejects fullscreen requests when the capability is unavailable", async () => {
await expect(toggleWebFullscreen()).rejects.toThrow("当前浏览器不支持页面全屏");
});
});
+92
View File
@@ -0,0 +1,92 @@
import { isTauriRuntime } from "./platform";
const DESKTOP_FULLSCREEN_CHANGED_EVENT = "ctms:desktop-fullscreen-changed";
let desktopFullscreenActive = false;
const getBrowserDocument = (): Document | null =>
typeof document === "undefined" ? null : document;
export const isWebFullscreenAvailable = (): boolean => {
if (isTauriRuntime()) return true;
const browserDocument = getBrowserDocument();
return Boolean(
browserDocument?.fullscreenEnabled &&
typeof browserDocument.documentElement?.requestFullscreen === "function" &&
typeof browserDocument.exitFullscreen === "function",
);
};
export const isWebFullscreenActive = (): boolean =>
isTauriRuntime() ? desktopFullscreenActive : Boolean(getBrowserDocument()?.fullscreenElement);
export const refreshWebFullscreenState = async (): Promise<boolean> => {
if (!isTauriRuntime()) return isWebFullscreenActive();
const { invoke } = await import("@tauri-apps/api/core");
desktopFullscreenActive = await invoke<boolean>("desktop_window_get_fullscreen");
return desktopFullscreenActive;
};
export const toggleWebFullscreen = async (): Promise<void> => {
if (isTauriRuntime()) {
const { invoke } = await import("@tauri-apps/api/core");
const current = await refreshWebFullscreenState();
desktopFullscreenActive = await invoke<boolean>("desktop_window_set_fullscreen", {
fullscreen: !current,
});
return;
}
const browserDocument = getBrowserDocument();
if (!browserDocument || !isWebFullscreenAvailable()) {
throw new Error("当前浏览器不支持页面全屏");
}
if (browserDocument.fullscreenElement) {
await browserDocument.exitFullscreen();
return;
}
await browserDocument.documentElement.requestFullscreen({ navigationUI: "hide" });
};
export const listenWebFullscreenChange = (listener: () => void): (() => void) => {
if (isTauriRuntime()) {
let disposed = false;
let desktopUnlisten: (() => void) | undefined;
const syncDesktopState = () => {
void refreshWebFullscreenState()
.then(() => { if (!disposed) listener(); })
.catch(() => {});
};
void import("@tauri-apps/api/event")
.then(({ listen }) => listen<boolean>(DESKTOP_FULLSCREEN_CHANGED_EVENT, (event) => {
desktopFullscreenActive = Boolean(event.payload);
if (!disposed) listener();
}))
.then((unlisten) => {
if (disposed) void unlisten();
else desktopUnlisten = unlisten;
})
.catch(() => {});
window.addEventListener("focus", syncDesktopState);
syncDesktopState();
return () => {
disposed = true;
window.removeEventListener("focus", syncDesktopState);
void desktopUnlisten?.();
};
}
const browserDocument = getBrowserDocument();
if (!browserDocument) return () => undefined;
browserDocument.addEventListener("fullscreenchange", listener);
browserDocument.addEventListener("visibilitychange", listener);
window.addEventListener("focus", listener);
window.addEventListener("resize", listener);
return () => {
browserDocument.removeEventListener("fullscreenchange", listener);
browserDocument.removeEventListener("visibilitychange", listener);
window.removeEventListener("focus", listener);
window.removeEventListener("resize", listener);
};
};
+2 -2
View File
@@ -225,12 +225,12 @@ body {
transition: var(--ctms-transition);
}
.el-button--primary {
.el-button--primary:not(.is-plain):not(.is-link):not(.is-text) {
background-color: var(--ctms-primary);
border-color: var(--ctms-primary);
}
.el-button--primary:hover {
.el-button--primary:not(.is-plain):not(.is-link):not(.is-text):hover {
background-color: var(--ctms-primary-hover);
border-color: var(--ctms-primary-hover);
}
+146
View File
@@ -0,0 +1,146 @@
export type CollaborationFileType = "word" | "cell" | "slide";
export type CollaborationMemberRole = "EDITOR" | "MANAGER";
export type CollaborationShareAccessMode = "VIEW" | "EDIT";
export type CollaborationShareExpiryPolicy = "ONE_DAY" | "SEVEN_DAYS" | "THIRTY_DAYS" | "PERMANENT";
export type CollaborationEditRequestStatus = "PENDING" | "APPROVED" | "REJECTED";
export interface CollaborationFileCollaborator {
user_id: string;
full_name: string;
role: CollaborationMemberRole;
avatar_url?: string | null;
}
export interface CollaborationFolder {
id: string;
study_id: string;
parent_id?: string | null;
name: string;
sort_order: number;
created_by: string;
deleted_at?: string | null;
created_at: string;
updated_at: string;
}
export interface CollaborationFile {
id: string;
study_id: string;
folder_id?: string | null;
title: string;
file_type: CollaborationFileType;
extension: "docx" | "xlsx" | "pptx";
status: "ACTIVE" | "ARCHIVED" | "DELETED";
owner_id: string;
current_revision_id?: string | null;
generation: number;
allow_export: boolean;
allow_edit_request: boolean;
allow_sheet_structure_edit: boolean;
deleted_at?: string | null;
created_at: string;
updated_at: string;
owner_name?: string | null;
folder_name?: 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;
collaboration_role?: CollaborationMemberRole | null;
collaborators: CollaborationFileCollaborator[];
can_edit: boolean;
can_manage: boolean;
can_export: boolean;
can_request_edit: boolean;
edit_request_status?: CollaborationEditRequestStatus | null;
can_transfer_ownership: boolean;
}
export interface CollaborationMember {
id: string;
file_id: string;
user_id: string;
role: CollaborationMemberRole;
invited_by: string;
full_name: string;
email: string;
created_at: string;
}
export interface CollaborationCandidate {
user_id: string;
full_name: string;
email: string;
role_in_study: string;
can_be_editor: boolean;
can_be_manager: boolean;
}
export interface CollaborationEditRequest {
id: string;
file_id: string;
requester_id: string;
requester_name: string;
requester_email: string;
status: CollaborationEditRequestStatus;
resolved_by?: string | null;
resolved_at?: string | null;
created_at: string;
}
export interface CollaborationRevision {
id: string;
file_id: string;
revision_no: number;
parent_revision_id?: string | null;
original_filename: string;
file_hash: string;
file_size: number;
mime_type: string;
source: string;
change_summary?: string | null;
created_by?: string | null;
created_by_name?: string | null;
created_by_avatar_url?: string | null;
created_at: string;
}
export interface CollaborationEditorConfig {
file_id: string;
file_name: string;
access_mode: "view" | "edit";
can_save_as: boolean;
can_download: boolean;
can_request_edit: boolean;
host_path: "/onlyoffice-host.html";
expires_at: string;
config: Record<string, unknown>;
}
export interface CollaborationShareLink {
id: string;
file_id: string;
enabled: boolean;
access_mode: CollaborationShareAccessMode;
expiry_policy: CollaborationShareExpiryPolicy;
expires_at?: string | null;
has_password: boolean;
share_path: "/collaboration/share";
share_token?: string | null;
created_at: string;
updated_at: string;
}
export interface CollaborationPublicShareMetadata {
file_name: string;
file_type: CollaborationFileType;
access_mode: "view" | "edit";
allow_export: boolean;
requires_password: boolean;
expires_at?: string | null;
}
export interface CollaborationShareAccessGrant {
access_token: string;
expires_at: string;
}
+20
View File
@@ -13,3 +13,23 @@ export interface NotificationItem {
delivered_at?: string | null;
read_at?: string | null;
}
export interface GeneralNotificationItem {
id: string;
study_id: string;
recipient_id: string;
category: string;
priority: "LOW" | "NORMAL" | "HIGH" | "URGENT";
title: string;
message: string;
action_path?: string | null;
source_type: string;
source_id: string;
read_at?: string | null;
created_at: string;
}
export interface GeneralNotificationFeed {
unread_count: number;
items: GeneralNotificationItem[];
}
+8 -1
View File
@@ -1,4 +1,4 @@
export type OnlyOfficeResourceType = "attachment" | "version";
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision";
export interface OnlyOfficePreviewConfig {
resource_type: OnlyOfficeResourceType;
@@ -14,3 +14,10 @@ export interface OnlyOfficeHostMessage {
nonce?: string;
detail?: Record<string, unknown>;
}
export interface OnlyOfficeSaveAsPayload {
fileType: string;
title: string;
mimeType?: string;
data: ArrayBuffer;
}
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const capabilitiesMock = vi.hoisted(() => vi.fn());
const downloadFileMock = vi.hoisted(() => vi.fn());
const openFileMock = vi.hoisted(() => vi.fn());
const pickFilesMock = vi.hoisted(() => vi.fn());
const saveFileMock = vi.hoisted(() => vi.fn());
@@ -14,6 +15,7 @@ vi.mock("../runtime", () => ({
clientRuntime: {
capabilities: capabilitiesMock,
},
downloadFile: downloadFileMock,
openFile: openFileMock,
pickFiles: pickFilesMock,
saveFile: saveFileMock,
@@ -45,6 +47,7 @@ describe("file task feedback", () => {
pickFilesMock.mockResolvedValue([]);
saveFileMock.mockResolvedValue("saved");
openFileMock.mockResolvedValue(undefined);
downloadFileMock.mockResolvedValue(undefined);
startActivityMock.mockReturnValue("activity-1");
});
@@ -80,6 +83,32 @@ describe("file task feedback", () => {
expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始");
});
it("downloads directly without invoking the save flow", async () => {
const { downloadFileWithFeedback } = await import("./fileTaskFeedback");
await downloadFileWithFeedback(output);
expect(downloadFileMock).toHaveBeenCalledWith(output);
expect(saveFileMock).not.toHaveBeenCalled();
expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始");
});
it("reports direct downloads through persistent desktop activity", async () => {
capabilitiesMock.mockReturnValue({ nativeFiles: true });
const { downloadFileWithFeedback } = await import("./fileTaskFeedback");
await downloadFileWithFeedback(output, { title: "下载 report.pdf" });
expect(downloadFileMock).toHaveBeenCalledWith(output);
expect(saveFileMock).not.toHaveBeenCalled();
expect(startActivityMock).toHaveBeenCalledWith({
kind: "download",
title: "下载 report.pdf",
detail: "正在准备下载",
});
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "completed", { detail: "文件下载已开始" });
});
it("writes to a destination that was selected before the download", async () => {
const destination = { kind: "web-file-handle", handle: { createWritable: vi.fn() } } as const;
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
+25
View File
@@ -1,6 +1,7 @@
import { ElMessage } from "element-plus";
import {
clientRuntime,
downloadFile,
openFile,
pickFiles,
saveFile,
@@ -28,6 +29,30 @@ const desktopFileActivitiesEnabled = () => clientRuntime.capabilities().nativeFi
const defaultSaveTitle = (kind: FileSaveActivityKind) => (kind === "export" ? "导出文件" : "保存文件");
export const downloadFileWithFeedback = async (
output: FileOutput,
options: Omit<FileTaskFeedbackOptions, "kind"> = {},
): Promise<void> => {
if (!desktopFileActivitiesEnabled()) {
await downloadFile(output);
ElMessage.success("文件下载已开始");
return;
}
const activityId = options.activityId || startDesktopActivity({
kind: "download",
title: options.title || "下载文件",
detail: options.pendingDetail || "正在准备下载",
});
updateDesktopActivity(activityId, { detail: options.pendingDetail || "正在准备下载", progress: 70 });
try {
await downloadFile(output);
finishDesktopActivity(activityId, "completed", { detail: options.completedDetail || "文件下载已开始" });
} catch (error) {
finishDesktopActivity(activityId, "failed", { detail: "文件下载失败" });
throw error;
}
};
export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Promise<File[]> => {
const files = await pickFiles(options);
if (files.length) {
+18
View File
@@ -50,6 +50,12 @@ const PERMISSIONS: Record<string, string[]> = {
"documents.create": ["ADMIN"],
"documents.update": ["ADMIN"],
"documents.delete": ["ADMIN"],
"collaboration.read": ["ADMIN"],
"collaboration.create": ["ADMIN"],
"collaboration.edit": ["ADMIN"],
"collaboration.manage": ["ADMIN"],
"collaboration.export": ["ADMIN"],
"collaboration.delete": ["ADMIN"],
};
const REASONS: Record<string, string> = {
@@ -94,6 +100,12 @@ const REASONS: Record<string, string> = {
"documents.create": TEXT.modules.permissions.default,
"documents.update": TEXT.modules.permissions.default,
"documents.delete": TEXT.modules.permissions.default,
"collaboration.read": TEXT.modules.permissions.default,
"collaboration.create": TEXT.modules.permissions.default,
"collaboration.edit": TEXT.modules.permissions.default,
"collaboration.manage": TEXT.modules.permissions.default,
"collaboration.export": TEXT.modules.permissions.default,
"collaboration.delete": TEXT.modules.permissions.default,
};
export const usePermission = () => {
@@ -154,6 +166,12 @@ export const usePermission = () => {
"documents.create": "documents:create",
"documents.update": "documents:update",
"documents.delete": "documents:delete",
"collaboration.read": "collaboration:read",
"collaboration.create": "collaboration:create",
"collaboration.edit": "collaboration:edit",
"collaboration.manage": "collaboration:manage",
"collaboration.export": "collaboration:export",
"collaboration.delete": "collaboration:delete",
"audit.export.read": "audit_logs:read",
};
const operationKey = permissionMap[action];
@@ -30,6 +30,8 @@ describe("project route permissions", () => {
expect(getProjectRoutePermission("/startup/training/new")).toBeNull();
expect(getProjectRoutePermission("/startup/training/abc/edit")).toBeNull();
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
expect(getProjectRoutePermission("/knowledge/collaboration")).toEqual({ operationKey: "collaboration:read" });
expect(getProjectRoutePermission("/knowledge/collaboration/file-1")).toEqual({ operationKey: "collaboration:read" });
expect(getProjectRoutePermission("/knowledge/precautions/new")).toEqual({ operationKey: "precautions:create" });
expect(getProjectRoutePermission("/knowledge/precautions/abc/edit")).toEqual({ operationKey: "precautions:update" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toBeNull();
@@ -28,6 +28,7 @@ const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePerm
{ prefixes: ["/risk-issues"], permission: { operationKey: "subject_aes:read" } },
{ prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/knowledge/medical-consult"], permission: { operationKey: "faq:read" } },
{ prefixes: ["/knowledge/collaboration"], permission: { operationKey: "collaboration:read" } },
{ prefixes: ["/knowledge/precautions", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "precautions:read" } },
];
@@ -59,6 +60,7 @@ export const projectRouteLandingPaths = [
"/risk-issues/monitoring-visits",
"/etmf",
"/knowledge/medical-consult",
"/knowledge/collaboration",
"/knowledge/precautions",
"/knowledge/support-files",
"/knowledge/instruction-files",
@@ -12,6 +12,8 @@ describe("OfficePreviewWorkspace contract", () => {
expect(source).not.toContain("downloadDocumentVersion");
expect(source).not.toContain("downloadAttachment");
expect(source).not.toContain("<iframe");
expect(source).toContain('route.name === "OfficeCollaborationRevisionPreview"');
expect(source).toContain("fetchCollaborationRevisionOnlyOfficeConfig");
});
it("destroys signed configuration on deactivation, server changes, and unmount", () => {
+18 -5
View File
@@ -39,7 +39,11 @@ import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounte
import { useRoute, useRouter } from "vue-router";
import { Loading } from "@element-plus/icons-vue";
import OnlyOfficeViewer from "../components/OnlyOfficeViewer.vue";
import { fetchAttachmentOnlyOfficeConfig, fetchVersionOnlyOfficeConfig } from "../api/onlyoffice";
import {
fetchAttachmentOnlyOfficeConfig,
fetchCollaborationRevisionOnlyOfficeConfig,
fetchVersionOnlyOfficeConfig,
} from "../api/onlyoffice";
import { useStudyStore } from "../store/study";
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
@@ -61,10 +65,13 @@ const requestSequence = ref(0);
const viewerSequence = ref(0);
let mounted = false;
const resourceType = computed<OnlyOfficeResourceType>(() =>
route.name === "OfficeAttachmentPreview" ? "attachment" : "version",
);
const resourceType = computed<OnlyOfficeResourceType>(() => {
if (route.name === "OfficeAttachmentPreview") return "attachment";
if (route.name === "OfficeCollaborationRevisionPreview") return "collaboration_revision";
return "version";
});
const resourceId = computed(() => String(route.params.id || ""));
const collaborationFileId = computed(() => String(route.params.fileId || ""));
const displayFileName = computed(() => previewConfig.value?.file_name || String(route.meta.title || "Office 文件预览"));
const viewerKey = computed(() => `${resourceType.value}:${resourceId.value}:${viewerSequence.value}`);
const backActionLabel = computed(() => workspaceTaskController ? "关闭预览并返回" : "返回");
@@ -104,7 +111,13 @@ const loadConfig = async () => {
try {
const response = resourceType.value === "attachment"
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
: await fetchVersionOnlyOfficeConfig(resourceId.value);
: resourceType.value === "collaboration_revision"
? await fetchCollaborationRevisionOnlyOfficeConfig(
study.currentStudy?.id || "",
collaborationFileId.value,
resourceId.value,
)
: await fetchVersionOnlyOfficeConfig(resourceId.value);
if (sequence !== requestSequence.value) return;
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) {
throw new Error("Office 预览宿主页配置不合法");
@@ -0,0 +1,332 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
describe("CollaborationLibrary UI contract", () => {
it("opens the create dialog directly from the primary sidebar action", () => {
expect(source).toContain(':icon="Plus" @click="openCreateDialog">新建</el-button>');
expect(source).toContain(':icon="Upload" :loading="importing"');
expect(source).not.toContain("新建协作文件⌄");
});
it("provides compact document-library views and file type filtering", () => {
expect(source).toContain("全部文件");
expect(source).toContain("最近更新");
expect(source).toContain("与我共享");
expect(source).toContain('v-model="fileTypeFilter"');
expect(source).toContain("currentViewTitle");
});
it("uses folder ellipsis actions on web and a context menu on desktop", () => {
expect(source).toContain('const isDesktop = isTauriRuntime();');
expect(source).toContain(":trigger=\"isDesktop ? 'contextmenu' : 'click'\"");
expect(source).toContain('v-if="!isDesktop && canManage');
expect(source).toContain('class="folder-more"');
expect(source).toContain('command="rename"');
expect(source).toContain('command="delete"');
expect(source).not.toContain('class="folder-actions"');
});
it("renames folders inline and keeps destructive deletion confirmed", () => {
expect(source).toContain(':data-folder-rename="folder.id"');
expect(source).toContain('@keydown.enter.stop.prevent="submitRenameFolder(folder)"');
expect(source).toContain('@keydown.esc.stop.prevent="cancelRenameFolder"');
expect(source).toContain('确认删除空文件夹“${folder.name}”');
});
it("renders action dialogs as a single desktop surface", () => {
expect(source.match(/class="collaboration-action-dialog(?:\s|\")/g)?.length).toBe(8);
expect(source).toContain("body.is-desktop-runtime .collaboration-action-dialog.el-dialog");
expect(source).toContain("--el-dialog-padding-primary: 0;");
expect(source).toContain("padding: 0 !important;");
});
it("offers the complete file action menu with explicit permission gates", () => {
for (const command of ["permissions", "rename", "copy", "move", "info", "history", "download", "trash"]) {
expect(source).toContain(`command: "${command}"`);
}
expect(source).toContain("item.can_export && canCreate.value");
expect(source).toContain("if (item.can_export) actions.push");
expect(source).toContain('label: "访问与权限"');
expect(source).not.toContain('command: "members"');
expect(source).not.toContain('command: "share"');
expect(source).not.toContain('command="manage"');
});
it("keeps rename and move as separate focused operations", () => {
expect(source).toContain('v-model="renameDialogVisible" title="重命名"');
expect(source).toContain('v-model="moveDialogVisible" title="移动到"');
expect(source).toContain("submitRenameFile");
expect(source).toContain("submitMoveFile");
expect(source).toContain("const targetFolderId = moveFolderId.value || null;");
expect(source).toContain("{ folder_id: targetFolderId }");
});
it("shows file metadata and downloads the current revision without a save picker", () => {
expect(source).toContain('v-model="infoDialogVisible" title="文件信息"');
expect(source).toContain("current_revision_file_size");
expect(source).toContain("current_revision_no");
expect(source).not.toContain('<el-table-column label="修订"');
expect(source).not.toContain('<el-descriptions-item label="当前版本"');
expect(source).not.toContain("R${");
expect(source).toContain("downloadCollaborationFile");
expect(source).toContain("downloadFileWithFeedback(");
expect(source).not.toContain("prepareSaveFile");
});
it("refreshes history immediately after restoring a revision", () => {
expect(source).toContain("const restoringRevisionId = ref<string | null>(null)");
expect(source).toContain(':loading="restoringRevisionId === row.id"');
expect(source).toContain("const { data: restored } = await restoreCollaborationRevision");
expect(source).toContain("current_revision_id: restoredForDisplay.id");
expect(source).toContain("...revisions.value.filter((item) => item.id !== restoredForDisplay.id)");
expect(source).toContain("const [revisionResponse] = await Promise.all([");
expect(source).toContain("if (refreshedFile) historyFile.value = refreshedFile");
});
it("loads fresh history on open and follows delayed ONLYOFFICE session callbacks", () => {
expect(source).toContain('@closed="closeHistory"');
expect(source).toContain("const historyRefreshTimers: number[] = []");
expect(source).toContain("scheduleHistoryRefresh();");
expect(source).toContain("refreshHistory({ silent: true })");
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
expect(source).toContain("fetchCollaborationFile(requireStudyId(), fileId)");
expect(source).toContain("historyFile.value = fileResponse.data");
expect(source).toContain("requestSequence !== historyRequestSequence");
});
it("presents version history as a date-grouped file timeline", () => {
expect(source).toContain('class="collaboration-action-dialog history-dialog"');
expect(source).toContain('width="840px"');
expect(source).toContain('modal-class="history-dialog-overlay"');
expect(source).toContain("history-file-badge");
expect(source).toContain("创建者:{{ historyFile.owner_name");
expect(source).toContain('v-model="historyOnlyDescribed"');
expect(source).toContain('v-model="historySourceFilter"');
expect(source).toContain('v-for="group in historyRevisionGroups"');
expect(source).toContain("revision.created_by_name");
expect(source).toContain(":src=\"row.created_by_avatar_url || undefined\"");
expect(source).toContain("当前版本");
expect(source).toContain("版本名称");
expect(source).not.toContain("<strong>R{{ row.revision_no }}</strong>");
expect(source).toContain("仅显示已命名版本");
expect(source).toContain("未命名");
expect(source).toContain(">命名</el-button>");
expect(source).toContain(">预览</el-button>");
expect(source).toContain(">另存为</el-button>");
expect(source).toContain("nameRevision(row)");
expect(source).toContain("previewRevision(row)");
expect(source).toContain("saveRevisionAs(row)");
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source).toContain('window.open(previewRoute.href, "_blank", "noopener,noreferrer")');
expect(source).not.toContain("historyDialogVisible.value = false");
expect(source).toContain("copyCollaborationRevision");
expect(source).toContain("updateCollaborationRevision");
expect(source).toContain('v-model="revisionSaveAsDialogVisible"');
expect(source).toContain("<CollaborationSaveAsDialog");
expect(source).toContain(':initial-folder-id="revisionSaveAsFolderId"');
expect(source).toContain("submitRevisionSaveAs");
expect(source).toContain("folder_id: payload.folderId");
expect(source).toContain('`${stem}[${revisionSaveAsTimeLabel(revision.created_at)}]${suffix}`');
expect(source).toContain("${pad(date.getMinutes())}");
expect(source).not.toContain('PERMISSION_CHANGE: "权限设置"');
expect(source).toContain('command="delete"');
expect(source).toContain("删除版本");
expect(source).toContain('v-if="historyFile?.can_manage" command="delete"');
expect(source).toContain("deleteCollaborationRevision");
expect(source).toContain("revision.id === historyFile.value.current_revision_id");
expect(source).toContain("height: min(760px, 82vh)");
expect(source).toContain("min-height: 48px");
expect(source).toContain("body.is-desktop-runtime .history-dialog.el-dialog");
expect(source).toContain("margin: 0 auto !important;");
expect(source).not.toContain('<el-table :data="revisions"');
});
it("supports inviting several members inside unified access management", () => {
expect(source).toContain('v-model="accessDialogVisible"');
expect(source).toContain('<strong>{{ accessPanelTitle }}</strong>');
expect(source).toContain("openAccessManagement");
expect(source).toContain('@click="openMemberInvite"');
expect(source).toContain('class="access-section__manage-button"');
expect(source).toContain("<span>管理协作者</span>");
expect(source).toContain('class="access-member-identity"');
expect(source).toContain('class="access-member-role"');
expect(source).toContain('class="access-member-owner">所有者</span>');
expect(source).not.toContain('<el-table :data="members"');
expect(source).toContain('v-model="memberInviteDialogVisible"');
expect(source).toContain('title="管理协作者"');
expect(source).not.toContain('title="添加协作者"');
expect(source).toContain('placeholder="搜索姓名、账号或角色"');
expect(source).not.toContain('member-picker-group--recent');
expect(source).not.toContain('member-picker-recent-empty');
expect(source).not.toContain('最近选择的账号将显示在这里');
expect(source).toContain('role="listbox" aria-label="可添加的项目成员"');
expect(source).toContain("memberForm.user_ids.includes(candidate.user_id)");
expect(source).toContain("toggleMemberCandidate(candidate.user_id)");
expect(source).toContain("selectedMemberCandidates.length");
expect(source).toContain('aria-label="文件授权角色"');
expect(source).toContain('label="可编辑" value="EDITOR"');
expect(source).toContain('label="可管理" value="MANAGER"');
expect(source).toContain('class="member-picker-selection__footer"');
expect(source).toContain("Promise.allSettled(selectedUserIds.map");
expect(source).toContain("个账号,${failed.length} 个账号授权失败");
expect(source).toContain("closeAccessManagement");
expect(source).not.toContain("memberDialogVisible");
expect(source).not.toContain("shareDialogVisible");
});
it("opens permission settings as a returnable view in the same dialog", () => {
expect(source).toContain('const accessPanel = ref<"overview" | "permissions">("overview")');
expect(source).toContain('@click="openPermissionSettings"');
expect(source).toContain('aria-label="返回访问与权限"');
expect(source).toContain('@click="handleAccessPanelBack"');
expect(source).toContain("const handleAccessPanelBack = () =>");
expect(source).toContain('v-else-if="accessPanel === \'permissions\'" class="access-section access-section--common"');
expect(source).not.toContain("permissionDialogVisible");
});
it("implements edit requests, sheet structure control, and contact ownership transfer", () => {
expect(source).toContain("允许申请编辑权限");
expect(source).toContain("handleEditRequestPermissionChange");
expect(source).toContain("待处理申请");
expect(source).toContain("resolveCollaborationEditRequest");
expect(source).toContain("notifyProjectNotificationsChanged");
expect(source).toContain("route.query.editRequestFile");
expect(source).toContain("openEditRequestNotificationTarget");
expect(source).toContain('class="access-section__pending-count"');
expect(source).toContain('ref="editRequestSectionRef"');
expect(source).toContain("editRequestSectionRef.value?.focus");
const accountAuthorizationIndex = source.indexOf('class="access-section access-section--members"');
const pendingRequestIndex = source.indexOf('class="edit-request-list"');
const permissionSettingsIndex = source.indexOf("v-else-if=\"accessPanel === 'permissions'\"");
expect(pendingRequestIndex).toBeGreaterThan(accountAuthorizationIndex);
expect(pendingRequestIndex).toBeLessThan(permissionSettingsIndex);
expect(source).toContain("允许所有协作者添加、删除工作表");
expect(source).toContain("handleSheetStructurePermissionChange");
expect(source).toContain("转让文档所有权");
expect(source).toContain('v-model="transferDialogVisible"');
expect(source).toContain('title="请选择新的所有者"');
expect(source).toContain('class="ownership-transfer-layout"');
expect(source).toContain('role="listbox" aria-label="可转让联系人"');
expect(source).toContain('v-for="candidate in transferCandidates"');
expect(source).toContain('{{ candidate.role_in_study }}');
expect(source).toContain('{{ selectedTransferCandidate.role_in_study }}');
expect(source).toContain('currentOwnerCandidate?.role_in_study');
expect(source).toContain('class="collaboration-role-tag"');
expect(source).toContain('--el-tag-bg-color: color-mix(in srgb, var(--ctms-primary) 13%, var(--ctms-bg-muted))');
expect(source).toContain('v-if="selectedTransferCandidate"');
expect(source).toContain('description="暂未选择联系人"');
expect(source).toContain(':disabled="!selectedTransferCandidate"');
expect(source).toContain('>确定</el-button>');
expect(source).not.toContain('accessPanel === "transfer"');
expect(source).toContain("transferCollaborationOwnership");
});
it("saves permission switches silently while retaining failure feedback", () => {
expect(source).not.toContain('ElMessage.success("内容操作权限已更新")');
expect(source).not.toContain('ElMessage.success("编辑权限申请设置已更新")');
expect(source).not.toContain('ElMessage.success("工作表结构权限已更新")');
expect(source).toContain('getApiErrorMessage(error, "内容操作权限保存失败")');
expect(source).toContain('getApiErrorMessage(error, "编辑权限申请设置保存失败")');
expect(source).toContain('getApiErrorMessage(error, "工作表结构权限保存失败")');
});
it("configures real immutable link sharing with mutable access settings", () => {
expect(source).toContain('v-model="accessDialogVisible"');
expect(source).toContain("const openAccessManagement = async");
expect(source).toContain("accessFile.value.id");
expect(source).toContain('v-model="shareForm.enabled"');
expect(source).toContain('class="access-setting-status"');
expect(source).toContain('class="share-settings-list"');
expect(source).toContain('class="share-setting-row share-setting-row--password"');
expect(source).toContain('class="share-link-panel"');
expect(source).toContain('class="access-setting-navigation"');
expect(source).toContain("accessPermissionSummary");
expect(source).toContain('v-model="shareForm.access_mode"');
expect(source).toContain('v-model="commonExportEnabled"');
expect(source).toContain('@change="handleCommonExportChange"');
expect(source).toContain("{ allow_export: next }");
expect(source).not.toContain("shareForm.allow_export");
expect(source).toContain('v-model="shareForm.expiry_policy"');
for (const policy of ["ONE_DAY", "SEVEN_DAYS", "THIRTY_DAYS", "PERMANENT"]) {
expect(source).toContain(`value="${policy}"`);
}
expect(source).toContain('v-model="shareForm.password_enabled"');
expect(source).toContain("handleCommonExportChange");
expect(source).toContain("promptSharePassword");
expect(source).toContain('label="可查看" value="VIEW"');
expect(source).toContain('label="可编辑" value="EDIT"');
expect(source).not.toContain("获得链接的人可编辑");
expect(source).not.toContain("获得链接的人可查看");
expect(source).toContain("允许编辑者和匿名访问者下载、打印、另存和复制");
expect(source).not.toContain("系统内账号登录后按文件角色访问");
expect(source).not.toContain("无需 CTMS 账号,通过链接访问文件");
expect(source).not.toContain("同时约束账号授权和匿名访问");
expect(source).toContain('@change="persistShareSettings()"');
expect(source).not.toContain("保存链接设置");
expect(source).toContain("fetchCollaborationShareLink");
expect(source).toContain("updateCollaborationShareLink");
expect(source).not.toContain("regenerateCollaborationShareLink");
expect(source).not.toContain("重新生成链接");
expect(source).not.toContain("关闭后重新开启仍使用同一地址");
expect(source).not.toContain("匿名访问已关闭,之前复制的访问链接无法继续使用");
expect(source).not.toContain("share-link-disabled");
expect(source).not.toContain("修改后自动保存");
expect(source).not.toContain("通过链接邀请外部人员查看或协作编辑");
expect(source).not.toContain("邀请项目内成员并分配协作权限");
expect(source).toContain("copyShareUrl");
expect(source).not.toContain("链接分享暂未开放");
});
it("uses an edge-to-edge compact table with a single row action menu", () => {
expect(source).toContain('size="small"');
expect(source).toContain('label="操作" width="72"');
expect(source).toContain('popper-class="collaboration-row-actions-popper"');
expect(source).toContain('@command="handleFileAction($event, row)"');
expect(source).toContain(".collaboration-library__main { display: flex; min-width: 0; min-height: 0;");
expect(source).toContain("height: 100%;");
expect(source).toContain(".collaboration-table-wrap { min-height: 0; flex: 1; overflow: auto;");
expect(source).not.toContain('label="操作" width="300"');
});
it("opens the same action definitions from a desktop row context menu", () => {
expect(source).toContain('@row-contextmenu="openFileContextMenu"');
expect(source).toContain('v-if="isDesktop"');
expect(source).toContain('ref="fileContextMenuRef"');
expect(source).toContain("fileActions(fileContextMenuRow)");
expect(source).toContain("handleFileContextAction");
expect(source).toContain("event.preventDefault();");
});
it("keeps the fixed operation column background consistent with the table", () => {
expect(source).toContain(".el-table__header-wrapper .el-table-fixed-column--right");
expect(source).toContain("background: var(--unified-table-header-bg) !important;");
expect(source).not.toContain("background: var(--el-table-header-bg-color) !important;");
expect(source).toContain(".el-table__body tr:hover > .el-table-fixed-column--right");
});
it("uses shared theme tokens across the full collaboration canvas", () => {
expect(source).toContain("background: var(--ctms-bg-card);");
expect(source).toContain("background: var(--ctms-bg-base);");
expect(source).toContain("border-right: 1px solid var(--ctms-border-color);");
expect(source).toContain(".folder-item.active { color: var(--ctms-primary); background: var(--ctms-primary-light);");
expect(source).toContain(".collaboration-table-wrap { min-height: 0; flex: 1; overflow: auto; background: var(--ctms-bg-card);");
expect(source).not.toContain("background: #f8fafc;");
});
it("shows collaborators from the file-list response as a compact avatar stack", () => {
expect(source).toContain('label="协作者" width="150"');
expect(source).toContain("row.collaborators");
expect(source).toContain("collaborator-stack");
expect(source).toContain("collaboratorTone(collaborator.user_id)");
});
it("refreshes again after returning from an editor so delayed save callbacks update the list", () => {
expect(source).toContain("const scheduleRevisionRefresh");
expect(source).toContain("[2_500, 12_000]");
expect(source).toContain("onActivated(() =>");
expect(source).toContain("onDeactivated(clearRevisionRefreshTimers)");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationShareWorkspace.vue"), "utf8");
describe("CollaborationShareWorkspace security contract", () => {
it("reads the opaque link from the URL fragment and keeps grants in memory", () => {
expect(source).toContain("window.location.hash.slice(1)");
expect(source).toContain("const accessToken = ref");
expect(source).not.toContain("localStorage");
expect(source).not.toContain("sessionStorage");
expect(source).not.toContain("route.query");
});
it("requires the link password before initializing ONLYOFFICE", () => {
expect(source).toContain("verifyPublicCollaborationSharePassword");
expect(source).toContain("fetchPublicCollaborationEditorConfig");
expect(source).toContain('v-else-if="needsPassword"');
expect(source).toContain(':allow-save-as="false"');
expect(source).toContain(':allow-download="editorConfig.can_download"');
expect(source).toContain(':allow-clipboard="Boolean(metadata?.allow_export)"');
expect(source).toContain("response.data.host_path !== ONLYOFFICE_HOST_PATH");
});
it("downloads export-enabled shares to a user-selected local destination", () => {
expect(source).toContain('@download="handleDownload"');
expect(source).toContain("CollaborationDownloadDialog");
expect(source).toContain('@confirm="confirmDownload"');
expect(source).toContain("downloadDialogVisible.value = true");
expect(source).toContain("const destinationPromise = prepareSaveFile(suggestedName)");
expect(source).toContain('kind: "download"');
});
it("shows view or edit access and destroys sensitive state on unmount", () => {
expect(source).toContain('metadata.access_mode === "edit"');
expect(source).toContain("destroyEditor();");
expect(source).toContain('accessToken.value = ""');
expect(source).toContain('password.value = ""');
});
});
@@ -0,0 +1,368 @@
<template>
<section class="share-workspace">
<header class="share-workspace__header">
<div class="share-workspace__brand">CTMS 在线协作</div>
<div class="share-workspace__title" :title="metadata?.file_name">{{ metadata?.file_name || "共享文件" }}</div>
<span v-if="metadata" class="share-workspace__access" :class="`is-${metadata.access_mode}`">
{{ metadata.access_mode === "edit" ? "可编辑" : "只读查看" }}
</span>
<span v-if="metadata?.expires_at" class="share-workspace__expiry">有效期至 {{ displayDateTime(metadata.expires_at) }}</span>
<span v-else-if="metadata" class="share-workspace__expiry">永久有效</span>
<span class="share-workspace__status" :class="`is-${status}`">{{ statusLabel }}</span>
</header>
<main class="share-workspace__content">
<OnlyOfficeViewer
v-if="editorConfig && !errorMessage"
:key="viewerKey"
:config="editorConfig.config"
:allow-clipboard="Boolean(metadata?.allow_export)"
:allow-save-as="false"
:allow-download="editorConfig.can_download"
:frame-title="`ONLYOFFICE 共享文件:${metadata?.file_name || ''}`"
@ready="status = 'ready'"
@warning="handleWarning"
@error="handleViewerError"
@state-change="handleStateChange"
@download="handleDownload"
@download-error="handleDownloadError"
/>
<div v-else class="share-workspace__state">
<el-icon v-if="loading" class="is-loading" :size="30"><Loading /></el-icon>
<div v-else-if="needsPassword" class="share-password-card">
<div class="share-password-card__icon"><el-icon><Lock /></el-icon></div>
<h1>此链接受密码保护</h1>
<p>{{ metadata?.file_name }}</p>
<el-input
v-model="password"
type="password"
show-password
maxlength="64"
autocomplete="current-password"
placeholder="请输入链接密码"
autofocus
@keyup.enter="submitPassword"
/>
<el-button type="primary" :loading="verifying" @click="submitPassword">打开文件</el-button>
<span v-if="passwordError" class="share-password-card__error">{{ passwordError }}</span>
</div>
<div v-else-if="errorMessage" class="share-error-card">
<h1>无法打开共享文件</h1>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadShare">重新加载</el-button>
</div>
</div>
</main>
<CollaborationDownloadDialog
v-model="downloadDialogVisible"
:file-name="downloadTitle"
:saving="downloading"
@confirm="confirmDownload"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Loading, Lock } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import OnlyOfficeViewer from "../../components/OnlyOfficeViewer.vue";
import CollaborationDownloadDialog from "../../components/collaboration/CollaborationDownloadDialog.vue";
import {
fetchPublicCollaborationEditorConfig,
fetchPublicCollaborationShareMetadata,
verifyPublicCollaborationSharePassword,
} from "../../api/collaboration";
import { isTauriRuntime, ONLYOFFICE_HOST_PATH, prepareSaveFile } from "../../runtime";
import { displayDateTime } from "../../utils/display";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import type {
CollaborationEditorConfig,
CollaborationPublicShareMetadata,
} from "../../types/collaboration";
import type { OnlyOfficeSaveAsPayload } from "../../types/onlyoffice";
type ShareStatus = "loading" | "locked" | "ready" | "saving" | "saved" | "warning" | "error";
const metadata = ref<CollaborationPublicShareMetadata | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const loading = ref(false);
const verifying = ref(false);
const needsPassword = ref(false);
const password = ref("");
const passwordError = ref("");
const errorMessage = ref("");
const status = ref<ShareStatus>("loading");
const warningMessage = ref("");
const accessToken = ref("");
const requestSequence = ref(0);
const viewerSequence = ref(0);
const downloading = ref(false);
const downloadDialogVisible = ref(false);
const downloadTitle = ref("");
const pendingDownload = ref<OnlyOfficeSaveAsPayload | null>(null);
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
});
const shareToken = () => {
try {
return decodeURIComponent(window.location.hash.slice(1).trim());
} catch {
return "";
}
};
const clientId = (() => {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, "0")).join("");
})();
const viewerKey = computed(() => `${clientId}:${viewerSequence.value}`);
const statusLabel = computed(() => {
if (downloading.value) return "正在下载";
if (status.value === "locked") return "等待密码";
if (status.value === "ready") return "已连接";
if (status.value === "saving") return "正在自动保存";
if (status.value === "saved") return "更改已同步";
if (status.value === "warning") return warningMessage.value || "协作服务警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const errorForResponse = async (error: unknown) => {
const statusCode = Number((error as any)?.response?.status || 0);
if (statusCode === 404) return "链接不存在、已关闭或共享文件已被删除";
if (statusCode === 410) return "共享链接已过期,请联系文件所有者重新分享";
if (statusCode === 429) return "密码尝试次数过多,请稍后再试";
if (statusCode === 503) return "在线协作服务暂时不可用";
return getApiErrorMessage(error, "共享文件加载失败");
};
const destroyEditor = () => {
requestSequence.value += 1;
editorConfig.value = null;
viewerSequence.value += 1;
downloading.value = false;
downloadDialogVisible.value = false;
downloadTitle.value = "";
pendingDownload.value = null;
};
const loadEditor = async (sequence: number) => {
const response = await fetchPublicCollaborationEditorConfig(shareToken(), {
access_token: accessToken.value || undefined,
client_id: clientId,
display_name: "链接访客",
});
if (requestSequence.value !== sequence) return;
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
editorConfig.value = response.data;
viewerSequence.value += 1;
};
const loadShare = async () => {
const token = shareToken();
if (!token) {
errorMessage.value = "共享链接缺少访问凭证";
status.value = "error";
return;
}
destroyEditor();
const sequence = requestSequence.value;
loading.value = true;
errorMessage.value = "";
passwordError.value = "";
needsPassword.value = false;
status.value = "loading";
try {
const response = await fetchPublicCollaborationShareMetadata(token);
if (requestSequence.value !== sequence) return;
metadata.value = response.data;
needsPassword.value = response.data.requires_password && !accessToken.value;
if (needsPassword.value) {
status.value = "locked";
} else {
await loadEditor(sequence);
}
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (requestSequence.value === sequence) loading.value = false;
}
};
const submitPassword = async () => {
if (!password.value || verifying.value) {
if (!password.value) passwordError.value = "请输入链接密码";
return;
}
verifying.value = true;
passwordError.value = "";
try {
const response = await verifyPublicCollaborationSharePassword(shareToken(), password.value);
accessToken.value = response.data.access_token;
password.value = "";
needsPassword.value = false;
status.value = "loading";
loading.value = true;
const sequence = requestSequence.value;
await loadEditor(sequence);
} catch (error) {
const statusCode = Number((error as any)?.response?.status || 0);
passwordError.value = statusCode === 401 ? "链接密码不正确" : await errorForResponse(error);
status.value = statusCode === 401 ? "locked" : "error";
} finally {
verifying.value = false;
loading.value = false;
}
};
const eventMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
};
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = eventMessage(detail) || "协作服务返回警告";
status.value = "warning";
};
const handleViewerError = (detail: Record<string, unknown>) => {
errorMessage.value = eventMessage(detail) || "共享文件加载或转换失败";
editorConfig.value = null;
status.value = "error";
};
const handleStateChange = (changed: boolean) => {
status.value = changed ? "saving" : "saved";
};
const suggestedDownloadName = (payload: OnlyOfficeSaveAsPayload) => {
const extension = payload.fileType.toLowerCase();
return payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
};
const savePendingDownload = async () => {
const payload = pendingDownload.value;
if (!payload || downloading.value || !editorConfig.value?.can_download) return;
const suggestedName = suggestedDownloadName(payload);
// Web
const destinationPromise = prepareSaveFile(suggestedName);
downloading.value = true;
try {
const destination = await destinationPromise;
if (!destination) {
if (!downloadDialogVisible.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
return;
}
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
kind: "download",
title: "下载共享文件",
pendingDetail: "请选择本机保存位置",
completedDetail: "文件已下载到指定位置",
},
destination,
);
if (result === "saved") {
downloadDialogVisible.value = false;
pendingDownload.value = null;
downloadTitle.value = "";
}
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "共享文件下载失败"));
} finally {
downloading.value = false;
}
};
const confirmDownload = () => {
void savePendingDownload();
};
const handleDownload = (payload: OnlyOfficeSaveAsPayload) => {
if (downloading.value || pendingDownload.value || !editorConfig.value?.can_download) return;
pendingDownload.value = payload;
downloadTitle.value = suggestedDownloadName(payload);
if (isTauriRuntime()) {
void savePendingDownload();
return;
}
downloadDialogVisible.value = true;
};
const handleDownloadError = (message: string) => {
ElMessage.error(message || "共享文件下载失败");
};
onMounted(loadShare);
onBeforeUnmount(() => {
destroyEditor();
accessToken.value = "";
password.value = "";
});
</script>
<style scoped>
.share-workspace {
display: grid;
grid-template-rows: 48px minmax(0, 1fr);
width: 100vw;
height: 100vh;
height: 100dvh;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #273b50;
background: #f3f5f8;
}
.share-workspace__header {
z-index: 1;
display: grid;
grid-template-columns: auto minmax(120px, 1fr) auto auto auto;
align-items: center;
gap: 12px;
padding: 0 18px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.share-workspace__brand { color: #3d617d; font-size: 14px; font-weight: 700; white-space: nowrap; }
.share-workspace__title { overflow: hidden; font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.share-workspace__access { padding: 3px 9px; border-radius: 11px; color: #52667a; background: #eef2f6; font-size: 12px; white-space: nowrap; }
.share-workspace__access.is-edit { color: #237451; background: #e7f5ee; }
.share-workspace__expiry,
.share-workspace__status { color: #738398; font-size: 12px; white-space: nowrap; }
.share-workspace__status.is-ready,
.share-workspace__status.is-saved { color: #25845b; }
.share-workspace__status.is-saving { color: #99651e; }
.share-workspace__status.is-warning,
.share-workspace__status.is-error { color: #b65c29; }
.share-workspace__content { min-width: 0; min-height: 0; overflow: hidden; }
.share-workspace__state { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; padding: 24px; }
.share-password-card,
.share-error-card { display: flex; width: min(390px, 100%); flex-direction: column; align-items: stretch; gap: 14px; padding: 30px; border: 1px solid #dbe3ec; border-radius: 14px; background: #fff; box-shadow: 0 16px 42px rgba(40, 58, 78, 0.12); text-align: center; }
.share-password-card__icon { display: grid; width: 46px; height: 46px; place-items: center; align-self: center; border-radius: 50%; color: #3f6f91; background: #eaf2f8; font-size: 22px; }
.share-password-card h1,
.share-error-card h1 { margin: 0; color: #26384c; font-size: 19px; }
.share-password-card p,
.share-error-card p { margin: 0 0 4px; color: #75859a; font-size: 13px; }
.share-password-card__error { color: #d14f4f; font-size: 12px; text-align: left; }
@media (max-width: 720px) {
.share-workspace__header { grid-template-columns: minmax(0, 1fr) auto auto; padding: 0 10px; }
.share-workspace__brand,
.share-workspace__expiry { display: none; }
}
</style>
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationWorkspace.vue"), "utf8");
const globalStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
describe("CollaborationWorkspace Save As contract", () => {
it("separates signed Save As and download capabilities", () => {
expect(source).toContain(':allow-save-as="editorConfig.can_save_as"');
expect(source).toContain(':allow-download="editorConfig.can_download"');
expect(source).toContain(':allow-clipboard="Boolean(editorConfig.can_download)"');
expect(source).toContain('@save-as="handleSaveAs"');
expect(source).toContain('@download="handleDownload"');
expect(source).toContain("!editorConfig.value?.can_save_as");
expect(source).toContain("!editorConfig.value?.can_download");
});
it("reuses the workspace picker and imports Save As bytes as a new file", () => {
expect(source).toContain("CollaborationSaveAsDialog");
expect(source).toContain('@confirm="submitSaveAs"');
expect(source).toContain("const form = new FormData()");
expect(source).toContain('form.append("folder_id", selection.folderId)');
expect(source).toContain("importCollaborationFile(studyId.value, form)");
});
it("asks for a local destination from a user-confirmed web dialog and records the completed download", () => {
expect(source).toContain("CollaborationDownloadDialog");
expect(source).toContain('@confirm="confirmDownload"');
expect(source).toContain("downloadDialogVisible.value = true");
expect(source).toContain("const destinationPromise = prepareSaveFile(suggestedName)");
expect(source).toContain("saveFileWithFeedback(");
expect(source).toContain('kind: "download"');
expect(source).toContain("recordCollaborationDownload(studyId.value, fileId.value, extension)");
});
it("submits ONLYOFFICE edit-right requests without granting edit locally", () => {
expect(source).toContain('@request-edit-rights="requestEditRights"');
expect(source).toContain("createCollaborationEditRequest(studyId.value, fileId.value)");
expect(source).toContain('edit_request_status: "PENDING"');
expect(source).toContain("编辑权限申请处理中");
});
it("keeps the plain edit-request action visually distinct from solid primary buttons", () => {
expect(source).toContain('type="primary"');
expect(source).toContain("plain");
expect(source).toContain("申请编辑权限");
expect(globalStyles).toContain(".el-button--primary:not(.is-plain):not(.is-link):not(.is-text)");
expect(globalStyles).not.toMatch(/\.el-button--primary\s*\{[^}]*background-color:/);
});
it("provides an accessible web fullscreen control without duplicating native desktop controls", () => {
expect(source).toContain('v-if="showWorkspaceFullscreenControl"');
expect(source).toContain("computed(() => webFullscreenAvailable.value && !isTauriRuntime())");
expect(source).toContain('class="collaboration-workspace__fullscreen"');
expect(source).toContain(':aria-label="webFullscreenLabel"');
expect(source).toContain(':aria-pressed="webFullscreenActive"');
expect(source).toContain("toggleWebFullscreen");
expect(source).toContain("listenWebFullscreenChange(syncWebFullscreenState)");
expect(source).toContain("await exitWorkspaceFullscreen()");
});
it("reserves the macOS traffic-light safe area only outside native fullscreen", () => {
expect(source).toContain("'is-macos-desktop': isMacDesktop");
expect(source).toContain('isTauriRuntime() && getRuntimePlatform() === "macos"');
expect(source).toContain(".collaboration-workspace.is-macos-desktop:not(.is-fullscreen) .collaboration-workspace__header");
expect(source).toContain("padding-left: 72px;");
});
it("keeps the shared web and desktop document bar compact", () => {
expect(source).toContain("grid-template-rows: 36px minmax(0, 1fr);");
expect(source).toContain("grid-template-columns: 30px minmax(0, 1fr) auto auto auto auto;");
expect(source).toContain("width: 28px;");
expect(source).toContain("height: 28px;");
});
it("reduces the fullscreen CTMS header to an opaque auto-hiding control", () => {
expect(source).toContain("'is-fullscreen': webFullscreenActive");
expect(source).toContain("'is-fullscreen-toolbar-visible': fullscreenToolbarVisible");
expect(source).toContain('class="collaboration-workspace__fullscreen-hotzone"');
expect(source).toContain('@pointerenter="showFullscreenToolbar"');
expect(source).toContain("fullscreenToolbarHideTimer = window.setTimeout");
expect(source).toContain("}, 900);");
expect(source).toContain('const viewerKey = computed(() => `${fileId.value}:${viewerSequence.value}`);');
expect(source).toContain(".collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }");
expect(source).toContain("width: 88px;");
expect(source).toContain("transform: translateY(calc(-100% - 12px));");
expect(source).toContain(".collaboration-workspace.is-fullscreen .collaboration-workspace__header > :not(.collaboration-workspace__back):not(.collaboration-workspace__fullscreen)");
expect(source).toContain(".collaboration-workspace.is-fullscreen .collaboration-workspace__header:has(:focus-visible)");
expect(source).toContain("background: #fff;");
expect(source).toContain("opacity: 1;");
expect(source).toContain(".collaboration-workspace.is-fullscreen .collaboration-workspace__content { grid-row: 1; }");
});
});
@@ -0,0 +1,617 @@
<template>
<section
class="collaboration-workspace"
:class="{
'is-fullscreen': webFullscreenActive,
'is-fullscreen-toolbar-visible': fullscreenToolbarVisible,
'is-macos-desktop': isMacDesktop,
}"
>
<div
v-if="webFullscreenActive"
class="collaboration-workspace__fullscreen-hotzone"
aria-hidden="true"
@pointerenter="showFullscreenToolbar"
@pointerleave="scheduleFullscreenToolbarHide"
/>
<header
class="collaboration-workspace__header"
@pointerenter="cancelFullscreenToolbarHide"
@pointerleave="scheduleFullscreenToolbarHide"
@focusin="showFullscreenToolbar"
@focusout="scheduleFullscreenToolbarHide"
>
<button class="collaboration-workspace__back" type="button" title="关闭协作文件并返回" @click="goBack"></button>
<div class="collaboration-workspace__title" :title="fileName">{{ fileName }}</div>
<div class="collaboration-workspace__access" :class="`is-${accessMode}`">
{{ accessMode === "edit" ? "协作编辑" : "只读查看" }}
</div>
<div class="collaboration-workspace__status" :class="`is-${status}`">{{ statusLabel }}</div>
<el-button
v-if="accessMode === 'view' && file?.edit_request_status === 'PENDING'"
size="small"
disabled
>编辑权限申请处理中</el-button>
<el-button
v-else-if="accessMode === 'view' && (file?.can_request_edit || editorConfig?.can_request_edit)"
size="small"
type="primary"
plain
:loading="requestingEdit"
@click="requestEditRights"
>申请编辑权限</el-button>
<el-button v-if="errorMessage" link type="primary" :loading="loading" @click="loadWorkspace">重试</el-button>
<el-tooltip v-if="showWorkspaceFullscreenControl" :content="webFullscreenLabel" placement="bottom">
<button
type="button"
class="collaboration-workspace__fullscreen"
:aria-label="webFullscreenLabel"
:aria-pressed="webFullscreenActive"
@click="handleWebFullscreenToggle"
>
<el-icon><ScaleToOriginal v-if="webFullscreenActive" /><FullScreen v-else /></el-icon>
</button>
</el-tooltip>
</header>
<main class="collaboration-workspace__content">
<OnlyOfficeViewer
v-if="editorConfig && !errorMessage"
:key="viewerKey"
:config="editorConfig.config"
:allow-clipboard="Boolean(editorConfig.can_download)"
:allow-save-as="editorConfig.can_save_as"
:allow-download="editorConfig.can_download"
:frame-title="`ONLYOFFICE 在线协作:${fileName}`"
@ready="handleReady"
@warning="handleWarning"
@error="handleError"
@state-change="handleStateChange"
@save-as="handleSaveAs"
@save-as-error="handleSaveAsError"
@download="handleDownload"
@download-error="handleDownloadError"
@request-edit-rights="requestEditRights"
/>
<div v-else class="collaboration-workspace__state">
<el-icon v-if="loading" class="is-loading" :size="28"><Loading /></el-icon>
<template v-else-if="errorMessage">
<strong>无法打开协作文件</strong>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadWorkspace">重新加载</el-button>
</template>
</div>
</main>
<CollaborationSaveAsDialog
v-model="saveAsDialogVisible"
:study-id="studyId"
:initial-title="saveAsTitle"
:initial-folder-id="file?.folder_id || null"
:saving="savingCopy"
:can-create-folder="canCreateWorkspaceFile"
@confirm="submitSaveAs"
/>
<CollaborationDownloadDialog
v-model="downloadDialogVisible"
:file-name="downloadTitle"
:saving="downloading"
@confirm="confirmDownload"
/>
</section>
</template>
<script setup lang="ts">
import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { FullScreen, Loading, ScaleToOriginal } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import OnlyOfficeViewer from "../../components/OnlyOfficeViewer.vue";
import CollaborationDownloadDialog from "../../components/collaboration/CollaborationDownloadDialog.vue";
import CollaborationSaveAsDialog from "../../components/collaboration/CollaborationSaveAsDialog.vue";
import { workspaceTaskControllerKey } from "../../components/layout/workspaceTaskController";
import {
fetchCollaborationEditorConfig,
fetchCollaborationFile,
createCollaborationEditRequest,
importCollaborationFile,
recordCollaborationDownload,
} from "../../api/collaboration";
import { useStudyStore } from "../../store/study";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getRuntimePlatform,
isWebFullscreenActive,
isWebFullscreenAvailable,
isTauriRuntime,
listenWebFullscreenChange,
ONLYOFFICE_HOST_PATH,
prepareSaveFile,
refreshWebFullscreenState,
toggleWebFullscreen,
} from "../../runtime";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { usePermission } from "../../utils/permission";
import type { CollaborationEditorConfig, CollaborationFile } from "../../types/collaboration";
import type { OnlyOfficeSaveAsPayload } from "../../types/onlyoffice";
type WorkspaceStatus = "loading" | "ready" | "saving" | "saved" | "warning" | "error";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const { can } = usePermission();
const file = ref<CollaborationFile | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const errorMessage = ref("");
const warningMessage = ref("");
const loading = ref(false);
const status = ref<WorkspaceStatus>("loading");
const requestSequence = ref(0);
const viewerSequence = ref(0);
const savingCopy = ref(false);
const requestingEdit = ref(false);
const downloading = ref(false);
const downloadDialogVisible = ref(false);
const downloadTitle = ref("");
const pendingDownload = ref<OnlyOfficeSaveAsPayload | null>(null);
const saveAsDialogVisible = ref(false);
const saveAsTitle = ref("");
const pendingSaveAs = ref<OnlyOfficeSaveAsPayload | null>(null);
const webFullscreenAvailable = ref(false);
const webFullscreenActive = ref(false);
const fullscreenToolbarVisible = ref(false);
const isMacDesktop = isTauriRuntime() && getRuntimePlatform() === "macos";
let mounted = false;
let webFullscreenUnlisten: (() => void) | null = null;
let fullscreenToolbarHideTimer: number | undefined;
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
});
const studyId = computed(() => study.currentStudy?.id || "");
const fileId = computed(() => String(route.params.fileId || ""));
const fileName = computed(() => editorConfig.value?.file_name || file.value?.title || "在线协作");
const accessMode = computed(() => editorConfig.value?.access_mode || (file.value?.can_edit ? "edit" : "view"));
const viewerKey = computed(() => `${fileId.value}:${viewerSequence.value}`);
const canCreateWorkspaceFile = computed(() =>
["read", "create", "edit", "manage", "export", "delete"].every((action) =>
can(`collaboration.${action}`),
),
);
const statusLabel = computed(() => {
if (savingCopy.value) return "正在另存为";
if (downloading.value) return "正在下载";
if (status.value === "ready") return "已连接";
if (status.value === "saving") return "正在自动保存";
if (status.value === "saved") return "更改已同步";
if (status.value === "warning") return warningMessage.value || "协作服务警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const showWorkspaceFullscreenControl = computed(() => webFullscreenAvailable.value && !isTauriRuntime());
const webFullscreenLabel = computed(() => webFullscreenActive.value ? "退出全屏" : "进入全屏");
const cancelFullscreenToolbarHide = () => {
if (fullscreenToolbarHideTimer !== undefined) window.clearTimeout(fullscreenToolbarHideTimer);
fullscreenToolbarHideTimer = undefined;
};
const showFullscreenToolbar = () => {
if (!webFullscreenActive.value) return;
cancelFullscreenToolbarHide();
fullscreenToolbarVisible.value = true;
};
const scheduleFullscreenToolbarHide = () => {
if (!webFullscreenActive.value) return;
cancelFullscreenToolbarHide();
fullscreenToolbarHideTimer = window.setTimeout(() => {
fullscreenToolbarVisible.value = false;
fullscreenToolbarHideTimer = undefined;
}, 900);
};
const syncWebFullscreenState = () => {
webFullscreenAvailable.value = isWebFullscreenAvailable();
const active = isWebFullscreenActive();
if (active !== webFullscreenActive.value) {
cancelFullscreenToolbarHide();
fullscreenToolbarVisible.value = false;
}
webFullscreenActive.value = active;
};
const handleWebFullscreenToggle = async () => {
try {
await toggleWebFullscreen();
} catch (error) {
const message = isTauriRuntime()
? "无法切换桌面全屏,请使用“显示 > 进入全屏”重试"
: typeof error === "object" && error !== null && "name" in error && error.name === "NotAllowedError"
? "浏览器未允许进入全屏,请再次点击或检查站点权限"
: "无法切换全屏,请使用浏览器菜单重试";
ElMessage.warning(message);
} finally {
syncWebFullscreenState();
}
};
const exitWorkspaceFullscreen = async () => {
try {
if (!await refreshWebFullscreenState()) return;
await toggleWebFullscreen();
} catch {
// 退
} finally {
syncWebFullscreenState();
}
};
const errorForResponse = async (error: unknown) => {
const statusCode = Number((error as any)?.response?.status || 0);
const code = String((error as any)?.response?.data?.code || "");
if (code === "ONLYOFFICE_DISABLED") return "在线协作服务尚未启用";
if (code === "ONLYOFFICE_UNAVAILABLE") return "在线协作服务暂时不可用";
if (statusCode === 401) return "登录状态已失效,请重新登录";
if (statusCode === 403) return "您没有查看此协作文件的权限";
if (statusCode === 404) return "协作文件不存在或已被移入回收站";
return getApiErrorMessage(error, "在线协作编辑器加载失败");
};
const destroyEditor = () => {
requestSequence.value += 1;
editorConfig.value = null;
viewerSequence.value += 1;
savingCopy.value = false;
downloading.value = false;
downloadDialogVisible.value = false;
downloadTitle.value = "";
pendingDownload.value = null;
saveAsDialogVisible.value = false;
pendingSaveAs.value = null;
};
const loadWorkspace = async () => {
if (!studyId.value || !fileId.value) return;
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
destroyEditor();
requestSequence.value = sequence;
errorMessage.value = "";
warningMessage.value = "";
loading.value = true;
status.value = "loading";
try {
const [fileResponse, configResponse] = await Promise.all([
fetchCollaborationFile(studyId.value, fileId.value),
fetchCollaborationEditorConfig(studyId.value, fileId.value),
]);
if (requestSequence.value !== sequence) return;
if (configResponse.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
file.value = fileResponse.data;
editorConfig.value = configResponse.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (requestSequence.value === sequence) loading.value = false;
}
};
const eventMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
const code = detail.errorCode ?? detail.warningCode ?? detail.code;
return code === undefined ? "" : `错误代码:${String(code)}`;
};
const handleReady = () => { status.value = "ready"; };
const handleStateChange = (changed: boolean) => { status.value = changed ? "saving" : "saved"; };
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = eventMessage(detail) || "协作服务返回警告";
status.value = "warning";
};
const handleError = (detail: Record<string, unknown>) => {
errorMessage.value = eventMessage(detail) || "协作文件加载或转换失败";
status.value = "error";
editorConfig.value = null;
};
const requestEditRights = async () => {
if (requestingEdit.value || accessMode.value === "edit" || file.value?.edit_request_status === "PENDING") return;
requestingEdit.value = true;
try {
await createCollaborationEditRequest(studyId.value, fileId.value);
if (file.value) {
file.value = { ...file.value, can_request_edit: false, edit_request_status: "PENDING" };
}
if (editorConfig.value) editorConfig.value = { ...editorConfig.value, can_request_edit: false };
ElMessage.success("编辑权限申请已提交");
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "编辑权限申请失败"));
} finally {
requestingEdit.value = false;
}
};
const handleSaveAs = (payload: OnlyOfficeSaveAsPayload) => {
if (savingCopy.value || !editorConfig.value?.can_save_as) return;
const extension = payload.fileType.toLowerCase();
saveAsTitle.value = payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
pendingSaveAs.value = payload;
saveAsDialogVisible.value = true;
};
const submitSaveAs = async (selection: { title: string; folderId: string | null }) => {
const payload = pendingSaveAs.value;
if (!payload || savingCopy.value || !editorConfig.value?.can_save_as) return;
savingCopy.value = true;
try {
const extension = payload.fileType.toLowerCase();
const targetTitle = selection.title.toLowerCase().endsWith(`.${extension}`)
? selection.title
: `${selection.title}.${extension}`;
const form = new FormData();
form.append("file", new File([payload.data], targetTitle, {
type: payload.mimeType || "application/octet-stream",
lastModified: Date.now(),
}));
if (selection.folderId) form.append("folder_id", selection.folderId);
const { data } = await importCollaborationFile(studyId.value, form);
saveAsDialogVisible.value = false;
pendingSaveAs.value = null;
ElMessage.success(`已在工作区创建“${data.title}`);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "协作文件另存为失败"));
} finally {
savingCopy.value = false;
}
};
const handleSaveAsError = (message: string) => {
ElMessage.error(message || "协作文件另存为失败");
};
const suggestedDownloadName = (payload: OnlyOfficeSaveAsPayload) => {
const extension = payload.fileType.toLowerCase();
return payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
};
const savePendingDownload = async () => {
const payload = pendingDownload.value;
if (!payload || downloading.value || !editorConfig.value?.can_download) return;
const extension = payload.fileType.toLowerCase();
const suggestedName = suggestedDownloadName(payload);
// Web
const destinationPromise = prepareSaveFile(suggestedName);
downloading.value = true;
try {
const destination = await destinationPromise;
if (!destination) {
if (!downloadDialogVisible.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
return;
}
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
kind: "download",
title: "下载协作文件",
pendingDetail: "请选择本机保存位置",
completedDetail: "文件已下载到指定位置",
},
destination,
);
if (result === "saved") {
downloadDialogVisible.value = false;
pendingDownload.value = null;
downloadTitle.value = "";
try {
await recordCollaborationDownload(studyId.value, fileId.value, extension);
} catch {
ElMessage.warning("文件已下载,但下载审计记录失败");
}
}
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "协作文件下载失败"));
} finally {
downloading.value = false;
}
};
const confirmDownload = () => {
void savePendingDownload();
};
const handleDownload = (payload: OnlyOfficeSaveAsPayload) => {
if (downloading.value || pendingDownload.value || !editorConfig.value?.can_download) return;
pendingDownload.value = payload;
downloadTitle.value = suggestedDownloadName(payload);
if (isTauriRuntime()) {
void savePendingDownload();
return;
}
downloadDialogVisible.value = true;
};
const handleDownloadError = (message: string) => {
ElMessage.error(message || "协作文件下载失败");
};
const handleServerChange = () => {
destroyEditor();
loading.value = false;
errorMessage.value = "服务器地址已切换,请重新加载协作文件";
status.value = "error";
};
const goBack = async () => {
await exitWorkspaceFullscreen();
if (workspaceTaskController?.closeTransientTask(route.path)) return;
await router.push("/knowledge/collaboration");
};
onMounted(() => {
mounted = true;
syncWebFullscreenState();
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
void loadWorkspace();
});
onActivated(() => {
syncWebFullscreenState();
if (mounted && !editorConfig.value && !loading.value && !errorMessage.value) void loadWorkspace();
});
onDeactivated(() => {
void exitWorkspaceFullscreen();
destroyEditor();
loading.value = false;
errorMessage.value = "";
});
onBeforeUnmount(() => {
cancelFullscreenToolbarHide();
webFullscreenUnlisten?.();
webFullscreenUnlisten = null;
void exitWorkspaceFullscreen();
destroyEditor();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
});
</script>
<style scoped>
.collaboration-workspace {
position: relative;
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #26384c;
background: #f3f5f8;
}
.collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }
.collaboration-workspace__fullscreen-hotzone {
position: absolute;
z-index: 20;
top: 0;
left: 0;
width: 88px;
height: 14px;
}
.collaboration-workspace__fullscreen-hotzone::after {
position: absolute;
top: 3px;
left: 32px;
width: 24px;
height: 3px;
border-radius: 999px;
background: rgba(38, 56, 76, 0.34);
content: "";
}
.collaboration-workspace__header {
z-index: 1;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto auto auto auto;
align-items: center;
gap: 8px;
padding: 0 12px 0 6px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.collaboration-workspace.is-macos-desktop:not(.is-fullscreen) .collaboration-workspace__header {
padding-left: 72px;
}
.collaboration-workspace.is-fullscreen .collaboration-workspace__header {
position: absolute;
z-index: 21;
top: 6px;
left: 6px;
display: flex;
width: auto;
height: 34px;
gap: 2px;
padding: 0 4px;
border: 1px solid #dce3eb;
border-radius: 11px;
background: #fff;
opacity: 0;
pointer-events: none;
transform: translateY(calc(-100% - 12px));
box-shadow: 0 8px 24px rgba(20, 32, 51, 0.16);
transition: opacity 150ms ease, transform 180ms ease;
}
.collaboration-workspace.is-fullscreen .collaboration-workspace__header > :not(.collaboration-workspace__back):not(.collaboration-workspace__fullscreen) {
display: none;
}
.collaboration-workspace.is-fullscreen.is-fullscreen-toolbar-visible .collaboration-workspace__header,
.collaboration-workspace.is-fullscreen .collaboration-workspace__header:has(:focus-visible) {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.collaboration-workspace__back {
width: 28px;
height: 28px;
padding: 0 0 3px;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
font: 26px/1 system-ui, sans-serif;
cursor: pointer;
}
.collaboration-workspace__back:hover { background: #edf2f7; }
.collaboration-workspace__title { overflow: hidden; font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.collaboration-workspace__access,
.collaboration-workspace__status { font-size: 12px; white-space: nowrap; }
.collaboration-workspace__access { padding: 3px 8px; border-radius: 10px; color: #52667a; background: #eef2f6; }
.collaboration-workspace__access.is-edit { color: #237451; background: #e7f5ee; }
.collaboration-workspace__status { color: #738398; }
.collaboration-workspace__status.is-saving { color: #99651e; }
.collaboration-workspace__fullscreen {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
cursor: pointer;
font-size: 16px;
}
.collaboration-workspace__fullscreen:hover { color: #237451; background: #edf5f1; }
.collaboration-workspace__fullscreen:focus-visible { outline: 2px solid rgba(35, 116, 81, 0.3); outline-offset: 1px; }
.collaboration-workspace__status.is-saved,
.collaboration-workspace__status.is-ready { color: #25845b; }
.collaboration-workspace__status.is-warning,
.collaboration-workspace__status.is-error { color: #b65c29; }
.collaboration-workspace__content { min-height: 0; overflow: hidden; }
.collaboration-workspace.is-fullscreen .collaboration-workspace__content { grid-row: 1; }
.collaboration-workspace__state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; width: 100%; height: 100%; color: #718096; text-align: center; }
.collaboration-workspace__state strong { color: #2f4054; font-size: 17px; }
.collaboration-workspace__state p { margin: 0; }
</style>
+1 -1
View File
@@ -51,7 +51,7 @@ http {
server {
listen 80;
server_name _;
client_max_body_size 20m;
client_max_body_size 50m;
location = /favicon.ico {
root /usr/share/nginx/html;
+1 -1
View File
@@ -54,7 +54,7 @@ http {
server {
listen 80;
server_name _;
client_max_body_size 20m;
client_max_body_size 50m;
location = /favicon.ico {
root /usr/share/nginx/html;
+8
View File
@@ -8,3 +8,11 @@ USER root
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
# CTMS defines "Save As" as creating a workspace copy. Keep the upstream
# request-save-as event, but use the product terminology in every Chinese editor.
RUN locale_root=/var/www/onlyoffice/documentserver/web-apps/apps \
&& matches="$(grep -RIl '另存副本为' "$locale_root"/*/main/locale/zh.json)" \
&& test -n "$matches" \
&& sed -i 's/另存副本为/另存为/g' $matches \
&& ! grep -RIl '另存副本为' "$locale_root"/*/main/locale/zh.json
+7
View File
@@ -101,6 +101,13 @@ paths = {route.path for route in app.routes}
required = {
"/api/v1/onlyoffice/attachments/{attachment_id}/config",
"/api/v1/onlyoffice/versions/{version_id}/config",
"/api/v1/studies/{study_id}/collaboration/files/{file_id}/editor-config",
"/api/v1/studies/{study_id}/collaboration/files/{file_id}/share-link",
"/api/v1/collaboration/shares/metadata",
"/api/v1/collaboration/shares/access",
"/api/v1/collaboration/shares/editor-config",
"/internal/onlyoffice/collaboration/sessions/{session_id}/content",
"/internal/onlyoffice/collaboration/sessions/{session_id}/callback",
}
if not settings.ONLYOFFICE_ENABLED:
raise SystemExit("ONLYOFFICE_ENABLED 未生效")