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
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Response, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.deps import get_db_session, get_current_user, require_study_member
|
from app.core.deps import get_db_session, get_current_user, require_study_member
|
||||||
from app.schemas.notification import NotificationItem
|
from app.schemas.notification import GeneralNotificationFeed, GeneralNotificationRead, NotificationItem
|
||||||
from app.services import document_service
|
from app.services import document_service, notification_service, project_reminder_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -29,3 +31,60 @@ async def list_notifications(
|
|||||||
skip=skip,
|
skip=skip,
|
||||||
limit=limit,
|
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 audit as audit_crud
|
||||||
from app.crud import document as document_crud
|
from app.crud import document as document_crud
|
||||||
from app.crud import document_version as version_crud
|
from app.crud import document_version as version_crud
|
||||||
|
from app.models.collaboration import CollaborationRevision
|
||||||
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||||
from app.services import document_service, onlyoffice_service
|
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",
|
media_type=version.mime_type or "application/octet-stream",
|
||||||
headers={"Content-Disposition": _content_disposition(file_name)},
|
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 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()
|
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(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(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(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(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
|
||||||
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
||||||
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
|
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
|
||||||
|
|||||||
@@ -643,6 +643,43 @@ API_ENDPOINT_PERMISSIONS = {
|
|||||||
"description": "删除文档",
|
"description": "删除文档",
|
||||||
"default_roles": ["PM"],
|
"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]:
|
def _operation_read_candidates(operation_key: str) -> list[str]:
|
||||||
@@ -843,6 +880,7 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
|||||||
"precautions:read",
|
"precautions:read",
|
||||||
"faq:read",
|
"faq:read",
|
||||||
"faq_category:read",
|
"faq_category:read",
|
||||||
|
"collaboration:read",
|
||||||
],
|
],
|
||||||
"write": [
|
"write": [
|
||||||
"precautions:create",
|
"precautions:create",
|
||||||
@@ -858,6 +896,11 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
|||||||
"faq_reply:create",
|
"faq_reply:create",
|
||||||
"faq_reply:delete",
|
"faq_reply:delete",
|
||||||
"faq_attachments: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_STORAGE_BASE_URL: str = "http://backend:8000"
|
||||||
ONLYOFFICE_INSTANCE_ID: Optional[str] = None
|
ONLYOFFICE_INSTANCE_ID: Optional[str] = None
|
||||||
ONLYOFFICE_CONFIG_TTL_SECONDS: int = Field(default=300, ge=60, le=900)
|
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
|
@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.etmf import EtmfNode # noqa: F401
|
||||||
from app.models.document import Document # noqa: F401
|
from app.models.document import Document # noqa: F401
|
||||||
from app.models.document_version import DocumentVersion # 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.distribution import Distribution # noqa: F401
|
||||||
from app.models.acknowledgement import Acknowledgement # noqa: F401
|
from app.models.acknowledgement import Acknowledgement # noqa: F401
|
||||||
from app.models.milestone import Milestone # noqa: F401
|
from app.models.milestone import Milestone # noqa: F401
|
||||||
@@ -48,4 +58,5 @@ from app.models.desktop_notification import ( # noqa: F401
|
|||||||
DesktopNotificationDelivery,
|
DesktopNotificationDelivery,
|
||||||
DesktopNotificationSubscription,
|
DesktopNotificationSubscription,
|
||||||
)
|
)
|
||||||
|
from app.models.notification import Notification # noqa: F401
|
||||||
from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # 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.router import api_router
|
||||||
from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_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.config import get_cors_allowed_origins, settings, validate_onlyoffice_configuration
|
||||||
from app.core.exceptions import register_exception_handlers
|
from app.core.exceptions import register_exception_handlers
|
||||||
from app.core.login_crypto import validate_login_crypto_configuration
|
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(api_router, prefix="/api/v1")
|
||||||
app.include_router(onlyoffice_internal_router)
|
app.include_router(onlyoffice_internal_router)
|
||||||
|
app.include_router(collaboration_internal_router)
|
||||||
return app
|
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):
|
class DesktopNotificationAckRequest(BaseModel):
|
||||||
claim_token: uuid.UUID
|
claim_token: uuid.UUID
|
||||||
delivered_ids: list[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):
|
class OnlyOfficePreviewConfigRead(BaseModel):
|
||||||
resource_type: Literal["attachment", "version"]
|
resource_type: Literal["attachment", "version", "collaboration_revision"]
|
||||||
resource_id: uuid.UUID
|
resource_id: uuid.UUID
|
||||||
file_name: str
|
file_name: str
|
||||||
host_path: str = "/onlyoffice-host.html"
|
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
|
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||||
|
|
||||||
OnlyOfficeDocumentType = Literal["word", "cell", "slide"]
|
OnlyOfficeDocumentType = Literal["word", "cell", "slide"]
|
||||||
OnlyOfficeResourceType = Literal["attachment", "version"]
|
OnlyOfficeResourceType = Literal["attachment", "version", "collaboration_revision"]
|
||||||
|
|
||||||
WORD_FORMATS = frozenset({
|
WORD_FORMATS = frozenset({
|
||||||
"doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt",
|
"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:
|
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 (
|
return (
|
||||||
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
||||||
f"/internal/onlyoffice/{plural}/{resource_id}/content"
|
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()
|
token = credential.strip()
|
||||||
try:
|
try:
|
||||||
header = jwt.get_unverified_header(token)
|
payload = decode_onlyoffice_token(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},
|
|
||||||
)
|
|
||||||
except JWTError as exc:
|
except JWTError as exc:
|
||||||
raise onlyoffice_error(
|
raise onlyoffice_error(
|
||||||
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
|
"ONLYOFFICE_SOURCE_UNAUTHORIZED",
|
||||||
@@ -225,3 +221,15 @@ def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any
|
|||||||
status.HTTP_401_UNAUTHORIZED,
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
)
|
)
|
||||||
return payload
|
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"
|
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():
|
def test_precautions_runtime_code_uses_business_entity_names():
|
||||||
"""运行时代码不应继续使用 knowledge_note 作为注意事项实体命名。"""
|
"""运行时代码不应继续使用 knowledge_note 作为注意事项实体命名。"""
|
||||||
backend_root = Path(__file__).resolve().parents[1] / "app"
|
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_STORAGE_BASE_URL: ${ONLYOFFICE_STORAGE_BASE_URL:-http://backend:8000}
|
||||||
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:-}
|
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:-}
|
||||||
ONLYOFFICE_CONFIG_TTL_SECONDS: ${ONLYOFFICE_CONFIG_TTL_SECONDS:-300}
|
ONLYOFFICE_CONFIG_TTL_SECONDS: ${ONLYOFFICE_CONFIG_TTL_SECONDS:-300}
|
||||||
|
COLLABORATION_MAX_FILE_BYTES: ${COLLABORATION_MAX_FILE_BYTES:-52428800}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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",
|
DOCUMENT_READY: "ctms.onlyoffice.document-ready",
|
||||||
WARNING: "ctms.onlyoffice.warning",
|
WARNING: "ctms.onlyoffice.warning",
|
||||||
ERROR: "ctms.onlyoffice.error",
|
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 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([
|
const ALLOWED_TAURI_ORIGINS = new Set([
|
||||||
"tauri://localhost",
|
"tauri://localhost",
|
||||||
"http://tauri.localhost",
|
"http://tauri.localhost",
|
||||||
@@ -21,6 +28,7 @@
|
|||||||
let apiScriptPromise = null;
|
let apiScriptPromise = null;
|
||||||
let readyTimer = null;
|
let readyTimer = null;
|
||||||
let readyDeadlineTimer = null;
|
let readyDeadlineTimer = null;
|
||||||
|
let documentTitle = "download";
|
||||||
|
|
||||||
const isLoopbackOrigin = (origin) => {
|
const isLoopbackOrigin = (origin) => {
|
||||||
try {
|
try {
|
||||||
@@ -34,9 +42,9 @@
|
|||||||
const isAllowedParentOrigin = (origin) =>
|
const isAllowedParentOrigin = (origin) =>
|
||||||
origin === window.location.origin || ALLOWED_TAURI_ORIGINS.has(origin) || isLoopbackOrigin(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;
|
if (!parentOrigin || !requestNonce) return;
|
||||||
window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin);
|
window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin, transfer);
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeEventDetail = (event) => {
|
const safeEventDetail = (event) => {
|
||||||
@@ -67,6 +75,83 @@
|
|||||||
return apiScriptPromise;
|
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 = () => {
|
const destroyEditor = () => {
|
||||||
if (editor && typeof editor.destroyEditor === "function") {
|
if (editor && typeof editor.destroyEditor === "function") {
|
||||||
try {
|
try {
|
||||||
@@ -95,14 +180,24 @@
|
|||||||
throw new Error("预览配置格式不正确");
|
throw new Error("预览配置格式不正确");
|
||||||
}
|
}
|
||||||
requestNonce = message.nonce;
|
requestNonce = message.nonce;
|
||||||
|
documentTitle = typeof config.document?.title === "string" && config.document.title.trim()
|
||||||
|
? config.document.title.trim().slice(0, 240)
|
||||||
|
: "download";
|
||||||
await loadOnlyOfficeApi();
|
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 = {
|
const editorConfig = {
|
||||||
...config,
|
...config,
|
||||||
events: {
|
events,
|
||||||
onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY),
|
|
||||||
onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)),
|
|
||||||
onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
editor = new window.DocsAPI.DocEditor("onlyoffice-editor", editorConfig);
|
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("!isAllowedParentOrigin(event.origin)"), "ONLYOFFICE host must validate the parent origin.");
|
||||||
assert(hostSource.includes("initialized ||"), "ONLYOFFICE host must only accept one initialization.");
|
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("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(!/\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("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.");
|
assert(apiSource.includes("disableRequestDedupe: true"), "ONLYOFFICE signed config requests must not be deduplicated.");
|
||||||
@@ -290,22 +295,31 @@ const verifyRustBoundary = async () => {
|
|||||||
"updates::desktop_update_install",
|
"updates::desktop_update_install",
|
||||||
"desktop_menu_set_shortcuts",
|
"desktop_menu_set_shortcuts",
|
||||||
"desktop_window_set_theme",
|
"desktop_window_set_theme",
|
||||||
|
"desktop_window_get_fullscreen",
|
||||||
|
"desktop_window_set_fullscreen",
|
||||||
];
|
];
|
||||||
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
|
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
|
||||||
const missing = allowedCommands.filter((command) => !commands.includes(command));
|
const missing = allowedCommands.filter((command) => !commands.includes(command));
|
||||||
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
|
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
|
||||||
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.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 verifyDesktopUiNativeSync = async () => {
|
||||||
const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8");
|
const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8");
|
||||||
const preferencesSource = await readFile(resolve(sourceDir, "runtime/desktopUiPreferences.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");
|
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('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(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('"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(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(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("initializeDesktopThemePreference()"), "Desktop theme synchronization must initialize at app startup.");
|
||||||
assert(appSource.includes("initializeDesktopMenuShortcutSync()"), "Native menu shortcut 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 violations = [];
|
||||||
const toPosixPath = (path) => path.split("\\").join("/");
|
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 walk = async (directory) => {
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
return (
|
return (
|
||||||
@@ -28,7 +36,7 @@ for (const path of await walk(sourceDir)) {
|
|||||||
|
|
||||||
const source = await readFile(path, "utf8");
|
const source = await readFile(path, "utf8");
|
||||||
const file = toPosixPath(relative(frontendDir, path));
|
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`);
|
violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`);
|
||||||
}
|
}
|
||||||
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
|
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use serde::Deserialize;
|
|||||||
use tauri::menu::{
|
use tauri::menu::{
|
||||||
AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
|
AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "macos")]
|
use tauri::{
|
||||||
use tauri::RunEvent;
|
Emitter, Manager, RunEvent, Runtime, Theme, WebviewWindow, WebviewWindowBuilder, WindowEvent,
|
||||||
use tauri::{Emitter, Manager, Runtime, Theme, WebviewWindow, WebviewWindowBuilder};
|
};
|
||||||
|
|
||||||
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
|
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
|
||||||
const VIEW_MENU_ID: &str = "ctms.menu.view";
|
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 TOGGLE_SIDEBAR_COMMAND_ID: &str = "ctms.desktop.toggleSidebar";
|
||||||
const BACK_COMMAND_ID: &str = "ctms.desktop.back";
|
const BACK_COMMAND_ID: &str = "ctms.desktop.back";
|
||||||
const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward";
|
const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward";
|
||||||
|
const DESKTOP_FULLSCREEN_CHANGED_EVENT: &str = "ctms:desktop-fullscreen-changed";
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -343,6 +344,25 @@ fn desktop_window_set_theme<R: Runtime>(
|
|||||||
.map_err(|error| format!("更新桌面窗口主题失败:{error}"))
|
.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() {
|
pub fn run() {
|
||||||
let app = tauri::Builder::default()
|
let app = tauri::Builder::default()
|
||||||
.menu(desktop_menu)
|
.menu(desktop_menu)
|
||||||
@@ -372,19 +392,33 @@ pub fn run() {
|
|||||||
updates::desktop_update_install,
|
updates::desktop_update_install,
|
||||||
desktop_menu_set_shortcuts,
|
desktop_menu_set_shortcuts,
|
||||||
desktop_window_set_theme,
|
desktop_window_set_theme,
|
||||||
|
desktop_window_get_fullscreen,
|
||||||
|
desktop_window_set_fullscreen,
|
||||||
])
|
])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building CTMS desktop application");
|
.expect("error while building CTMS desktop application");
|
||||||
|
|
||||||
app.run(|app, event| {
|
app.run(|app, event| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if let RunEvent::Reopen { .. } = event {
|
if let RunEvent::Reopen { .. } = &event {
|
||||||
if let Err(error) = restore_main_window(app) {
|
if let Err(error) = restore_main_window(app) {
|
||||||
eprintln!("{error}");
|
eprintln!("{error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "macos"))]
|
if let RunEvent::WindowEvent {
|
||||||
let _ = (app, event);
|
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);
|
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 () => {
|
it("keeps request headers in the dedupe key when they can affect the response", async () => {
|
||||||
const { apiGet } = await import("./axios");
|
const { apiGet } = await import("./axios");
|
||||||
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
|
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;
|
const API_RESPONSE_CACHE_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
export type ApiRequestConfig = AxiosRequestConfig & {
|
export type ApiRequestConfig = AxiosRequestConfig & {
|
||||||
|
publicRequest?: boolean;
|
||||||
suppressErrorMessage?: boolean;
|
suppressErrorMessage?: boolean;
|
||||||
_retry?: boolean;
|
_retry?: boolean;
|
||||||
_networkRetryCount?: number;
|
_networkRetryCount?: number;
|
||||||
@@ -225,7 +226,7 @@ instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiReque
|
|||||||
config.headers = config.headers || {};
|
config.headers = config.headers || {};
|
||||||
Object.assign(config.headers, getAppMetadataHeaders());
|
Object.assign(config.headers, getAppMetadataHeaders());
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) {
|
if (token && !config.publicRequest) {
|
||||||
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
@@ -237,12 +238,13 @@ instance.interceptors.response.use(
|
|||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const data = error.response?.data;
|
const data = error.response?.data;
|
||||||
const reqUrl = error.config?.url || "";
|
const reqUrl = error.config?.url || "";
|
||||||
|
const isPublicRequest = Boolean((error.config as ApiRequestConfig | undefined)?.publicRequest);
|
||||||
const isAuthEndpoint =
|
const isAuthEndpoint =
|
||||||
reqUrl.includes("/api/v1/auth/login") ||
|
reqUrl.includes("/api/v1/auth/login") ||
|
||||||
reqUrl.includes("/api/v1/auth/register") ||
|
reqUrl.includes("/api/v1/auth/register") ||
|
||||||
reqUrl.includes("/api/v1/auth/password-reset") ||
|
reqUrl.includes("/api/v1/auth/password-reset") ||
|
||||||
reqUrl.includes("/api/v1/auth/extend");
|
reqUrl.includes("/api/v1/auth/extend");
|
||||||
if (isAuthEndpoint) {
|
if (isAuthEndpoint || isPublicRequest) {
|
||||||
// 认证相关的错误由具体页面自行处理,避免重复提示
|
// 认证相关的错误由具体页面自行处理,避免重复提示
|
||||||
return Promise.reject(error);
|
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 { apiGet, apiPost } from "./axios";
|
||||||
import type { NotificationItem } from "../types/notifications";
|
import type { GeneralNotificationFeed, GeneralNotificationItem, NotificationItem } from "../types/notifications";
|
||||||
|
|
||||||
export const listNotifications = (studyId: string, params?: Record<string, any>) =>
|
export const listNotifications = (studyId: string, params?: Record<string, any>) =>
|
||||||
apiGet<NotificationItem[]>(`/api/v1/studies/${studyId}/notifications`, { params });
|
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) =>
|
export const fetchVersionOnlyOfficeConfig = (versionId: string) =>
|
||||||
apiGet<OnlyOfficePreviewConfig>(`/api/v1/onlyoffice/versions/${versionId}/config`, noStoreConfig);
|
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_category: "医学咨询/FAQ",
|
||||||
faq_reply: "医学咨询/FAQ",
|
faq_reply: "医学咨询/FAQ",
|
||||||
faq_attachments: "医学咨询/FAQ",
|
faq_attachments: "医学咨询/FAQ",
|
||||||
|
collaboration: "在线协作",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
|
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
|
||||||
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
|
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
|
||||||
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
|
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
|
||||||
shared_library: ["医学咨询/FAQ", "注意事项"],
|
shared_library: ["医学咨询/FAQ", "注意事项", "在线协作"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOperationSection = (row: Operation): string => {
|
const getOperationSection = (row: Operation): string => {
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'is-sidebar-hidden': !desktopSidebarVisible,
|
'is-sidebar-hidden': !desktopSidebarVisible,
|
||||||
'is-macos': desktopMetadata.platform === 'macos',
|
'is-macos': desktopMetadata.platform === 'macos',
|
||||||
|
'is-immersive-workspace': route.meta.immersiveWorkspace,
|
||||||
}"
|
}"
|
||||||
@click="closeWorkspaceTabMenu"
|
@click="closeWorkspaceTabMenu"
|
||||||
>
|
>
|
||||||
<aside class="desktop-sidebar">
|
<aside v-if="!route.meta.immersiveWorkspace" class="desktop-sidebar">
|
||||||
<header class="sidebar-head">
|
<header class="sidebar-head">
|
||||||
<div class="sidebar-title-row">
|
<div class="sidebar-title-row">
|
||||||
<div class="sidebar-app-brand">
|
<div class="sidebar-app-brand">
|
||||||
@@ -122,7 +123,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="desktop-main">
|
<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>
|
<div class="toolbar-left" data-tauri-drag-region>
|
||||||
<button
|
<button
|
||||||
class="toolbar-nav-button sidebar-toggle-button"
|
class="toolbar-nav-button sidebar-toggle-button"
|
||||||
@@ -254,7 +255,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-popover>
|
</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="项目提醒">
|
<button class="icon-button reminder-button" type="button" title="项目提醒">
|
||||||
<el-badge v-if="headerReminderTotal > 0" :value="headerReminderBadgeValue" class="reminder-badge">
|
<el-badge v-if="headerReminderTotal > 0" :value="headerReminderBadgeValue" class="reminder-badge">
|
||||||
<el-icon><Bell /></el-icon>
|
<el-icon><Bell /></el-icon>
|
||||||
@@ -277,13 +283,14 @@
|
|||||||
:key="item.key"
|
:key="item.key"
|
||||||
:command="item.command"
|
:command="item.command"
|
||||||
class="reminder-item"
|
class="reminder-item"
|
||||||
|
:class="{ 'is-read': item.isRead }"
|
||||||
>
|
>
|
||||||
<span class="reminder-mark" :class="`is-${item.tone}`"></span>
|
<span class="reminder-mark" :class="`is-${item.tone}`"></span>
|
||||||
<span class="reminder-body">
|
<span class="reminder-body">
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
<small>{{ item.description }}</small>
|
<small>{{ item.description }}</small>
|
||||||
</span>
|
</span>
|
||||||
<strong>{{ item.count }}</strong>
|
<strong>{{ item.timeLabel }}</strong>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="reminder-empty">
|
<div v-else class="reminder-empty">
|
||||||
@@ -326,7 +333,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="workspaceTabs.length > 1" class="workspace-tabs">
|
<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">
|
||||||
<div
|
<div
|
||||||
class="workspace-tabs-list"
|
class="workspace-tabs-list"
|
||||||
:style="{ gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))` }"
|
:style="{ gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))` }"
|
||||||
@@ -532,9 +539,8 @@ import {
|
|||||||
import { ElMessageBox } from "element-plus";
|
import { ElMessageBox } from "element-plus";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { fetchOverdueAesCount } from "../api/dashboard";
|
|
||||||
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
|
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
|
import { useProjectNotifications } from "../composables/useProjectNotifications";
|
||||||
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
||||||
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
|
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
|
||||||
import {
|
import {
|
||||||
@@ -657,8 +663,17 @@ const workspaceTabMenu = ref<{
|
|||||||
});
|
});
|
||||||
const expandedNavigationGroups = ref<Set<string>>(new Set());
|
const expandedNavigationGroups = ref<Set<string>>(new Set());
|
||||||
const collapsedNavigationGroups = ref<Set<string>>(new Set());
|
const collapsedNavigationGroups = ref<Set<string>>(new Set());
|
||||||
const headerRemindersLoading = ref(false);
|
const {
|
||||||
const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
|
headerRemindersLoading,
|
||||||
|
headerReminderItems,
|
||||||
|
headerReminderTotal,
|
||||||
|
headerReminderBadgeValue,
|
||||||
|
loadHeaderReminders,
|
||||||
|
handleReminderCommand,
|
||||||
|
handleReminderVisibilityChange,
|
||||||
|
startHeaderNotificationPolling,
|
||||||
|
stopHeaderNotificationPolling,
|
||||||
|
} = useProjectNotifications();
|
||||||
let desktopMenuUnlisten: (() => void) | undefined;
|
let desktopMenuUnlisten: (() => void) | undefined;
|
||||||
let desktopUpdateStatusUnlisten: (() => void) | undefined;
|
let desktopUpdateStatusUnlisten: (() => void) | undefined;
|
||||||
let desktopActivityUnlisten: (() => void) | undefined;
|
let desktopActivityUnlisten: (() => void) | undefined;
|
||||||
@@ -802,8 +817,8 @@ const currentDesktopRoutePreference = computed(() => {
|
|||||||
if (!item && route.meta.transientWorkspaceTask) {
|
if (!item && route.meta.transientWorkspaceTask) {
|
||||||
return {
|
return {
|
||||||
path: route.path,
|
path: route.path,
|
||||||
title: currentWorkspaceContext.value?.pageTitle || String(route.meta.title || "文档预览"),
|
title: currentWorkspaceContext.value?.pageTitle || String(route.meta.title || "工作区任务"),
|
||||||
group: "文档预览",
|
group: String(route.meta.workspaceTaskGroup || "工作区任务"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
@@ -888,41 +903,6 @@ const activeWorkspaceTabHistory = computed(() => workspaceTabHistories.value[act
|
|||||||
const canNavigateWorkspaceTabBack = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1));
|
const canNavigateWorkspaceTabBack = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1));
|
||||||
const canNavigateWorkspaceTabForward = 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 refreshDesktopRoutePreferences = () => {
|
||||||
const allowedPaths = new Set(desktopNavigationItems.value.map((item) => item.path));
|
const allowedPaths = new Set(desktopNavigationItems.value.map((item) => item.path));
|
||||||
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) => allowedPaths.has(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 () => {
|
const logoutImmediately = async () => {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
study.clearCurrentStudy();
|
study.clearCurrentStudy();
|
||||||
@@ -1521,6 +1465,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadHeaderReminders();
|
loadHeaderReminders();
|
||||||
|
startHeaderNotificationPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -1529,6 +1474,7 @@ onBeforeUnmount(() => {
|
|||||||
desktopMenuUnlisten?.();
|
desktopMenuUnlisten?.();
|
||||||
desktopUpdateStatusUnlisten?.();
|
desktopUpdateStatusUnlisten?.();
|
||||||
desktopActivityUnlisten?.();
|
desktopActivityUnlisten?.();
|
||||||
|
stopHeaderNotificationPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
useDesktopShortcuts(
|
useDesktopShortcuts(
|
||||||
@@ -1575,6 +1521,18 @@ useDesktopShortcuts(
|
|||||||
grid-template-columns: 0 minmax(0, 1fr);
|
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 {
|
.desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {
|
||||||
padding-left: 88px;
|
padding-left: 88px;
|
||||||
}
|
}
|
||||||
@@ -1823,6 +1781,7 @@ useDesktopShortcuts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desktop-toolbar {
|
.desktop-toolbar {
|
||||||
|
grid-row: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2132,6 +2091,7 @@ useDesktopShortcuts(
|
|||||||
padding: 8px 7px !important;
|
padding: 8px 7px !important;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
:global(.reminder-item.is-read) { opacity: 0.62 !important; }
|
||||||
|
|
||||||
.reminder-mark {
|
.reminder-mark {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
@@ -2146,6 +2106,14 @@ useDesktopShortcuts(
|
|||||||
.reminder-mark.is-warning {
|
.reminder-mark.is-warning {
|
||||||
background: #c58b2a;
|
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 {
|
.reminder-body {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -2353,6 +2321,7 @@ useDesktopShortcuts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspace-tabs {
|
.workspace-tabs {
|
||||||
|
grid-row: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -2726,6 +2695,7 @@ useDesktopShortcuts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desktop-content {
|
.desktop-content {
|
||||||
|
grid-row: 3;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ describe("desktop layout shell", () => {
|
|||||||
it("keeps web project switching routed through the workbench entry", () => {
|
it("keeps web project switching routed through the workbench entry", () => {
|
||||||
const webLayout = readWebLayoutSource();
|
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('await router.push("/workbench")');
|
||||||
expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);");
|
expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);");
|
||||||
expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"');
|
expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"');
|
||||||
@@ -218,6 +219,18 @@ describe("desktop layout shell", () => {
|
|||||||
expect(layout).toContain("pointer-events: none;");
|
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", () => {
|
it("renders desktop preferences as a split settings panel", () => {
|
||||||
const preferences = readDesktopPreferencesSource();
|
const preferences = readDesktopPreferencesSource();
|
||||||
const desktopLayout = readDesktopLayoutSource();
|
const desktopLayout = readDesktopLayoutSource();
|
||||||
@@ -302,7 +315,9 @@ describe("desktop layout shell", () => {
|
|||||||
|
|
||||||
it("keeps workspace tabs stable and draggable like browser tabs", () => {
|
it("keeps workspace tabs stable and draggable like browser tabs", () => {
|
||||||
const source = readDesktopLayoutSource();
|
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 tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
|
||||||
const tabsTemplate = source.slice(tabsStart, tabsEnd);
|
const tabsTemplate = source.slice(tabsStart, tabsEnd);
|
||||||
|
|
||||||
@@ -378,6 +393,7 @@ describe("desktop layout shell", () => {
|
|||||||
const source = readDesktopLayoutSource();
|
const source = readDesktopLayoutSource();
|
||||||
|
|
||||||
expect(source).toContain("const workspaceTaskOrigins = ref<Record<string, string>>({});");
|
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("if (route.meta.transientWorkspaceTask && previousTabPath && previousTabPath !== current.path)");
|
||||||
expect(source).toContain("workspaceTabs.value.find((tab) => tab.path === originPath)");
|
expect(source).toContain("workspaceTabs.value.find((tab) => tab.path === originPath)");
|
||||||
expect(source).toContain("provide(workspaceTaskControllerKey, { closeTransientTask: closeTransientWorkspaceTask });");
|
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", () => {
|
it("shows the four most recently opened tasks with a searchable all-tasks menu after five tabs", () => {
|
||||||
const source = readDesktopLayoutSource();
|
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 tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
|
||||||
const tabsTemplate = source.slice(tabsStart, tabsEnd);
|
const tabsTemplate = source.slice(tabsStart, tabsEnd);
|
||||||
|
|
||||||
@@ -590,6 +608,9 @@ describe("desktop layout shell", () => {
|
|||||||
it("keeps routed desktop pages stretched to the content boundary", () => {
|
it("keeps routed desktop pages stretched to the content boundary", () => {
|
||||||
const layout = readDesktopLayoutSource();
|
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(".desktop-route-shell {");
|
||||||
expect(layout).toContain("width: 100%;");
|
expect(layout).toContain("width: 100%;");
|
||||||
expect(layout).toContain("max-width: 100%;");
|
expect(layout).toContain("max-width: 100%;");
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { mount } from "@vue/test-utils";
|
import { mount } from "@vue/test-utils";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { isProxy, reactive } from "vue";
|
import { isProxy, reactive } from "vue";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
vi.mock("../runtime", () => ({
|
vi.mock("../runtime", () => ({
|
||||||
resolveOnlyOfficeHostUrl: () => new URL("https://ctms.example/onlyoffice-host.html"),
|
resolveOnlyOfficeHostUrl: () => new URL("https://ctms.example/onlyoffice-host.html"),
|
||||||
@@ -102,10 +106,102 @@ describe("OnlyOfficeViewer bridge", () => {
|
|||||||
wrapper.unmount();
|
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", () => {
|
it("reports host and document timeouts without falling back to a download", () => {
|
||||||
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
|
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
|
||||||
vi.advanceTimersByTime(15_000);
|
vi.advanceTimersByTime(15_000);
|
||||||
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_LOAD_TIMEOUT" });
|
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_LOAD_TIMEOUT" });
|
||||||
wrapper.unmount();
|
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"
|
ref="frameRef"
|
||||||
class="onlyoffice-viewer__frame"
|
class="onlyoffice-viewer__frame"
|
||||||
:src="hostUrl.href"
|
:src="hostUrl.href"
|
||||||
title="ONLYOFFICE 文档查看器"
|
:title="frameTitle"
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
:sandbox="sandboxPolicy"
|
||||||
allow="clipboard-read 'none'; clipboard-write 'none'"
|
:allow="allowPolicy"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
@load="handleFrameLoad"
|
@load="handleFrameLoad"
|
||||||
/>
|
/>
|
||||||
@@ -15,27 +15,60 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref, toRaw } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, toRaw } from "vue";
|
||||||
import { resolveOnlyOfficeHostUrl } from "../runtime";
|
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 HOST_READY = "ctms.onlyoffice.host-ready";
|
||||||
const INIT = "ctms.onlyoffice.init";
|
const INIT = "ctms.onlyoffice.init";
|
||||||
const DOCUMENT_READY = "ctms.onlyoffice.document-ready";
|
const DOCUMENT_READY = "ctms.onlyoffice.document-ready";
|
||||||
const WARNING = "ctms.onlyoffice.warning";
|
const WARNING = "ctms.onlyoffice.warning";
|
||||||
const ERROR = "ctms.onlyoffice.error";
|
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 HOST_LOAD_TIMEOUT_MS = 15_000;
|
||||||
const DOCUMENT_LOAD_TIMEOUT_MS = 120_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<{
|
const emit = defineEmits<{
|
||||||
ready: [];
|
ready: [];
|
||||||
warning: [detail: Record<string, unknown>];
|
warning: [detail: Record<string, unknown>];
|
||||||
error: [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 frameRef = ref<HTMLIFrameElement | null>(null);
|
||||||
const hostUrl = resolveOnlyOfficeHostUrl();
|
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 = () => {
|
const createNonce = () => {
|
||||||
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
||||||
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, "0")).join("");
|
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
|
// Vue 会将接口响应递归包装为 Proxy,而 Proxy 不能通过 structured clone
|
||||||
// 传递给 iframe。JSON 配置本身只包含 ONLYOFFICE 支持的 JSON 类型。
|
// 传递给 iframe。JSON 配置本身只包含 ONLYOFFICE 支持的 JSON 类型。
|
||||||
const config = JSON.parse(JSON.stringify(toRaw(props.config))) as Record<string, unknown>;
|
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 {
|
} catch {
|
||||||
emitTimeout("HOST_INIT_FAILED", "预览配置初始化失败,请重新加载");
|
emitTimeout("HOST_INIT_FAILED", "预览配置初始化失败,请重新加载");
|
||||||
return;
|
return;
|
||||||
@@ -107,6 +146,36 @@ const handleMessage = (event: MessageEvent<OnlyOfficeHostMessage>) => {
|
|||||||
} else if (message.type === ERROR) {
|
} else if (message.type === ERROR) {
|
||||||
clearTimers();
|
clearTimers();
|
||||||
emit("error", message.detail || {});
|
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>
|
<template>
|
||||||
<el-container
|
<el-container
|
||||||
class="layout-container web-layout-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"
|
@keydown.esc="closeMobileNav"
|
||||||
>
|
>
|
||||||
<el-aside
|
<el-aside
|
||||||
|
v-if="!route.meta.immersiveWorkspace"
|
||||||
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
|
:width="isMobileViewport ? '280px' : (isCollapsed ? '0px' : '200px')"
|
||||||
class="layout-aside"
|
class="layout-aside"
|
||||||
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
|
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
<el-icon><Flag /></el-icon>
|
<el-icon><Flag /></el-icon>
|
||||||
<span>{{ TEXT.menu.etmf }}</span>
|
<span>{{ TEXT.menu.etmf }}</span>
|
||||||
</el-menu-item>
|
</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>
|
<template #title>
|
||||||
<el-icon><Notebook /></el-icon>
|
<el-icon><Notebook /></el-icon>
|
||||||
<span>{{ TEXT.menu.sharedLibrary }}</span>
|
<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/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/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/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-sub-menu>
|
||||||
</el-menu-item-group>
|
</el-menu-item-group>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
<el-container class="main-container">
|
<el-container class="main-container">
|
||||||
<el-header class="layout-header">
|
<el-header v-if="!route.meta.immersiveWorkspace" class="layout-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-button
|
<el-button
|
||||||
class="collapse-btn"
|
class="collapse-btn"
|
||||||
@@ -189,11 +191,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<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">
|
<button v-if="isDesktop" class="desktop-command-trigger" type="button" @click="openCommandPalette">
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
<span>搜索命令</span>
|
<span>搜索命令</span>
|
||||||
@@ -233,7 +230,25 @@
|
|||||||
</button>
|
</button>
|
||||||
</el-tooltip>
|
</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">
|
<button class="header-reminder-button" :aria-label="TEXT.common.labels.projectReminders" type="button">
|
||||||
<el-badge
|
<el-badge
|
||||||
v-if="headerReminderTotal > 0"
|
v-if="headerReminderTotal > 0"
|
||||||
@@ -261,27 +276,20 @@
|
|||||||
:key="item.key"
|
:key="item.key"
|
||||||
:command="item.command"
|
:command="item.command"
|
||||||
class="header-reminder-item"
|
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-mark" :class="`is-${item.tone}`"></span>
|
||||||
<span class="header-reminder-item-main">
|
<span class="header-reminder-item-main">
|
||||||
<span class="header-reminder-item-title">{{ item.title }}</span>
|
<span class="header-reminder-item-title">{{ item.title }}</span>
|
||||||
<span class="header-reminder-item-desc">{{ item.description }}</span>
|
<span class="header-reminder-item-desc">{{ item.description }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="header-reminder-item-count">{{ item.count }}</span>
|
<span class="header-reminder-item-count">{{ item.timeLabel }}</span>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="header-reminder-empty">
|
<div v-else class="header-reminder-empty">
|
||||||
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
|
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -344,7 +352,7 @@
|
|||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="isMobileNavOpen"
|
v-if="isMobileNavOpen && !route.meta.immersiveWorkspace"
|
||||||
type="button"
|
type="button"
|
||||||
class="mobile-nav-backdrop"
|
class="mobile-nav-backdrop"
|
||||||
aria-label="关闭导航菜单"
|
aria-label="关闭导航菜单"
|
||||||
@@ -396,15 +404,13 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { fetchStudies } from "../api/studies";
|
import { fetchStudies } from "../api/studies";
|
||||||
import { fetchOverdueAesCount } from "../api/dashboard";
|
|
||||||
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
|
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
import {
|
import {
|
||||||
User, Suitcase, House, Calendar, Flag,
|
User, Suitcase, House, Calendar, Flag,
|
||||||
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, UserFilled, Bell, Setting,
|
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";
|
} from "@element-plus/icons-vue";
|
||||||
import { ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||||||
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
|
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
|
||||||
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
||||||
@@ -415,9 +421,14 @@ import {
|
|||||||
getAppMetadata,
|
getAppMetadata,
|
||||||
getDesktopServerUrl,
|
getDesktopServerUrl,
|
||||||
isTauriRuntime,
|
isTauriRuntime,
|
||||||
|
isWebFullscreenActive,
|
||||||
|
isWebFullscreenAvailable,
|
||||||
|
listenWebFullscreenChange,
|
||||||
|
refreshWebFullscreenState,
|
||||||
listenDesktopMenuCommand,
|
listenDesktopMenuCommand,
|
||||||
readDesktopFavoriteRoutes,
|
readDesktopFavoriteRoutes,
|
||||||
toggleDesktopFavoriteRoute,
|
toggleDesktopFavoriteRoute,
|
||||||
|
toggleWebFullscreen,
|
||||||
type DesktopRoutePreference,
|
type DesktopRoutePreference,
|
||||||
} from "../runtime";
|
} from "../runtime";
|
||||||
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
|
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
|
||||||
@@ -428,6 +439,7 @@ import AccountConnectionStatus from "./AccountConnectionStatus.vue";
|
|||||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||||
import DesktopPreferences from "../views/DesktopPreferences.vue";
|
import DesktopPreferences from "../views/DesktopPreferences.vue";
|
||||||
import { getActiveLayoutPath } from "./layout/navigation";
|
import { getActiveLayoutPath } from "./layout/navigation";
|
||||||
|
import { useProjectNotifications } from "../composables/useProjectNotifications";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -459,14 +471,23 @@ const profileDialogVisible = ref(false);
|
|||||||
const profileDialogDirty = ref(false);
|
const profileDialogDirty = ref(false);
|
||||||
const commandPaletteVisible = ref(false);
|
const commandPaletteVisible = ref(false);
|
||||||
const desktopPreferencesVisible = ref(false);
|
const desktopPreferencesVisible = ref(false);
|
||||||
|
const webFullscreenAvailable = ref(false);
|
||||||
|
const webFullscreenActive = ref(false);
|
||||||
const desktopServerUrl = ref(getDesktopServerUrl());
|
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||||
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
|
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
|
||||||
const headerRemindersLoading = ref(false);
|
const {
|
||||||
const headerReminderStats = ref({
|
headerRemindersLoading,
|
||||||
overdueAes: 0,
|
headerReminderItems,
|
||||||
overdueMonitoringIssues: 0,
|
headerReminderTotal,
|
||||||
});
|
headerReminderBadgeValue,
|
||||||
|
loadHeaderReminders,
|
||||||
|
handleReminderCommand,
|
||||||
|
handleReminderVisibilityChange,
|
||||||
|
startHeaderNotificationPolling,
|
||||||
|
stopHeaderNotificationPolling,
|
||||||
|
} = useProjectNotifications();
|
||||||
let desktopMenuUnlisten: (() => void) | undefined;
|
let desktopMenuUnlisten: (() => void) | undefined;
|
||||||
|
let webFullscreenUnlisten: (() => void) | undefined;
|
||||||
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
|
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
|
||||||
const isAdminContext = computed(() => route.path.startsWith("/admin"));
|
const isAdminContext = computed(() => route.path.startsWith("/admin"));
|
||||||
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
|
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
|
||||||
@@ -492,76 +513,6 @@ const userDisplayInitial = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value);
|
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(() => {
|
const accountProjectRoleLabel = computed(() => {
|
||||||
if (!hasProjectContext.value || !study.currentStudy) return "";
|
if (!hasProjectContext.value || !study.currentStudy) return "";
|
||||||
const roleCode = (projectRole.value || "").toUpperCase();
|
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.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.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.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)));
|
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.knowledgeNotes, path: "/knowledge/precautions" },
|
||||||
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files" },
|
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files" },
|
||||||
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-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);
|
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, () => {
|
watch(() => route.path, () => {
|
||||||
closeMobileNav();
|
closeMobileNav();
|
||||||
study.setViewContext(null);
|
study.setViewContext(null);
|
||||||
@@ -987,6 +921,28 @@ const closeMobileNav = () => {
|
|||||||
isMobileNavOpen.value = false;
|
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) => {
|
const syncMobileViewport = (matches: boolean) => {
|
||||||
isMobileViewport.value = matches;
|
isMobileViewport.value = matches;
|
||||||
if (!matches) closeMobileNav();
|
if (!matches) closeMobileNav();
|
||||||
@@ -1036,6 +992,9 @@ const onLogout = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
syncWebFullscreenState();
|
||||||
|
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
|
||||||
|
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
|
||||||
if (typeof window.matchMedia === "function") {
|
if (typeof window.matchMedia === "function") {
|
||||||
mobileViewportMediaQuery = window.matchMedia("(max-width: 767px)");
|
mobileViewportMediaQuery = window.matchMedia("(max-width: 767px)");
|
||||||
syncMobileViewport(mobileViewportMediaQuery.matches);
|
syncMobileViewport(mobileViewportMediaQuery.matches);
|
||||||
@@ -1059,14 +1018,17 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
loadStudies();
|
loadStudies();
|
||||||
loadHeaderReminders();
|
loadHeaderReminders();
|
||||||
|
startHeaderNotificationPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
webFullscreenUnlisten?.();
|
||||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||||
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
|
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
|
||||||
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
|
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
|
||||||
}
|
}
|
||||||
desktopMenuUnlisten?.();
|
desktopMenuUnlisten?.();
|
||||||
|
stopHeaderNotificationPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
const onCommand = (cmd: string) => {
|
const onCommand = (cmd: string) => {
|
||||||
@@ -1572,26 +1534,6 @@ useDesktopShortcuts(
|
|||||||
flex-shrink: 0;
|
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 {
|
.desktop-command-trigger {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
grid-template-columns: auto minmax(0, auto) auto;
|
grid-template-columns: auto minmax(0, auto) auto;
|
||||||
@@ -1743,6 +1685,7 @@ useDesktopShortcuts(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-fullscreen-button,
|
||||||
.header-reminder-button {
|
.header-reminder-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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;
|
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 {
|
.header-reminder-button:hover {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
border-color: rgba(0, 0, 0, 0.14);
|
border-color: rgba(0, 0, 0, 0.14);
|
||||||
@@ -1765,6 +1709,7 @@ useDesktopShortcuts(
|
|||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-fullscreen-button .el-icon,
|
||||||
.header-reminder-button .el-icon {
|
.header-reminder-button .el-icon {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
@@ -1835,6 +1780,7 @@ useDesktopShortcuts(
|
|||||||
padding: 8px 7px !important;
|
padding: 8px 7px !important;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
:global(.header-reminder-item.is-read) { opacity: 0.62 !important; }
|
||||||
|
|
||||||
.header-reminder-item :deep(.el-dropdown-menu__item) {
|
.header-reminder-item :deep(.el-dropdown-menu__item) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1854,6 +1800,7 @@ useDesktopShortcuts(
|
|||||||
.header-reminder-item-mark.is-warning {
|
.header-reminder-item-mark.is-warning {
|
||||||
background: #f59e0b;
|
background: #f59e0b;
|
||||||
}
|
}
|
||||||
|
.header-reminder-item-mark.is-info { background: var(--ctms-primary); }
|
||||||
|
|
||||||
.header-reminder-item-main {
|
.header-reminder-item-main {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1881,9 +1828,10 @@ useDesktopShortcuts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-reminder-item-count {
|
.header-reminder-item-count {
|
||||||
color: #0f172a;
|
color: #7c8da3;
|
||||||
font-size: 16px;
|
font-size: 11px;
|
||||||
font-weight: 750;
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-reminder-empty {
|
.header-reminder-empty {
|
||||||
@@ -2111,17 +2059,6 @@ useDesktopShortcuts(
|
|||||||
gap: 3px;
|
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,
|
.header-account-role,
|
||||||
.user-profile .username,
|
.user-profile .username,
|
||||||
.user-profile .el-icon,
|
.user-profile .el-icon,
|
||||||
@@ -2161,9 +2098,9 @@ useDesktopShortcuts(
|
|||||||
max-width: 42vw;
|
max-width: 42vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-fullscreen-button,
|
||||||
.header-reminder-button,
|
.header-reminder-button,
|
||||||
.collapse-btn,
|
.collapse-btn,
|
||||||
.workbench-return-button,
|
|
||||||
.dropdown-trigger.compact {
|
.dropdown-trigger.compact {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 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 { 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 labels = (items: ReturnType<typeof buildAdminNavigationItems>) => items.map((item) => item.label);
|
||||||
|
const webLayoutSource = readFileSync(resolve(__dirname, "../WebLayout.vue"), "utf8");
|
||||||
|
|
||||||
describe("system management navigation", () => {
|
describe("system management navigation", () => {
|
||||||
it("exposes project management, audit logs, and permission management to PMs", () => {
|
it("exposes project management, audit logs, and permission management to PMs", () => {
|
||||||
@@ -26,3 +29,22 @@ describe("system management navigation", () => {
|
|||||||
expect(labels(items)).toEqual(["项目管理"]);
|
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/precautions")) return "/knowledge/precautions";
|
||||||
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
||||||
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-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("/projects/")) return "/admin/projects";
|
||||||
if (path.startsWith("/admin/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";
|
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",
|
icon: "notebook",
|
||||||
keywords: ["knowledge", "instruction"],
|
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: "入组",
|
plannedEnrollment: "入组",
|
||||||
dataUpdatedAt: "数据",
|
dataUpdatedAt: "数据",
|
||||||
projectReminders: "提醒",
|
projectReminders: "提醒",
|
||||||
projectRemindersSubtitle: "逾期与风险提示",
|
projectRemindersSubtitle: "业务通知与待办",
|
||||||
projectRemindersEmpty: "暂无逾期风险",
|
projectRemindersEmpty: "暂无未处理通知",
|
||||||
overdueAes: "逾期 AE",
|
overdueAes: "逾期 AE",
|
||||||
overdueAesDesc: "需关注安全性事件处理时效",
|
overdueAesDesc: "需关注安全性事件处理时效",
|
||||||
overdueMonitoringIssues: "逾期监查问题",
|
overdueMonitoringIssues: "逾期监查问题",
|
||||||
@@ -306,6 +306,7 @@ export const TEXT = {
|
|||||||
knowledgeNotes: "注意事项",
|
knowledgeNotes: "注意事项",
|
||||||
knowledgeSupportFiles: "支持性文件",
|
knowledgeSupportFiles: "支持性文件",
|
||||||
knowledgeInstructionFiles: "说明文件",
|
knowledgeInstructionFiles: "说明文件",
|
||||||
|
knowledgeCollaboration: "在线协作",
|
||||||
profile: "个人中心",
|
profile: "个人中心",
|
||||||
logout: "退出登录",
|
logout: "退出登录",
|
||||||
},
|
},
|
||||||
@@ -733,6 +734,17 @@ export const TEXT = {
|
|||||||
listTitle: "说明文件列表",
|
listTitle: "说明文件列表",
|
||||||
emptyDescription: "说明文件功能正在搭建基础能力,敬请期待。",
|
emptyDescription: "说明文件功能正在搭建基础能力,敬请期待。",
|
||||||
},
|
},
|
||||||
|
knowledgeCollaboration: {
|
||||||
|
title: "在线协作",
|
||||||
|
newFile: "新建协作文件",
|
||||||
|
importFile: "导入 Office 文件",
|
||||||
|
newFolder: "新建文件夹",
|
||||||
|
allFiles: "全部文件",
|
||||||
|
recycleBin: "回收站",
|
||||||
|
empty: "暂无协作文件",
|
||||||
|
loadFailed: "协作文件加载失败",
|
||||||
|
editorLoadFailed: "在线协作编辑器加载失败",
|
||||||
|
},
|
||||||
etmf: {
|
etmf: {
|
||||||
title: "eTMF",
|
title: "eTMF",
|
||||||
subtitle: "按 TMF 目录归档项目级与中心级文件",
|
subtitle: "按 TMF 目录归档项目级与中心级文件",
|
||||||
|
|||||||
@@ -12,11 +12,35 @@ describe("admin project route permissions", () => {
|
|||||||
expect(source).toContain('name: "OfficeAttachmentPreview"');
|
expect(source).toContain('name: "OfficeAttachmentPreview"');
|
||||||
expect(source).toContain('path: "office-preview/version/:id"');
|
expect(source).toContain('path: "office-preview/version/:id"');
|
||||||
expect(source).toContain('name: "OfficeVersionPreview"');
|
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(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(source.match(/transientWorkspaceTask: 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", () => {
|
it("lets any authorized project role open the project management detail page", () => {
|
||||||
const source = readRouter();
|
const source = readRouter();
|
||||||
|
|
||||||
@@ -138,7 +162,7 @@ describe("admin project route permissions", () => {
|
|||||||
|
|
||||||
it("validates restored session tokens through /me before entering protected routes", () => {
|
it("validates restored session tokens through /me before entering protected routes", () => {
|
||||||
const source = readRouter();
|
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 fetchMeIndex = source.indexOf("await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true })", restoreGuardIndex);
|
||||||
const preserveIndex = source.indexOf("shouldPreserveDesktopSessionOnAuthCheckFailure(error)", fetchMeIndex);
|
const preserveIndex = source.indexOf("shouldPreserveDesktopSessionOnAuthCheckFailure(error)", fetchMeIndex);
|
||||||
const restoreRedirectIndex = source.indexOf("next({ path: DESKTOP_SESSION_RESTORE_PATH", preserveIndex);
|
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 Precautions from "../views/ia/Precautions.vue";
|
||||||
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
|
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
|
||||||
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.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 FeasibilityForm from "../views/startup/FeasibilityForm.vue";
|
||||||
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
|
import FeasibilityDetail from "../views/startup/FeasibilityDetail.vue";
|
||||||
import EthicsForm from "../views/startup/EthicsForm.vue";
|
import EthicsForm from "../views/startup/EthicsForm.vue";
|
||||||
@@ -93,6 +96,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: ForgotPassword,
|
component: ForgotPassword,
|
||||||
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
|
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/collaboration/share",
|
||||||
|
name: "CollaborationPublicShare",
|
||||||
|
component: CollaborationShareWorkspace,
|
||||||
|
meta: { public: true, title: "共享协作文件" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/desktop/server-settings",
|
path: "/desktop/server-settings",
|
||||||
name: "DesktopServerSettings",
|
name: "DesktopServerSettings",
|
||||||
@@ -206,6 +215,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: OfficePreviewWorkspace,
|
component: OfficePreviewWorkspace,
|
||||||
meta: { title: "Office 文件预览", requiresStudy: true, fullBleed: true, transientWorkspaceTask: true },
|
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",
|
path: "startup/feasibility-ethics",
|
||||||
name: "StartupFeasibilityEthics",
|
name: "StartupFeasibilityEthics",
|
||||||
@@ -372,6 +387,25 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: KnowledgeInstructionFiles,
|
component: KnowledgeInstructionFiles,
|
||||||
meta: { title: TEXT.menu.knowledgeInstructionFiles, requiresStudy: true },
|
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;
|
const getToken = () => auth.token;
|
||||||
let token = getToken();
|
let token = getToken();
|
||||||
const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH;
|
const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH;
|
||||||
if (!auth.user && getToken() && !isDesktopSessionRestoreRoute) {
|
if (!to.meta.public && !auth.user && getToken() && !isDesktopSessionRestoreRoute) {
|
||||||
try {
|
try {
|
||||||
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
|
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { clientRuntime } from "./clientRuntime";
|
|||||||
import { getAppMetadata, getAppMetadataHeaders } from "./appMetadata";
|
import { getAppMetadata, getAppMetadataHeaders } from "./appMetadata";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
Reflect.deleteProperty(window, "isTauri");
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ describe("client runtime", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exposes only the implemented desktop capability", () => {
|
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().clientType).toBe("desktop");
|
||||||
expect(getAppMetadata().clientSource).toBe("ctms-desktop");
|
expect(getAppMetadata().clientSource).toBe("ctms-desktop");
|
||||||
@@ -44,7 +44,7 @@ describe("client runtime", () => {
|
|||||||
"X-CTMS-Client-Platform": "web",
|
"X-CTMS-Client-Platform": "web",
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
|
||||||
expect(getAppMetadataHeaders()).toMatchObject({
|
expect(getAppMetadataHeaders()).toMatchObject({
|
||||||
"X-CTMS-Client-Type": "desktop",
|
"X-CTMS-Client-Type": "desktop",
|
||||||
"X-CTMS-Client-Source": "ctms-desktop",
|
"X-CTMS-Client-Source": "ctms-desktop",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
invokeMock.mockReset();
|
invokeMock.mockReset();
|
||||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
Reflect.deleteProperty(window, "isTauri");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("desktop native menu", () => {
|
describe("desktop native menu", () => {
|
||||||
@@ -21,7 +21,7 @@ describe("desktop native menu", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("synchronizes validated shortcut preferences through the controlled command", async () => {
|
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);
|
await syncDesktopMenuShortcuts(DEFAULT_DESKTOP_SHORTCUTS);
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
|
|
||||||
const setTauriRuntime = (enabled: boolean) => {
|
const setTauriRuntime = (enabled: boolean) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
|
||||||
} else {
|
} else {
|
||||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
Reflect.deleteProperty(window, "isTauri");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("desktop UI preferences", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
Reflect.deleteProperty(window, "isTauri");
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ describe("desktop UI preferences", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("synchronizes the selected theme through the controlled desktop command", async () => {
|
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");
|
setDesktopThemePreference("system");
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,17 @@ describe("web file saving", () => {
|
|||||||
await expect(saveFile({ suggestedName: "protocol.pdf", data: "content" }, destination)).resolves.toBe("saved");
|
await expect(saveFile({ suggestedName: "protocol.pdf", data: "content" }, destination)).resolves.toBe("saved");
|
||||||
expect(click).toHaveBeenCalledOnce();
|
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";
|
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> => {
|
export const openFile = async (output: FileOutput): Promise<void> => {
|
||||||
if (!isTauriRuntime()) {
|
if (!isTauriRuntime()) {
|
||||||
const url = URL.createObjectURL(toBlob(output));
|
const url = URL.createObjectURL(toBlob(output));
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export {
|
|||||||
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
|
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
|
||||||
export {
|
export {
|
||||||
cleanupTemporaryFiles,
|
cleanupTemporaryFiles,
|
||||||
|
downloadFile,
|
||||||
openFile,
|
openFile,
|
||||||
pickFiles,
|
pickFiles,
|
||||||
prepareSaveFile,
|
prepareSaveFile,
|
||||||
@@ -79,6 +80,13 @@ export {
|
|||||||
type NotificationPermissionState,
|
type NotificationPermissionState,
|
||||||
} from "./notifications";
|
} from "./notifications";
|
||||||
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
||||||
|
export {
|
||||||
|
isWebFullscreenActive,
|
||||||
|
isWebFullscreenAvailable,
|
||||||
|
listenWebFullscreenChange,
|
||||||
|
refreshWebFullscreenState,
|
||||||
|
toggleWebFullscreen,
|
||||||
|
} from "./webFullscreen";
|
||||||
export { ONLYOFFICE_HOST_PATH, resolveOnlyOfficeHostUrl } from "./onlyoffice";
|
export { ONLYOFFICE_HOST_PATH, resolveOnlyOfficeHostUrl } from "./onlyoffice";
|
||||||
export {
|
export {
|
||||||
clearSessionToken,
|
clearSessionToken,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ vi.mock("@tauri-apps/plugin-notification", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const enableTauriRuntime = () => {
|
const enableTauriRuntime = () => {
|
||||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
Object.defineProperty(window, "isTauri", { value: true, configurable: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setBrowserNotificationPermission = (permission: NotificationPermission) => {
|
const setBrowserNotificationPermission = (permission: NotificationPermission) => {
|
||||||
@@ -27,7 +27,7 @@ const setBrowserNotificationPermission = (permission: NotificationPermission) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
Reflect.deleteProperty(window, "isTauri");
|
||||||
Reflect.deleteProperty(window, "Notification");
|
Reflect.deleteProperty(window, "Notification");
|
||||||
vi.clearAllMocks();
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__TAURI__?: unknown;
|
isTauri?: boolean;
|
||||||
__TAURI_INTERNALS__?: unknown;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isTauriRuntime = (): boolean =>
|
type TauriRuntimeGlobal = typeof globalThis & {
|
||||||
typeof window !== "undefined" && Boolean(window.__TAURI__ || window.__TAURI_INTERNALS__);
|
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 => {
|
export const getRuntimePlatform = (): RuntimePlatform => {
|
||||||
if (!isTauriRuntime() || typeof navigator === "undefined") return "web";
|
if (!isTauriRuntime() || typeof navigator === "undefined") return "web";
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("saved login credentials", () => {
|
|||||||
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
|
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
invokeMock.mockReset();
|
invokeMock.mockReset();
|
||||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
Reflect.deleteProperty(window, "isTauri");
|
||||||
Reflect.deleteProperty(window, "PasswordCredential");
|
Reflect.deleteProperty(window, "PasswordCredential");
|
||||||
Reflect.deleteProperty(navigator, "credentials");
|
Reflect.deleteProperty(navigator, "credentials");
|
||||||
});
|
});
|
||||||
@@ -45,13 +45,13 @@ describe("saved login credentials", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
Reflect.deleteProperty(window, "isTauri");
|
||||||
Reflect.deleteProperty(window, "PasswordCredential");
|
Reflect.deleteProperty(window, "PasswordCredential");
|
||||||
Reflect.deleteProperty(navigator, "credentials");
|
Reflect.deleteProperty(navigator, "credentials");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores desktop remembered passwords in the system credential store", async () => {
|
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);
|
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
|
||||||
let stored = "";
|
let stored = "";
|
||||||
invokeMock.mockImplementation(async (command: string, args: any) => {
|
invokeMock.mockImplementation(async (command: string, args: any) => {
|
||||||
@@ -88,7 +88,7 @@ describe("saved login credentials", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("deletes malformed desktop remembered credentials", async () => {
|
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);
|
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
|
||||||
invokeMock.mockImplementation(async (command: string) => {
|
invokeMock.mockImplementation(async (command: string) => {
|
||||||
if (command === "login_credential_get") return JSON.stringify({ password: "missing-email" });
|
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 });
|
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
|
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.mockReset();
|
||||||
invokeMock.mockResolvedValue(undefined);
|
invokeMock.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
@@ -56,7 +56,7 @@ describe("secure session storage", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
resetSecureSessionStorageForTests();
|
resetSecureSessionStorageForTests();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
Reflect.deleteProperty(window, "isTauri");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores desktop tokens as a 30 day secure session record", async () => {
|
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);
|
transition: var(--ctms-transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-button--primary {
|
.el-button--primary:not(.is-plain):not(.is-link):not(.is-text) {
|
||||||
background-color: var(--ctms-primary);
|
background-color: var(--ctms-primary);
|
||||||
border-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);
|
background-color: var(--ctms-primary-hover);
|
||||||
border-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;
|
delivered_at?: string | null;
|
||||||
read_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 {
|
export interface OnlyOfficePreviewConfig {
|
||||||
resource_type: OnlyOfficeResourceType;
|
resource_type: OnlyOfficeResourceType;
|
||||||
@@ -14,3 +14,10 @@ export interface OnlyOfficeHostMessage {
|
|||||||
nonce?: string;
|
nonce?: string;
|
||||||
detail?: Record<string, unknown>;
|
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";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const capabilitiesMock = vi.hoisted(() => vi.fn());
|
const capabilitiesMock = vi.hoisted(() => vi.fn());
|
||||||
|
const downloadFileMock = vi.hoisted(() => vi.fn());
|
||||||
const openFileMock = vi.hoisted(() => vi.fn());
|
const openFileMock = vi.hoisted(() => vi.fn());
|
||||||
const pickFilesMock = vi.hoisted(() => vi.fn());
|
const pickFilesMock = vi.hoisted(() => vi.fn());
|
||||||
const saveFileMock = vi.hoisted(() => vi.fn());
|
const saveFileMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -14,6 +15,7 @@ vi.mock("../runtime", () => ({
|
|||||||
clientRuntime: {
|
clientRuntime: {
|
||||||
capabilities: capabilitiesMock,
|
capabilities: capabilitiesMock,
|
||||||
},
|
},
|
||||||
|
downloadFile: downloadFileMock,
|
||||||
openFile: openFileMock,
|
openFile: openFileMock,
|
||||||
pickFiles: pickFilesMock,
|
pickFiles: pickFilesMock,
|
||||||
saveFile: saveFileMock,
|
saveFile: saveFileMock,
|
||||||
@@ -45,6 +47,7 @@ describe("file task feedback", () => {
|
|||||||
pickFilesMock.mockResolvedValue([]);
|
pickFilesMock.mockResolvedValue([]);
|
||||||
saveFileMock.mockResolvedValue("saved");
|
saveFileMock.mockResolvedValue("saved");
|
||||||
openFileMock.mockResolvedValue(undefined);
|
openFileMock.mockResolvedValue(undefined);
|
||||||
|
downloadFileMock.mockResolvedValue(undefined);
|
||||||
startActivityMock.mockReturnValue("activity-1");
|
startActivityMock.mockReturnValue("activity-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +83,32 @@ describe("file task feedback", () => {
|
|||||||
expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始");
|
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 () => {
|
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 destination = { kind: "web-file-handle", handle: { createWritable: vi.fn() } } as const;
|
||||||
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
|
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import {
|
import {
|
||||||
clientRuntime,
|
clientRuntime,
|
||||||
|
downloadFile,
|
||||||
openFile,
|
openFile,
|
||||||
pickFiles,
|
pickFiles,
|
||||||
saveFile,
|
saveFile,
|
||||||
@@ -28,6 +29,30 @@ const desktopFileActivitiesEnabled = () => clientRuntime.capabilities().nativeFi
|
|||||||
|
|
||||||
const defaultSaveTitle = (kind: FileSaveActivityKind) => (kind === "export" ? "导出文件" : "保存文件");
|
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[]> => {
|
export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Promise<File[]> => {
|
||||||
const files = await pickFiles(options);
|
const files = await pickFiles(options);
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ const PERMISSIONS: Record<string, string[]> = {
|
|||||||
"documents.create": ["ADMIN"],
|
"documents.create": ["ADMIN"],
|
||||||
"documents.update": ["ADMIN"],
|
"documents.update": ["ADMIN"],
|
||||||
"documents.delete": ["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> = {
|
const REASONS: Record<string, string> = {
|
||||||
@@ -94,6 +100,12 @@ const REASONS: Record<string, string> = {
|
|||||||
"documents.create": TEXT.modules.permissions.default,
|
"documents.create": TEXT.modules.permissions.default,
|
||||||
"documents.update": TEXT.modules.permissions.default,
|
"documents.update": TEXT.modules.permissions.default,
|
||||||
"documents.delete": 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 = () => {
|
export const usePermission = () => {
|
||||||
@@ -154,6 +166,12 @@ export const usePermission = () => {
|
|||||||
"documents.create": "documents:create",
|
"documents.create": "documents:create",
|
||||||
"documents.update": "documents:update",
|
"documents.update": "documents:update",
|
||||||
"documents.delete": "documents:delete",
|
"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",
|
"audit.export.read": "audit_logs:read",
|
||||||
};
|
};
|
||||||
const operationKey = permissionMap[action];
|
const operationKey = permissionMap[action];
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ describe("project route permissions", () => {
|
|||||||
expect(getProjectRoutePermission("/startup/training/new")).toBeNull();
|
expect(getProjectRoutePermission("/startup/training/new")).toBeNull();
|
||||||
expect(getProjectRoutePermission("/startup/training/abc/edit")).toBeNull();
|
expect(getProjectRoutePermission("/startup/training/abc/edit")).toBeNull();
|
||||||
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
|
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/new")).toEqual({ operationKey: "precautions:create" });
|
||||||
expect(getProjectRoutePermission("/knowledge/precautions/abc/edit")).toEqual({ operationKey: "precautions:update" });
|
expect(getProjectRoutePermission("/knowledge/precautions/abc/edit")).toEqual({ operationKey: "precautions:update" });
|
||||||
expect(getProjectRoutePermission("/knowledge/notes/new")).toBeNull();
|
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: ["/risk-issues"], permission: { operationKey: "subject_aes:read" } },
|
||||||
{ prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
|
{ prefixes: ["/etmf"], permission: { operationKey: "documents:read" } },
|
||||||
{ prefixes: ["/knowledge/medical-consult"], permission: { operationKey: "faq: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" } },
|
{ prefixes: ["/knowledge/precautions", "/knowledge/support-files", "/knowledge/instruction-files"], permission: { operationKey: "precautions:read" } },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export const projectRouteLandingPaths = [
|
|||||||
"/risk-issues/monitoring-visits",
|
"/risk-issues/monitoring-visits",
|
||||||
"/etmf",
|
"/etmf",
|
||||||
"/knowledge/medical-consult",
|
"/knowledge/medical-consult",
|
||||||
|
"/knowledge/collaboration",
|
||||||
"/knowledge/precautions",
|
"/knowledge/precautions",
|
||||||
"/knowledge/support-files",
|
"/knowledge/support-files",
|
||||||
"/knowledge/instruction-files",
|
"/knowledge/instruction-files",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ describe("OfficePreviewWorkspace contract", () => {
|
|||||||
expect(source).not.toContain("downloadDocumentVersion");
|
expect(source).not.toContain("downloadDocumentVersion");
|
||||||
expect(source).not.toContain("downloadAttachment");
|
expect(source).not.toContain("downloadAttachment");
|
||||||
expect(source).not.toContain("<iframe");
|
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", () => {
|
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 { useRoute, useRouter } from "vue-router";
|
||||||
import { Loading } from "@element-plus/icons-vue";
|
import { Loading } from "@element-plus/icons-vue";
|
||||||
import OnlyOfficeViewer from "../components/OnlyOfficeViewer.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 { useStudyStore } from "../store/study";
|
||||||
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
|
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
|
||||||
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
|
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
|
||||||
@@ -61,10 +65,13 @@ const requestSequence = ref(0);
|
|||||||
const viewerSequence = ref(0);
|
const viewerSequence = ref(0);
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
const resourceType = computed<OnlyOfficeResourceType>(() =>
|
const resourceType = computed<OnlyOfficeResourceType>(() => {
|
||||||
route.name === "OfficeAttachmentPreview" ? "attachment" : "version",
|
if (route.name === "OfficeAttachmentPreview") return "attachment";
|
||||||
);
|
if (route.name === "OfficeCollaborationRevisionPreview") return "collaboration_revision";
|
||||||
|
return "version";
|
||||||
|
});
|
||||||
const resourceId = computed(() => String(route.params.id || ""));
|
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 displayFileName = computed(() => previewConfig.value?.file_name || String(route.meta.title || "Office 文件预览"));
|
||||||
const viewerKey = computed(() => `${resourceType.value}:${resourceId.value}:${viewerSequence.value}`);
|
const viewerKey = computed(() => `${resourceType.value}:${resourceId.value}:${viewerSequence.value}`);
|
||||||
const backActionLabel = computed(() => workspaceTaskController ? "关闭预览并返回" : "返回");
|
const backActionLabel = computed(() => workspaceTaskController ? "关闭预览并返回" : "返回");
|
||||||
@@ -104,7 +111,13 @@ const loadConfig = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = resourceType.value === "attachment"
|
const response = resourceType.value === "attachment"
|
||||||
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
|
? 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 (sequence !== requestSequence.value) return;
|
||||||
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) {
|
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) {
|
||||||
throw new Error("Office 预览宿主页配置不合法");
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
client_max_body_size 20m;
|
client_max_body_size 50m;
|
||||||
|
|
||||||
location = /favicon.ico {
|
location = /favicon.ico {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ http {
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
client_max_body_size 20m;
|
client_max_body_size 50m;
|
||||||
|
|
||||||
location = /favicon.ico {
|
location = /favicon.ico {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
@@ -8,3 +8,11 @@ USER root
|
|||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 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 = {
|
required = {
|
||||||
"/api/v1/onlyoffice/attachments/{attachment_id}/config",
|
"/api/v1/onlyoffice/attachments/{attachment_id}/config",
|
||||||
"/api/v1/onlyoffice/versions/{version_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:
|
if not settings.ONLYOFFICE_ENABLED:
|
||||||
raise SystemExit("ONLYOFFICE_ENABLED 未生效")
|
raise SystemExit("ONLYOFFICE_ENABLED 未生效")
|
||||||
|
|||||||
Reference in New Issue
Block a user