feat(collaboration): 完善在线文档协作与通知闭环
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
@@ -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,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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// 传递给 iframe。JSON 配置本身只包含 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"');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 目录归档项目级与中心级文件",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("当前浏览器不支持页面全屏");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 未生效")
|
||||
|
||||
Reference in New Issue
Block a user