发布候选:整合桌面端界面与发布稳定化里程碑
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
@@ -0,0 +1,159 @@
|
||||
name: Client Quality Gates
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "AGENTS.md"
|
||||
- "frontend/**"
|
||||
- ".github/workflows/client-quality-gates.yml"
|
||||
- "docs/branch-governance.md"
|
||||
- "docs/desktop-project-plan.md"
|
||||
- "docs/desktop-phase-1-design.md"
|
||||
- "docs/desktop-phase-2-design.md"
|
||||
- "docs/audits/desktop-release-stabilization-checklist.md"
|
||||
- "docs/guides/client-release.md"
|
||||
- "docs/guides/branch-maintenance-sop-zh.md"
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
- release
|
||||
tags:
|
||||
- "v*"
|
||||
paths:
|
||||
- "AGENTS.md"
|
||||
- "frontend/**"
|
||||
- ".github/workflows/client-quality-gates.yml"
|
||||
- "docs/branch-governance.md"
|
||||
- "docs/desktop-project-plan.md"
|
||||
- "docs/desktop-phase-1-design.md"
|
||||
- "docs/desktop-phase-2-design.md"
|
||||
- "docs/audits/desktop-release-stabilization-checklist.md"
|
||||
- "docs/guides/client-release.md"
|
||||
- "docs/guides/branch-maintenance-sop-zh.md"
|
||||
|
||||
jobs:
|
||||
web:
|
||||
name: Shared client and Web
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Resolve build metadata
|
||||
id: build-metadata
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
channel="release"
|
||||
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
|
||||
channel="${GITHUB_REF_NAME}"
|
||||
else
|
||||
channel="dev"
|
||||
fi
|
||||
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
|
||||
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Check release build metadata
|
||||
run: npm run release:env:check
|
||||
env:
|
||||
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||
|
||||
- name: Check synchronized client version
|
||||
run: npm run version:check
|
||||
|
||||
- name: Check runtime boundary
|
||||
run: npm run runtime:check
|
||||
|
||||
- name: Check desktop release and security gate
|
||||
run: npm run desktop:release:check
|
||||
|
||||
- name: Check UI contract
|
||||
run: npm run ui:contract
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Build Web artifact
|
||||
run: npm run build
|
||||
env:
|
||||
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||
|
||||
desktop:
|
||||
name: macOS Desktop
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Resolve build metadata
|
||||
id: build-metadata
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
channel="release"
|
||||
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
|
||||
channel="${GITHUB_REF_NAME}"
|
||||
else
|
||||
channel="dev"
|
||||
fi
|
||||
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
|
||||
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Check release build metadata
|
||||
run: npm run release:env:check
|
||||
env:
|
||||
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||
|
||||
- name: Check synchronized client version
|
||||
run: npm run version:check
|
||||
|
||||
- name: Check runtime boundary
|
||||
run: npm run runtime:check
|
||||
|
||||
- name: Check desktop release and security gate
|
||||
run: npm run desktop:release:check
|
||||
|
||||
- name: Check UI contract
|
||||
run: npm run ui:contract
|
||||
|
||||
- name: Build unsigned macOS application smoke artifact
|
||||
run: npm run desktop:build:app
|
||||
env:
|
||||
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||
@@ -53,6 +53,10 @@ pyrightconfig.json
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/src-tauri/target/
|
||||
frontend/src-tauri/gen/schemas/
|
||||
frontend/src-tauri/icons/android/
|
||||
frontend/src-tauri/icons/ios/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -82,4 +86,5 @@ backend/app/uploads/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
worktrees/
|
||||
.install-logs/
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Agent Instructions
|
||||
|
||||
处理 CTMS 桌面端任务前,必须先阅读 `docs/desktop-project-plan.md`。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知和桌面端安全边界。
|
||||
|
||||
桌面端当前阶段边界以 `docs/desktop-project-plan.md` 为准。除非先明确修改计划书,否则不要实现离线功能、本地业务数据存储、内嵌后端服务、离线同步或新的阶段性桌面产品线。
|
||||
|
||||
`docs/desktop-project-plan.md` 是当前桌面端进展和工作边界的事实来源。本文件只保留执行约束,不重复维护审查结论或优化方向;不得重新按第一阶段空白项目初始化 Tauri,也不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。如实际代码状态与计划书不一致,先更新计划书再实现。
|
||||
|
||||
处理前端或桌面端实现时,优先保持以下边界:
|
||||
|
||||
- 共享业务代码通过 `frontend/src/runtime/index.ts` 获取平台能力。
|
||||
- Tauri API 仅允许出现在 `frontend/src/runtime/`、`frontend/src-tauri/` 或有明确记录的窄入口中。
|
||||
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。
|
||||
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
|
||||
- Windows 仍只作为第二阶段兼容性验证目标;未获明确批准前不发布正式 Windows 安装包。
|
||||
|
||||
## 分支与发布治理
|
||||
|
||||
处理代码提交、分支同步、版本晋级、正式发布或生产热修复前,必须先阅读:
|
||||
|
||||
- `docs/guides/branch-maintenance-sop-zh.md`
|
||||
- `docs/branch-governance.md`
|
||||
- 涉及网页端或桌面端客户端发布时,还需阅读 `docs/guides/client-release.md`
|
||||
|
||||
必须遵守以下规则:
|
||||
|
||||
- CTMS 网页端和桌面端属于同一个产品,共用 `dev`、`main`、`release` 分支,不创建 `web-dev`、`desktop-dev`、`web-release`、`desktop-release` 等长期平行分支。
|
||||
- 默认晋级路径为短期任务分支进入 `dev`,再由 `dev` 晋级到 `main`,最后由 `main` 发布到 `release`。
|
||||
- Agent 创建分支时默认使用 `codex/<任务名称>`;分支必须从最新 `dev` 创建,并在合并到 `dev` 后删除。
|
||||
- `codex/ctms-desktop` 是历史桌面端临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。
|
||||
- 生产热修复从 `release` 创建,合并到 `release` 后必须依次回合并到 `main` 和 `dev`。
|
||||
- 网页端和桌面端必须使用同一个语义化版本号、正式标签和源代码提交。修改客户端版本时使用 `frontend/package.json` 中的 `version:set` 和 `version:check` 命令。
|
||||
- 平台差异必须收敛在 `frontend/src/runtime/` 之后,不能通过长期分支或复制业务代码维护桌面差异。
|
||||
- 未经用户明确要求,不执行提交、推送、合并、变基、打标签、删除分支或强制更新远程分支。
|
||||
- 执行用户明确要求的 Git 操作前,先检查工作区和目标分支,只暂存本次任务相关文件,不覆盖或撤销用户已有改动。
|
||||
- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行任何分支切换、提交、推送或变基前必须先说明目标基线,并等待用户明确指令。
|
||||
- 分支治理规则发生变化时,必须同步更新上述治理文档,不能只修改 `AGENTS.md`。
|
||||
|
||||
## 常用质量门禁
|
||||
|
||||
前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run version:check
|
||||
npm run release:env:check
|
||||
npm run runtime:check
|
||||
npm run desktop:release:check
|
||||
npm run ui:contract
|
||||
npm run type-check
|
||||
npm run test:unit
|
||||
npm run build
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
正式桌面发布构建仍必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述。
|
||||
@@ -40,8 +40,18 @@
|
||||
- 后端 API:同域 `/api/v1/*`
|
||||
- `nginx` 负责托管前端静态资源,并将 `/api` 与 `/health` 转发到 `backend`
|
||||
|
||||
## macOS 桌面端开发
|
||||
- 桌面端遵循 `docs/desktop-project-plan.md` 第一阶段边界:Tauri 在线客户端,不内嵌后端、不保存本地业务数据、不做离线同步。
|
||||
- 开发启动:进入 `frontend/` 后执行 `npm run desktop:dev`。
|
||||
- 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`;DMG 构建执行 `npm run desktop:bundle:dmg`。
|
||||
- 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health`。
|
||||
- 生产或非本地服务地址必须使用 HTTPS;本地开发允许 `http://localhost` 或 `http://127.0.0.1`。
|
||||
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `npm run version:check` 检查漂移。
|
||||
- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`。
|
||||
|
||||
## 仓库治理文档
|
||||
- 分支治理规范:`docs/branch-governance.md`
|
||||
- 分支维护中文 SOP:`docs/guides/branch-maintenance-sop-zh.md`
|
||||
- 分支环境安装配置:`docs/guides/branch-environment-installation.md`
|
||||
- 发布检查清单:`docs/guides/release-checklist.md`
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""add desktop phase 2 notification and client metadata state
|
||||
|
||||
Revision ID: 20260630_02
|
||||
Revises: 20260630_01
|
||||
Create Date: 2026-06-30 21:30:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260630_02"
|
||||
down_revision: Union[str, None] = "20260630_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("security_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
|
||||
op.add_column("security_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
|
||||
op.create_index(
|
||||
"ix_security_log_client_created",
|
||||
"security_access_logs",
|
||||
["client_type", "client_version", "created_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"desktop_notification_subscriptions",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
sa.Column("enabled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"desktop_notification_deliveries",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("distribution_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("claim_token", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["distribution_id"], ["distributions.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"user_id",
|
||||
"distribution_id",
|
||||
name="uq_desktop_notification_user_distribution",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_desktop_notification_claim",
|
||||
"desktop_notification_deliveries",
|
||||
["user_id", "delivered_at", "claimed_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_desktop_notification_claim", table_name="desktop_notification_deliveries")
|
||||
op.drop_table("desktop_notification_deliveries")
|
||||
op.drop_table("desktop_notification_subscriptions")
|
||||
op.drop_index("ix_security_log_client_created", table_name="security_access_logs")
|
||||
for column in ("build_commit", "build_channel", "client_platform", "client_version", "client_type"):
|
||||
op.drop_column("security_access_logs", column)
|
||||
@@ -346,13 +346,10 @@ async def preview_attachment(
|
||||
|
||||
|
||||
async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID):
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
if not token:
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
|
||||
payload = decode_token(token)
|
||||
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi import File, UploadFile
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -130,8 +130,10 @@ def ensure_user_active(db_user) -> None:
|
||||
|
||||
@router.get("/email-domains", response_model=EmailDomainsResponse)
|
||||
async def read_email_domains(
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> EmailDomainsResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
rows = await email_service.list_email_settings(db)
|
||||
return EmailDomainsResponse(items=[row.register_domain for row in rows])
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.schemas.notification import (
|
||||
DesktopNotificationAckRequest,
|
||||
DesktopNotificationClaimRequest,
|
||||
DesktopNotificationClaimResponse,
|
||||
DesktopNotificationSubscriptionRead,
|
||||
DesktopNotificationSubscriptionUpdate,
|
||||
)
|
||||
from app.services import desktop_notification_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/subscription", response_model=DesktopNotificationSubscriptionRead)
|
||||
async def read_subscription(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DesktopNotificationSubscriptionRead:
|
||||
subscription = await desktop_notification_service.get_subscription(db, current_user.id)
|
||||
return DesktopNotificationSubscriptionRead(
|
||||
enabled=bool(subscription and subscription.enabled),
|
||||
enabled_at=subscription.enabled_at if subscription else None,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/subscription", response_model=DesktopNotificationSubscriptionRead)
|
||||
async def update_subscription(
|
||||
payload: DesktopNotificationSubscriptionUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DesktopNotificationSubscriptionRead:
|
||||
subscription = await desktop_notification_service.set_subscription(
|
||||
db, current_user.id, payload.enabled
|
||||
)
|
||||
return DesktopNotificationSubscriptionRead(
|
||||
enabled=subscription.enabled,
|
||||
enabled_at=subscription.enabled_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/claim", response_model=DesktopNotificationClaimResponse)
|
||||
async def claim_notifications(
|
||||
payload: DesktopNotificationClaimRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DesktopNotificationClaimResponse:
|
||||
if payload.limit < 1 or payload.limit > 50:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="limit 必须在 1 到 50 之间")
|
||||
claim_token, lease_expires_at, items = await desktop_notification_service.claim_notifications(
|
||||
db, current_user.id, payload.limit
|
||||
)
|
||||
return DesktopNotificationClaimResponse(
|
||||
claim_token=claim_token,
|
||||
lease_expires_at=lease_expires_at,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ack", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def acknowledge_notifications(
|
||||
payload: DesktopNotificationAckRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await desktop_notification_service.acknowledge_notifications(
|
||||
db,
|
||||
current_user.id,
|
||||
payload.claim_token,
|
||||
payload.delivered_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{distribution_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def mark_notification_read(
|
||||
distribution_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await desktop_notification_service.mark_notification_read(
|
||||
db, current_user.id, distribution_id
|
||||
)
|
||||
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, list_active_pm_study_ids
|
||||
from app.core.permission_monitor import (
|
||||
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES,
|
||||
CACHE_METRICS_WINDOW_SECONDS,
|
||||
@@ -43,6 +43,11 @@ class MonitoringScope:
|
||||
async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope:
|
||||
if is_system_admin(current_user):
|
||||
return MonitoringScope(is_admin=True, study_ids=set())
|
||||
user_id = getattr(current_user, "id", None)
|
||||
if user_id:
|
||||
study_ids = await list_active_pm_study_ids(db, user_id)
|
||||
if study_ids:
|
||||
return MonitoringScope(is_admin=False, study_ids=study_ids)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
|
||||
@@ -479,6 +484,8 @@ async def get_security_access_logs(
|
||||
_=Depends(get_current_user),
|
||||
status_min: Optional[int] = Query(None, ge=100, le=599),
|
||||
auth_status: Optional[str] = Query(None),
|
||||
client_type: Optional[str] = Query(None),
|
||||
client_version: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> dict:
|
||||
@@ -486,11 +493,17 @@ async def get_security_access_logs(
|
||||
scope = await resolve_monitoring_scope(db, _)
|
||||
if not scope.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
client_type = client_type if isinstance(client_type, str) else None
|
||||
client_version = client_version if isinstance(client_version, str) else None
|
||||
conditions = []
|
||||
if status_min is not None:
|
||||
conditions.append(SecurityAccessLog.status_code >= status_min)
|
||||
if auth_status:
|
||||
conditions.append(SecurityAccessLog.auth_status == auth_status)
|
||||
if client_type:
|
||||
conditions.append(SecurityAccessLog.client_type == client_type)
|
||||
if client_version:
|
||||
conditions.append(SecurityAccessLog.client_version == client_version)
|
||||
|
||||
query = select(SecurityAccessLog)
|
||||
count_query = select(func.count()).select_from(SecurityAccessLog)
|
||||
@@ -545,6 +558,11 @@ async def get_security_access_logs(
|
||||
"ip_city": ip_location.city,
|
||||
"ip_isp": ip_location.isp,
|
||||
"user_agent": log.user_agent,
|
||||
"client_type": log.client_type,
|
||||
"client_version": log.client_version,
|
||||
"client_platform": log.client_platform,
|
||||
"build_channel": log.build_channel,
|
||||
"build_commit": log.build_commit,
|
||||
"auth_status": log.auth_status,
|
||||
"user_identifier": log.user_identifier,
|
||||
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||
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
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -10,6 +10,7 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
||||
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
|
||||
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
|
||||
api_router.include_router(desktop_notifications.router, prefix="/desktop-notifications", tags=["desktop-notifications"])
|
||||
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
|
||||
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
|
||||
api_router.include_router(api_permissions.router, tags=["api-permissions"])
|
||||
|
||||
@@ -26,6 +26,10 @@ class Settings(BaseSettings):
|
||||
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
|
||||
SETTINGS_ENCRYPTION_KEY: Optional[str] = None
|
||||
FRONTEND_PUBLIC_URL: str = "http://localhost:8888"
|
||||
CORS_ALLOWED_ORIGINS: str = (
|
||||
"http://localhost:8888,http://localhost:5173,"
|
||||
"tauri://localhost,http://tauri.localhost"
|
||||
)
|
||||
IP2REGION_XDB_PATH: Optional[str] = None
|
||||
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
|
||||
|
||||
@@ -36,3 +40,11 @@ def get_settings() -> Settings:
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def get_cors_allowed_origins() -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
for origin in settings.CORS_ALLOWED_ORIGINS.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
@@ -42,4 +42,8 @@ from app.models.permission_access_log import PermissionAccessLog # noqa: F401
|
||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401
|
||||
from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401
|
||||
from app.models.security_access_log import SecurityAccessLog # noqa: F401
|
||||
from app.models.desktop_notification import ( # noqa: F401
|
||||
DesktopNotificationDelivery,
|
||||
DesktopNotificationSubscription,
|
||||
)
|
||||
from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import settings
|
||||
from app.core.config import get_cors_allowed_origins, settings
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
from app.core.login_crypto import validate_login_crypto_configuration
|
||||
from app.crud.user import ensure_admin_exists
|
||||
@@ -116,10 +116,19 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=get_cors_allowed_origins(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_headers=[
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-CTMS-Client-Type",
|
||||
"X-CTMS-Client-Version",
|
||||
"X-CTMS-Client-Platform",
|
||||
"X-CTMS-Build-Channel",
|
||||
"X-CTMS-Build-Commit",
|
||||
],
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -269,6 +278,11 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a
|
||||
"elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2),
|
||||
"client_ip": _resolve_client_ip(request),
|
||||
"user_agent": request.headers.get("user-agent"),
|
||||
"client_type": request.headers.get("x-ctms-client-type"),
|
||||
"client_version": request.headers.get("x-ctms-client-version"),
|
||||
"client_platform": request.headers.get("x-ctms-client-platform"),
|
||||
"build_channel": request.headers.get("x-ctms-build-channel"),
|
||||
"build_commit": request.headers.get("x-ctms-build-commit"),
|
||||
"auth_status": auth_status,
|
||||
"user_identifier": user_identifier,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class DesktopNotificationSubscription(Base):
|
||||
__tablename__ = "desktop_notification_subscriptions"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
enabled_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()
|
||||
)
|
||||
|
||||
|
||||
class DesktopNotificationDelivery(Base):
|
||||
__tablename__ = "desktop_notification_deliveries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "distribution_id", name="uq_desktop_notification_user_distribution"),
|
||||
Index("ix_desktop_notification_claim", "user_id", "delivered_at", "claimed_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
distribution_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
claim_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -30,6 +30,11 @@ class SecurityAccessLog(Base):
|
||||
elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||
client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
||||
build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
auth_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -16,5 +16,33 @@ class NotificationItem(BaseModel):
|
||||
change_summary: str | None = None
|
||||
effective_at: datetime | None = None
|
||||
created_at: datetime
|
||||
study_id: uuid.UUID | None = None
|
||||
study_name: str | None = None
|
||||
delivered_at: datetime | None = None
|
||||
read_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DesktopNotificationSubscriptionRead(BaseModel):
|
||||
enabled: bool
|
||||
enabled_at: datetime | None = None
|
||||
|
||||
|
||||
class DesktopNotificationSubscriptionUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DesktopNotificationClaimRequest(BaseModel):
|
||||
limit: int = 20
|
||||
|
||||
|
||||
class DesktopNotificationClaimResponse(BaseModel):
|
||||
claim_token: uuid.UUID | None = None
|
||||
lease_expires_at: datetime | None = None
|
||||
items: list[NotificationItem]
|
||||
|
||||
|
||||
class DesktopNotificationAckRequest(BaseModel):
|
||||
claim_token: uuid.UUID
|
||||
delivered_ids: list[uuid.UUID]
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, exists, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.desktop_notification import (
|
||||
DesktopNotificationDelivery,
|
||||
DesktopNotificationSubscription,
|
||||
)
|
||||
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||
from app.models.document import Document
|
||||
from app.models.document_version import DocumentVersion
|
||||
from app.models.study import Study
|
||||
from app.models.study_member import StudyMember
|
||||
from app.schemas.notification import NotificationItem
|
||||
|
||||
CLAIM_LEASE = timedelta(minutes=5)
|
||||
|
||||
|
||||
async def get_subscription(db: AsyncSession, user_id: uuid.UUID) -> DesktopNotificationSubscription | None:
|
||||
return await db.get(DesktopNotificationSubscription, user_id)
|
||||
|
||||
|
||||
async def set_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
enabled: bool,
|
||||
) -> DesktopNotificationSubscription:
|
||||
subscription = await get_subscription(db, user_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if subscription is None:
|
||||
subscription = DesktopNotificationSubscription(
|
||||
user_id=user_id,
|
||||
enabled=enabled,
|
||||
enabled_at=now if enabled else None,
|
||||
)
|
||||
db.add(subscription)
|
||||
elif subscription.enabled != enabled:
|
||||
subscription.enabled = enabled
|
||||
subscription.enabled_at = now if enabled else None
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
return subscription
|
||||
|
||||
|
||||
def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime):
|
||||
role_target_exists = exists(
|
||||
select(StudyMember.id).where(
|
||||
StudyMember.study_id == Document.trial_id,
|
||||
StudyMember.user_id == user_id,
|
||||
StudyMember.is_active.is_(True),
|
||||
StudyMember.role_in_study == Distribution.target_id,
|
||||
)
|
||||
)
|
||||
target_matches = or_(
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.USER,
|
||||
Distribution.target_id == str(user_id),
|
||||
),
|
||||
and_(
|
||||
Distribution.target_type == DistributionTargetType.ROLE,
|
||||
role_target_exists,
|
||||
),
|
||||
)
|
||||
return (
|
||||
select(
|
||||
Distribution,
|
||||
Document,
|
||||
DocumentVersion,
|
||||
Study,
|
||||
DesktopNotificationDelivery,
|
||||
)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.join(Study, Document.trial_id == Study.id)
|
||||
.outerjoin(
|
||||
DesktopNotificationDelivery,
|
||||
and_(
|
||||
DesktopNotificationDelivery.distribution_id == Distribution.id,
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Distribution.status == DistributionStatus.ACTIVE,
|
||||
Distribution.created_at >= enabled_at,
|
||||
target_matches,
|
||||
or_(
|
||||
DesktopNotificationDelivery.id.is_(None),
|
||||
and_(
|
||||
DesktopNotificationDelivery.delivered_at.is_(None),
|
||||
or_(
|
||||
DesktopNotificationDelivery.claimed_at.is_(None),
|
||||
DesktopNotificationDelivery.claimed_at < lease_cutoff,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.order_by(Distribution.created_at.asc())
|
||||
.with_for_update(of=Distribution, skip_locked=True)
|
||||
)
|
||||
|
||||
|
||||
async def claim_notifications(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
limit: int,
|
||||
) -> tuple[uuid.UUID | None, datetime | None, list[NotificationItem]]:
|
||||
subscription = await get_subscription(db, user_id)
|
||||
if not subscription or not subscription.enabled or not subscription.enabled_at:
|
||||
return None, None, []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = uuid.uuid4()
|
||||
rows = (
|
||||
await db.execute(
|
||||
_eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE).limit(max(1, min(limit, 50)))
|
||||
)
|
||||
).all()
|
||||
items: list[NotificationItem] = []
|
||||
for distribution, document, version, study, delivery in rows:
|
||||
if delivery is None:
|
||||
delivery = DesktopNotificationDelivery(
|
||||
user_id=user_id,
|
||||
distribution_id=distribution.id,
|
||||
)
|
||||
db.add(delivery)
|
||||
delivery.claim_token = token
|
||||
delivery.claimed_at = now
|
||||
items.append(
|
||||
NotificationItem(
|
||||
id=distribution.id,
|
||||
document_id=document.id,
|
||||
version_id=version.id,
|
||||
document_title=document.title,
|
||||
document_no=document.doc_no,
|
||||
version_no=version.version_no,
|
||||
change_summary=version.change_summary,
|
||||
effective_at=version.effective_at,
|
||||
created_at=distribution.created_at,
|
||||
study_id=study.id,
|
||||
study_name=study.name,
|
||||
delivered_at=delivery.delivered_at,
|
||||
read_at=delivery.read_at,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
if not items:
|
||||
return None, None, []
|
||||
return token, now + CLAIM_LEASE, items
|
||||
|
||||
|
||||
async def acknowledge_notifications(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
claim_token: uuid.UUID,
|
||||
delivered_ids: list[uuid.UUID],
|
||||
) -> None:
|
||||
if delivered_ids:
|
||||
await db.execute(
|
||||
update(DesktopNotificationDelivery)
|
||||
.where(
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
DesktopNotificationDelivery.claim_token == claim_token,
|
||||
DesktopNotificationDelivery.distribution_id.in_(delivered_ids),
|
||||
)
|
||||
.values(delivered_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def mark_notification_read(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
distribution_id: uuid.UUID,
|
||||
) -> None:
|
||||
delivery = (
|
||||
await db.execute(
|
||||
select(DesktopNotificationDelivery).where(
|
||||
DesktopNotificationDelivery.user_id == user_id,
|
||||
DesktopNotificationDelivery.distribution_id == distribution_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if delivery is None:
|
||||
delivery = DesktopNotificationDelivery(
|
||||
user_id=user_id,
|
||||
distribution_id=distribution_id,
|
||||
)
|
||||
db.add(delivery)
|
||||
delivery.read_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
@@ -10,7 +10,7 @@ from typing import Iterable
|
||||
import aiofiles
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope
|
||||
@@ -30,6 +30,8 @@ from app.models.audit_log import AuditLog
|
||||
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
||||
from app.models.document import Document, DocumentScopeType, DocumentStatus
|
||||
from app.models.document_version import DocumentVersion, DocumentVersionStatus
|
||||
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||
from app.models.study import Study
|
||||
from app.schemas.acknowledgement import AcknowledgementCreate
|
||||
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
|
||||
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
|
||||
@@ -778,9 +780,20 @@ async def list_distribution_notifications(
|
||||
DocumentVersion.version_no,
|
||||
DocumentVersion.change_summary,
|
||||
DocumentVersion.effective_at,
|
||||
Study.name.label("study_name"),
|
||||
DesktopNotificationDelivery.delivered_at,
|
||||
DesktopNotificationDelivery.read_at,
|
||||
)
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(Study, Document.trial_id == Study.id)
|
||||
.outerjoin(
|
||||
DesktopNotificationDelivery,
|
||||
and_(
|
||||
DesktopNotificationDelivery.distribution_id == Distribution.id,
|
||||
DesktopNotificationDelivery.user_id == current_user.id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Document.trial_id == study_id,
|
||||
Distribution.status == DistributionStatus.ACTIVE,
|
||||
@@ -804,6 +817,10 @@ async def list_distribution_notifications(
|
||||
change_summary=row.change_summary,
|
||||
effective_at=row.effective_at,
|
||||
created_at=row.created_at,
|
||||
study_id=study_id,
|
||||
study_name=row.study_name,
|
||||
delivered_at=row.delivered_at,
|
||||
read_at=row.read_at,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
@@ -76,6 +76,11 @@ class SecurityAccessLogWriter:
|
||||
elapsed_ms=entry["elapsed_ms"],
|
||||
client_ip=entry.get("client_ip"),
|
||||
user_agent=entry.get("user_agent"),
|
||||
client_type=entry.get("client_type"),
|
||||
client_version=entry.get("client_version"),
|
||||
client_platform=entry.get("client_platform"),
|
||||
build_channel=entry.get("build_channel"),
|
||||
build_commit=entry.get("build_commit"),
|
||||
auth_status=entry["auth_status"],
|
||||
user_identifier=entry.get("user_identifier"),
|
||||
created_at=entry.get("created_at", datetime.now(timezone.utc)),
|
||||
|
||||
@@ -70,6 +70,22 @@ async def add_email_settings(SessionLocal, domain: str) -> None:
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_email_domains_come_only_from_email_service_settings(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
|
||||
empty_response = await client.get("/api/v1/auth/email-domains")
|
||||
assert empty_response.status_code == 200
|
||||
assert empty_response.json() == {"items": []}
|
||||
assert empty_response.headers["cache-control"] == "no-store"
|
||||
|
||||
await add_email_settings(SessionLocal, "example.com")
|
||||
configured_response = await client.get("/api/v1/auth/email-domains")
|
||||
assert configured_response.status_code == 200
|
||||
assert configured_response.json() == {"items": ["example.com"]}
|
||||
assert configured_response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict:
|
||||
key_resp = await client.get("/api/v1/auth/login-key")
|
||||
assert key_resp.status_code == 200
|
||||
|
||||
@@ -26,7 +26,13 @@ services:
|
||||
set -e
|
||||
lock_hash="$$(sha256sum package-lock.json | awk '{print $$1}')"
|
||||
marker="node_modules/.ctms-package-lock.sha256"
|
||||
if [ ! -f "$$marker" ] || [ "$$(cat "$$marker")" != "$$lock_hash" ]; then
|
||||
missing_runtime_deps=0
|
||||
for dep in @tauri-apps/api @tauri-apps/plugin-dialog @tauri-apps/plugin-fs @tauri-apps/plugin-opener; do
|
||||
if [ ! -d "node_modules/$$dep" ]; then
|
||||
missing_runtime_deps=1
|
||||
fi
|
||||
done
|
||||
if [ ! -f "$$marker" ] || [ "$$(cat "$$marker")" != "$$lock_hash" ] || [ "$$missing_runtime_deps" = "1" ]; then
|
||||
npm ci
|
||||
printf '%s' "$$lock_hash" > "$$marker"
|
||||
fi
|
||||
@@ -35,6 +41,7 @@ services:
|
||||
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
|
||||
VITE_RUNTIME_ENV: ${ENV:-development}
|
||||
VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-true}
|
||||
VITE_HMR_CLIENT_PORT: ${VITE_HMR_CLIENT_PORT:-8888}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5173/"]
|
||||
interval: 2s
|
||||
|
||||
@@ -4,8 +4,12 @@ CTMS 文档入口只展示当前仍会影响开发、发布和运维决策的内
|
||||
|
||||
## 当前约束
|
||||
|
||||
- [`desktop-project-plan.md`](desktop-project-plan.md): 桌面端 Tauri 项目边界、阶段计划与必读约束
|
||||
- [`desktop-phase-1-design.md`](desktop-phase-1-design.md): 桌面端第一阶段 macOS 在线客户端详细方案
|
||||
- [`branch-governance.md`](branch-governance.md): 长期分支治理规则
|
||||
- [`guides/release-checklist.md`](guides/release-checklist.md): 发布前检查项与回归门禁
|
||||
- [`guides/client-release.md`](guides/client-release.md): Web/桌面端统一版本、构建与发布流程
|
||||
- [`guides/branch-maintenance-sop-zh.md`](guides/branch-maintenance-sop-zh.md): 分支维护、版本晋级、发布和热修复中文标准操作规程
|
||||
- [`audits/storage-persistence-governance.md`](audits/storage-persistence-governance.md): 重要数据落库治理基线
|
||||
- [`audits/module-level-permissions-transition.md`](audits/module-level-permissions-transition.md): 模块级权限迁移状态与约束
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# CTMS Desktop Release Stabilization Checklist
|
||||
|
||||
状态: `active`
|
||||
适用范围: Web 与 macOS Desktop 统一客户端发布
|
||||
最后更新: `2026-07-01`
|
||||
|
||||
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。
|
||||
|
||||
## 1. 发布链路门禁
|
||||
|
||||
发布候选提交必须从同一 Git 提交构建 Web 与 Desktop 制品,并完成以下检查:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run version:check
|
||||
npm run release:env:check
|
||||
npm run runtime:check
|
||||
npm run desktop:release:check
|
||||
npm run ui:contract
|
||||
npm run type-check
|
||||
npm run test:unit
|
||||
npm run build
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
正式发布还必须确认:
|
||||
|
||||
- [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。
|
||||
- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。
|
||||
- [ ] macOS app 已签名和公证。
|
||||
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。
|
||||
- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build -- --bundles app`。
|
||||
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <latest.json> --artifacts-dir <artifact-dir>`。
|
||||
- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。
|
||||
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
|
||||
|
||||
## 2. 安全边界复审
|
||||
|
||||
自动门禁 `npm run desktop:release:check` 覆盖以下静态约束:
|
||||
|
||||
- [ ] Tauri bundle 启用 `app`、`dmg` 和 updater artifacts。
|
||||
- [ ] updater public key 已配置。
|
||||
- [ ] CSP 禁止 wildcard source、`unsafe-eval`、宽泛 HTTP API 访问和 `object-src`。
|
||||
- [ ] Tauri capability 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。
|
||||
- [ ] 文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。
|
||||
- [ ] 单实例插件先于其他桌面插件注册。
|
||||
- [ ] Tauri command 白名单仅包含凭据和更新命令。
|
||||
- [ ] 前端源码不通过 query string 传递 token。
|
||||
- [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。
|
||||
- [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。
|
||||
- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。
|
||||
|
||||
人工复审还必须确认:
|
||||
|
||||
- [ ] token 不出现在 URL、日志、系统通知正文、下载链接或持久化业务缓存中。
|
||||
- [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。
|
||||
- [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。
|
||||
- [ ] Web 运行时不直接导入 Tauri API。
|
||||
|
||||
## 3. 端到端回归矩阵
|
||||
|
||||
| 场景 | Web | macOS Desktop | 预期 |
|
||||
| --- | --- | --- | --- |
|
||||
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
|
||||
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
||||
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
|
||||
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
|
||||
| 附件上传 | 必测 | 必测 | Web 使用浏览器文件选择,Desktop 使用原生选择 |
|
||||
| 附件下载/保存/打开 | 必测 | 必测 | 使用 Authorization header;无 `?token=` |
|
||||
| 临时文件清理 | 不适用 | 必测 | 启动时清理 `$TEMP/ctms-desktop/**` |
|
||||
| 系统通知开启 | 不适用 | 必测 | 用户主动开启后请求 OS 权限并创建订阅 |
|
||||
| 系统通知拒绝 | 不适用 | 必测 | 开关回退,提示系统权限未开启 |
|
||||
| 通知领取与 ack | 不适用 | 必测 | 显示成功后 ack;失败等待租约重试 |
|
||||
| 单实例重复启动 | 不适用 | 必测 | 恢复、显示并聚焦主窗口 |
|
||||
| 自动更新检查 | 不适用 | 必测 | release 通道按当前 CTMS origin 派生清单 |
|
||||
| 更新稍后提醒 | 不适用 | 必测 | 同版本 24 小时内不重复提示 |
|
||||
| 更新安装失败 | 不适用 | 必测 | 不打断业务录入,显示可排障错误 |
|
||||
|
||||
## 4. 桌面体验验收
|
||||
|
||||
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
|
||||
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
|
||||
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
|
||||
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
|
||||
- [ ] 通知开关显示 OS 权限状态。
|
||||
- [ ] 手动检查更新能反馈“已是最新版本”、未启用更新或检查失败。
|
||||
- [ ] 关键弹窗、表单、按钮在最小窗口尺寸 `1180x760` 下不重叠、不溢出。
|
||||
- [ ] 更新弹窗只显示版本、发布日期和通用 release notes,不展示 token、下载链接或业务详情。
|
||||
|
||||
## 5. 不允许项
|
||||
|
||||
- [ ] 不实现离线登录、离线浏览、离线队列或离线同步。
|
||||
- [ ] 不在桌面端保存 CTMS 业务数据副本。
|
||||
- [ ] 不内嵌 FastAPI、PostgreSQL、SQLite 或本地业务 API 镜像。
|
||||
- [ ] 不绕过后端做本地权限裁决或本地审计回放。
|
||||
|
||||
## 6. 2026-07-01 收尾验证记录
|
||||
|
||||
本轮收尾验证在 `/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop` 的 detached HEAD `c923f887` 上执行,包含当前工作区文档与 CI 门禁调整。
|
||||
|
||||
已通过的自动门禁:
|
||||
|
||||
- `cd frontend && npm run version:check`
|
||||
- `cd frontend && npm run release:env:check`
|
||||
- `cd frontend && npm run runtime:check`
|
||||
- `cd frontend && npm run desktop:release:check`
|
||||
- `cd frontend && npm run ui:contract`
|
||||
- `cd frontend && npm run type-check`
|
||||
- `cd frontend && npm run test:unit`
|
||||
- `cd frontend && npm run build`
|
||||
- `cd frontend && npm run desktop:build:app`
|
||||
- `cd frontend && node --check scripts/verify-desktop-update-feed.mjs`
|
||||
|
||||
验证结论:
|
||||
|
||||
- Tauri 运行时边界、release 静态安全门禁、构建元数据预检、版本一致性和 UI 合约均通过。
|
||||
- Web 生产构建和未签名 macOS `.app` smoke 构建均可重复执行。
|
||||
- 当前 CI 已补齐 `npm run release:env:check` 和 `npm run ui:contract`,tag 构建会将 `VITE_BUILD_CHANNEL` 规范为 `release` 并校验 tag 与版本号一致。
|
||||
- updater feed 校验脚本已完成语法检查;正式 `latest.json` 需要在签名 updater artifacts 生成后执行实物校验。
|
||||
|
||||
仍需正式发布前人工确认:
|
||||
|
||||
- macOS 签名、公证、Apple Developer 凭据和组织 updater 私钥。
|
||||
- 签名后的 updater artifacts、`.sig`、checksum manifest 和 `latest.json` 在真实发布目录内通过 `npm run desktop:update-feed:check`。
|
||||
- 不可变制品上传完成后,再原子替换线上 `latest.json`。
|
||||
- Desktop 端到端人工回归矩阵、最小窗口体验验收和系统通知/自动更新真实环境验证。
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
状态: `snapshot`
|
||||
适用范围: `storage-persistence`
|
||||
最后更新: `2026-02-27`
|
||||
最后更新: `2026-06-30`
|
||||
|
||||
- 扫描目录: `/Users/zcc/MyCTMS/ctms-project/frontend/src`
|
||||
- 总发现数: `46`
|
||||
- 高风险: `4` / 中风险: `0` / 低风险: `42`
|
||||
- 总发现数: `36`
|
||||
- 高风险: `4` / 中风险: `0` / 低风险: `32`
|
||||
|
||||
> 2026-06-30 第二阶段更新:认证 token 已迁移到 `frontend/src/runtime/secureSessionStorage.ts`;
|
||||
> 业务入口不再直接从 `localStorage` 读取 `ctms_token`,附件下载不再使用 query-token。
|
||||
|
||||
## 明细
|
||||
|
||||
@@ -18,13 +21,6 @@
|
||||
| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2984 | `localStorage.removeItem` | `storageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 |
|
||||
| low | ui-preference | `frontend/src/components/Layout.vue` | 227 | `localStorage.getItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 |
|
||||
| low | ui-preference | `frontend/src/components/Layout.vue` | 390 | `localStorage.setItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 |
|
||||
| low | auth-session | `frontend/src/components/ThreadList.vue` | 72 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 132 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 137 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 168 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 169 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 174 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 223 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/session/sessionManager.ts` | 33 | `localStorage.setItem` | `"ctms_auth_broadcast"` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/session/sessionManager.ts` | 181 | `sessionStorage.removeItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/session/sessionManager.ts` | 184 | `sessionStorage.setItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) |
|
||||
@@ -49,9 +45,6 @@
|
||||
| low | ui-preference | `frontend/src/store/study.ts` | 167 | `localStorage.removeItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 |
|
||||
| low | ui-preference | `frontend/src/store/study.ts` | 174 | `localStorage.setItem` | `SITE_KEY` | UI偏好/上下文缓存 |
|
||||
| low | ui-preference | `frontend/src/store/study.ts` | 176 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 4 | `localStorage.getItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 7 | `localStorage.setItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 11 | `localStorage.removeItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 17 | `localStorage.setItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 24 | `localStorage.getItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||
| low | auth-session | `frontend/src/utils/auth.ts` | 38 | `localStorage.removeItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||
|
||||
@@ -78,6 +78,29 @@ Meaning:
|
||||
|
||||
Direct promotion that skips stages is discouraged and must be justified in writing.
|
||||
|
||||
## 3.1 Unified Web and Desktop Mainline
|
||||
|
||||
CTMS Web and Desktop are two delivery targets of the same product version. They
|
||||
share the Vue application, API contract, and product branches.
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not create long-lived `web-dev`, `desktop-dev`, `web-release`, or
|
||||
`desktop-release` branches.
|
||||
- Web and Desktop changes both follow `feature/*` -> `dev` -> `main` ->
|
||||
`release`.
|
||||
- A platform-specific feature or agent task branch is allowed while work is in
|
||||
progress, for example `feature/desktop-file-picker` or
|
||||
`codex/desktop-menu-polish`, but it must merge back into `dev`.
|
||||
- `codex/ctms-desktop` was the temporary desktop integration branch for the
|
||||
Tauri baseline. It is no longer a current desktop mainline. Do not commit,
|
||||
rebase, or push new desktop work to it unless explicitly cleaning up the
|
||||
historical branch after its accepted changes are present on `dev`.
|
||||
- Platform differences belong behind `frontend/src/runtime/`. Shared business
|
||||
modules must not import Tauri APIs directly.
|
||||
- A release tag identifies one product source state. Web and Desktop artifacts
|
||||
for that release must be built from the same tag and Git commit.
|
||||
|
||||
## 4. Branch Entry Rules
|
||||
|
||||
### Changes allowed into `dev`
|
||||
@@ -214,6 +237,13 @@ Rules:
|
||||
- tags are created on `release`, not on `dev`
|
||||
- a tag must point to the exact production release commit
|
||||
- patch hotfixes on `release` should increment the patch version
|
||||
- Web and Desktop use the same semantic version. Do not add a separate Desktop
|
||||
product version.
|
||||
- Desktop packaging-only rebuilds may add build metadata to the artifact name,
|
||||
but must retain the product version and record the source commit.
|
||||
- Before creating a tag, run `cd frontend && npm run version:check`.
|
||||
- Change the shared client version with
|
||||
`cd frontend && npm run version:set -- <version>`.
|
||||
|
||||
## 7. Hotfix Back-Merge Rules
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
# CTMS 桌面端第一阶段详细方案
|
||||
|
||||
日期:2026-06-30
|
||||
|
||||
## 目标
|
||||
|
||||
第一阶段只交付 macOS 在线桌面客户端。桌面端使用 Tauri 承载现有 Vue/Vite 前端,连接已有 CTMS 服务端,不内嵌后端、不内嵌数据库、不做离线能力。
|
||||
|
||||
本阶段结束后,应能在 macOS 上启动 CTMS 桌面 App,配置 CTMS 服务端地址,完成登录,并执行现有 Web 端的常规在线业务流程。Web 端构建和现有 Docker/nginx 部署必须继续可用。
|
||||
|
||||
## 范围边界
|
||||
|
||||
本阶段必须做:
|
||||
|
||||
- 在 `frontend/` 内加入 Tauri 工程结构。
|
||||
- 复用现有 Vue 3、Vite、Element Plus、Pinia、Vue Router、Axios 前端代码。
|
||||
- 让桌面端连接已有 CTMS 后端公开入口,推荐连接 nginx 入口而不是直接连接后端容器。
|
||||
- 提供桌面端服务端地址配置能力。
|
||||
- 抽出最小运行时适配层,避免业务页面直接依赖 Tauri API。
|
||||
- 保持认证、权限、审计和业务数据持久化由 FastAPI 后端裁决。
|
||||
- 产出 macOS 开发构建流程和后续签名、公证、DMG 发布路径。
|
||||
|
||||
本阶段明确不做:
|
||||
|
||||
- 离线登录、离线浏览、离线草稿、离线队列、离线同步。
|
||||
- 本地 PostgreSQL、SQLite、IndexedDB 业务数据缓存或本地 API 镜像。
|
||||
- 内嵌 Python/FastAPI 后端服务。
|
||||
- 重写 CTMS 业务页面。
|
||||
- Windows 安装包交付。
|
||||
- 自动更新实现。
|
||||
- token/session 安全存储迁移;该事项进入第二阶段。
|
||||
|
||||
## 当前代码影响点
|
||||
|
||||
现状:
|
||||
|
||||
- `frontend/src/api/axios.ts` 和 `frontend/src/api/authClient.ts` 的 `baseURL` 都是 `/`。
|
||||
- API 调用路径均为 `/api/v1/...`。
|
||||
- Web 端依赖 nginx 同域反代 `/api`。
|
||||
- Tauri 打包后页面运行在桌面 WebView 中,不能继续假设 `/api` 一定代表 CTMS 服务端。
|
||||
- `frontend/src/utils/auth.ts` 当前使用 `localStorage` 保存 token。
|
||||
- `frontend/src/session/sessionManager.ts` 依赖 `localStorage`、`sessionStorage` 和 `BroadcastChannel`。
|
||||
|
||||
第一阶段的核心改动是先解决“桌面端如何知道并访问服务端地址”,同时尽量不触碰业务页面。
|
||||
|
||||
## 目标架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Tauri macOS App"] --> B["Bundled Vue/Vite Frontend"]
|
||||
B --> C["Runtime Adapters"]
|
||||
C --> D["Configured CTMS Server URL"]
|
||||
D --> E["nginx / HTTPS entry"]
|
||||
E --> F["FastAPI backend"]
|
||||
F --> G["PostgreSQL"]
|
||||
```
|
||||
|
||||
桌面端只负责本地窗口、运行时配置和少量平台能力。业务数据仍走服务端 API,权限和审计仍由后端完成。
|
||||
|
||||
## Tauri 工程方案
|
||||
|
||||
Tauri 目录放在现有前端工程内:
|
||||
|
||||
```text
|
||||
frontend/
|
||||
src-tauri/
|
||||
tauri.conf.json
|
||||
Cargo.toml
|
||||
src/
|
||||
main.rs
|
||||
capabilities/
|
||||
default.json
|
||||
```
|
||||
|
||||
建议新增脚本:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
"desktop:dev": "tauri dev",
|
||||
"desktop:build": "tauri build",
|
||||
"desktop:bundle:dmg": "tauri build -- --bundles dmg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tauri 配置方向:
|
||||
|
||||
- `build.beforeDevCommand`: `npm run dev`
|
||||
- `build.beforeBuildCommand`: `npm run build`
|
||||
- `build.devUrl`: `http://localhost:5173`
|
||||
- `build.frontendDist`: `../dist`
|
||||
- 主窗口标题:`CTMS`
|
||||
- 主窗口初始尺寸建议:`1440x900`
|
||||
- 最小窗口尺寸建议:`1180x760`
|
||||
- bundle identifier 待最终确认,临时建议:`cn.huapont.ctms.desktop`
|
||||
|
||||
第一阶段不加载远程 Web UI。Tauri 只加载本地打包出的前端资源,远程访问仅限 Axios 访问配置好的 CTMS 服务端。
|
||||
|
||||
## Vite 调整方案
|
||||
|
||||
当前 `vite.config.ts` 为 Docker 开发做了固定代理:
|
||||
|
||||
- dev server 端口:`5173`
|
||||
- HMR client port:`8888`
|
||||
- `/api` 代理目标:`http://backend:8000`
|
||||
|
||||
为兼容 Web、Docker 和 Tauri,建议改成环境变量驱动:
|
||||
|
||||
- `VITE_DEV_API_PROXY_TARGET`:默认 `http://backend:8000`。
|
||||
- `VITE_HMR_CLIENT_PORT`:Docker/nginx 开发时设为 `8888`,桌面端开发不设置。
|
||||
- `TAURI_DEV_HOST`:按 Tauri/Vite 推荐方式兼容 Tauri dev。
|
||||
|
||||
Vite 配置需要补充:
|
||||
|
||||
- `server.strictPort: true`,避免 Tauri devUrl 与 Vite 实际端口不一致。
|
||||
- `server.watch.ignored: ["**/src-tauri/**"]`,避免 Rust 目录变动触发不必要的前端监听。
|
||||
- `clearScreen: false`,避免 Rust 编译错误被 Vite 清屏隐藏。
|
||||
- `build.target` 按 Tauri 平台设置:Windows 使用 Chromium 目标,macOS 使用 Safari/WebKit 目标。
|
||||
- `envPrefix` 保留 `VITE_`,并允许 `TAURI_ENV_*`。
|
||||
|
||||
## 服务端地址配置
|
||||
|
||||
新增运行时适配层:
|
||||
|
||||
```text
|
||||
frontend/src/runtime/
|
||||
platform.ts
|
||||
apiBaseUrl.ts
|
||||
desktopServerConfig.ts
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
- `platform.ts`
|
||||
- 判断当前是否运行在 Tauri。
|
||||
- 判断目标平台是 Web、macOS 桌面端还是 Windows 桌面端。
|
||||
- 业务页面不得直接读取 Tauri 全局对象。
|
||||
|
||||
- `desktopServerConfig.ts`
|
||||
- 保存和读取桌面端服务端地址。
|
||||
- 第一阶段可继续使用 `localStorage` 保存服务器地址;这不是业务数据,也不是离线能力。
|
||||
- key 建议:`ctms_desktop_server_url`。
|
||||
- 仅接受 `http://localhost`、`http://127.0.0.1` 或 `https://...`。非本地生产服务不接受明文 HTTP。
|
||||
|
||||
- `apiBaseUrl.ts`
|
||||
- Web 端返回 `/`,保持现有同域 `/api` 行为。
|
||||
- 桌面端返回配置的服务端 origin,例如 `https://ctms.example.com/`。
|
||||
- 统一规范尾斜杠,避免拼接出错。
|
||||
|
||||
Axios 调整:
|
||||
|
||||
- `frontend/src/api/axios.ts` 使用 `resolveApiBaseUrl()` 初始化 `baseURL`。
|
||||
- `frontend/src/api/authClient.ts` 同步使用同一 baseURL。
|
||||
- 当桌面端服务端地址变化时,需要更新两个 Axios 实例的 `defaults.baseURL`。
|
||||
- 现有 API path 继续保持 `/api/v1/...`,不修改业务 API 文件。
|
||||
|
||||
## 服务端配置入口
|
||||
|
||||
第一阶段需要一个轻量的桌面端服务端设置入口。
|
||||
|
||||
建议实现方式:
|
||||
|
||||
- 桌面端启动时,如果没有服务端地址,则在登录页前展示服务端设置界面。
|
||||
- 登录页提供“服务器设置”入口,允许修改当前服务端地址。
|
||||
- 保存前调用 `${serverUrl}/health` 做连通性检查。
|
||||
- 连通性检查失败时允许用户重新输入,不自动降级到离线模式。
|
||||
- Web 端不显示桌面端服务器设置入口。
|
||||
|
||||
建议新增文件:
|
||||
|
||||
```text
|
||||
frontend/src/views/DesktopServerSettings.vue
|
||||
frontend/src/router/desktopGuard.ts
|
||||
frontend/src/runtime/desktopServerConfig.ts
|
||||
```
|
||||
|
||||
路由策略:
|
||||
|
||||
- 保持现有 Web 路由结构。
|
||||
- 桌面端未配置服务端时,将用户导向 `/desktop/server-settings`。
|
||||
- `/login` 页面中允许打开服务器设置。
|
||||
- Web 端访问 `/desktop/server-settings` 时重定向到 `/login` 或显示不可用状态。
|
||||
|
||||
## 认证和会话策略
|
||||
|
||||
第一阶段保持现有认证机制:
|
||||
|
||||
- 登录仍使用后端登录公钥和加密登录流程。
|
||||
- token 仍通过 `frontend/src/utils/auth.ts` 存储在 `localStorage`。
|
||||
- 会话超时、token keep-alive、401 refresh 仍沿用现有逻辑。
|
||||
|
||||
本阶段只允许做为 Tauri 接入所必需的最小改动:
|
||||
|
||||
- API baseURL 可切换。
|
||||
- 服务端地址变化时清理当前 token 和项目上下文,要求重新登录。
|
||||
- 不引入桌面安全存储;该事项归入第二阶段。
|
||||
|
||||
## Tauri 权限策略
|
||||
|
||||
第一阶段不需要文件系统、Shell、系统通知、自动更新或单实例插件。
|
||||
|
||||
权限原则:
|
||||
|
||||
- 只启用主窗口运行所需的最小 core capability。
|
||||
- 不开放宽泛文件系统权限。
|
||||
- 不开放 shell 执行能力。
|
||||
- 不开放远程页面访问 Tauri command 的能力。
|
||||
- 如确实需要读取 App 版本或平台信息,优先通过窄适配层处理,并明确 capability。
|
||||
|
||||
第一阶段建议不新增自定义 Tauri command。服务端地址配置可以先由前端 `localStorage` 完成。
|
||||
|
||||
## macOS 打包路径
|
||||
|
||||
开发构建:
|
||||
|
||||
```bash
|
||||
cd /Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop/frontend
|
||||
npm run desktop:dev
|
||||
```
|
||||
|
||||
生产构建:
|
||||
|
||||
```bash
|
||||
cd /Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop/frontend
|
||||
npm run desktop:build
|
||||
```
|
||||
|
||||
DMG 构建:
|
||||
|
||||
```bash
|
||||
cd /Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop/frontend
|
||||
npm run desktop:bundle:dmg
|
||||
```
|
||||
|
||||
正式分发前需要:
|
||||
|
||||
- Apple Developer 账号。
|
||||
- macOS 代码签名证书。
|
||||
- notarization 所需 App Store Connect API 或 Apple ID 凭据。
|
||||
- 明确是否分发 DMG,第一阶段推荐 DMG。
|
||||
|
||||
第一阶段可以先产出未签名或 ad-hoc 签名的内部开发构建,但不能把它描述为正式可分发版本。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 初始化 Tauri
|
||||
- 安装 `@tauri-apps/cli`。
|
||||
- 在 `frontend/` 下生成 `src-tauri/`。
|
||||
- 固定基础配置:app name、window title、bundle identifier、devUrl、frontendDist。
|
||||
|
||||
2. 调整 Vite
|
||||
- 引入 Tauri 兼容配置。
|
||||
- 保持 Docker/nginx 开发代理不破坏。
|
||||
- 使用环境变量控制代理和 HMR。
|
||||
|
||||
3. 新增运行时适配层
|
||||
- 添加 `platform.ts`。
|
||||
- 添加 `apiBaseUrl.ts`。
|
||||
- 添加 `desktopServerConfig.ts`。
|
||||
- 新增单元测试覆盖 URL 规范化、Web/Tauri 分支和非法 URL 拒绝。
|
||||
|
||||
4. 改造 API client
|
||||
- `axios.ts` 使用统一 baseURL。
|
||||
- `authClient.ts` 使用统一 baseURL。
|
||||
- 服务端地址变化后刷新 Axios baseURL。
|
||||
- 保持 API path 和业务模块不变。
|
||||
|
||||
5. 添加桌面服务端设置界面
|
||||
- 桌面端未配置服务端时拦截到设置页。
|
||||
- 保存时校验 URL 和 `/health`。
|
||||
- 修改服务端地址时清理当前登录态和项目上下文。
|
||||
|
||||
6. 最小 Tauri 权限
|
||||
- 检查 `capabilities/default.json`。
|
||||
- 移除第一阶段不需要的插件和权限。
|
||||
- 不新增自定义 command,除非实现过程证明必要。
|
||||
|
||||
7. macOS 验证和文档
|
||||
- 运行 Web 构建、类型检查、单元测试。
|
||||
- 运行 Tauri dev。
|
||||
- 验证 macOS 打包。
|
||||
- 补充桌面端运行说明。
|
||||
|
||||
## 验收清单
|
||||
|
||||
功能验收:
|
||||
|
||||
- macOS 桌面 App 可以启动。
|
||||
- 首次启动未配置服务端时进入服务器设置。
|
||||
- 服务端地址保存前会校验 `/health`。
|
||||
- 配置有效服务端后可以登录。
|
||||
- 登录后能进入项目列表和项目工作区。
|
||||
- 现有权限控制、401 refresh、会话超时行为与 Web 端一致。
|
||||
- 切换服务端地址会清理当前登录态。
|
||||
|
||||
工程验收:
|
||||
|
||||
- `npm run build` 通过。
|
||||
- `npm run type-check` 通过。
|
||||
- `npm run test:unit` 通过,或明确记录失败原因。
|
||||
- `npm run desktop:dev` 可启动 macOS 窗口。
|
||||
- `npm run desktop:build` 可完成构建。
|
||||
- Web 端 Docker/nginx 访问不回归。
|
||||
|
||||
边界验收:
|
||||
|
||||
- 没有新增本地业务数据缓存。
|
||||
- 没有内嵌后端服务。
|
||||
- 没有本地数据库。
|
||||
- 没有离线队列或同步机制。
|
||||
- Tauri API 没有散落在业务页面中。
|
||||
- Tauri capability 没有开放第一阶段不需要的文件系统或 shell 权限。
|
||||
|
||||
## 风险和决策点
|
||||
|
||||
- bundle identifier 需要正式确认,建议在第一阶段实现前由产品或组织负责人定稿。
|
||||
- 桌面端连接公开 CTMS 服务时,生产环境应使用 HTTPS;本地开发可允许 localhost HTTP。
|
||||
- 当前后端 CORS 是宽松配置。第一阶段可先不改后端,但正式分发前应评估是否收紧允许来源。
|
||||
- token 第一阶段仍在 `localStorage`,这是有意识的阶段性选择;安全存储迁移必须排入第二阶段。
|
||||
- macOS 正式分发需要 Apple Developer、签名和 notarization,不应等到最后一天处理。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- Tauri 创建项目与向现有前端加入 Tauri:https://tauri.app/start/create-project/
|
||||
- Tauri + Vite 配置:https://tauri.app/start/frontend/vite/
|
||||
- Tauri capabilities:https://tauri.app/security/capabilities/
|
||||
- Tauri DMG 分发:https://tauri.app/distribute/dmg/
|
||||
- Tauri macOS 签名与公证:https://tauri.app/distribute/sign/macos/
|
||||
- Tauri updater 资料,供第二阶段使用:https://tauri.app/plugin/updater/
|
||||
@@ -0,0 +1,160 @@
|
||||
# CTMS 桌面端第二阶段执行设计
|
||||
|
||||
## 范围与边界
|
||||
|
||||
第二阶段基于已合入 `dev` 的 Tauri 基线推进。每个评审单元从最新 `dev` 创建短期 `codex/*` 分支,合入后删除,不建立长期桌面主线。
|
||||
|
||||
本阶段只增强在线桌面客户端能力:
|
||||
|
||||
- 安全运行时与诊断。
|
||||
- 原生文件选择、保存、打开。
|
||||
- 服务端持久系统通知。
|
||||
- 签名更新、发布流水线与 Windows 打包准备。
|
||||
|
||||
本阶段明确不实现离线登录、离线缓存、本地业务数据库、本地业务队列、后台业务同步或本地权限裁决。所有业务数据、权限、审计和认证判定仍以 FastAPI 后端为准。
|
||||
|
||||
## 运行时边界
|
||||
|
||||
共享业务模块只能通过 `frontend/src/runtime/` 使用平台能力。非 runtime 模块不得直接导入 Tauri API。
|
||||
|
||||
`ClientRuntime` 第二阶段能力包括:
|
||||
|
||||
- `secureSessionStorage`
|
||||
- `files`
|
||||
- `notifications`
|
||||
- `updates`
|
||||
- `capabilities.secureSessionStorage/nativeFiles/systemNotifications/automaticUpdates`
|
||||
|
||||
能力标志只在对应运行时可用或初始化成功后开启。Web 端继续使用浏览器能力,不申请系统通知权限,不启动桌面通知轮询,也不触发 updater。
|
||||
|
||||
## 安全会话存储
|
||||
|
||||
桌面端 token 存入系统凭据库:
|
||||
|
||||
- macOS:Keychain。
|
||||
- Windows:Credential Manager。
|
||||
|
||||
Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。
|
||||
|
||||
应用挂载前异步初始化 token:
|
||||
|
||||
1. Web 端继续读取 `localStorage.ctms_token`。
|
||||
2. 桌面端先删除 legacy `localStorage.ctms_token`。
|
||||
3. 若 legacy token 仍有效,则迁移到系统凭据库。
|
||||
4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。
|
||||
|
||||
登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。
|
||||
|
||||
## 原生文件能力
|
||||
|
||||
公共接口固定为:
|
||||
|
||||
```ts
|
||||
pickFiles(options): Promise<File[]>
|
||||
saveFile({ suggestedName, mimeType, data }): Promise<"saved" | "cancelled">
|
||||
openFile({ suggestedName, mimeType, data }): Promise<void>
|
||||
```
|
||||
|
||||
桌面端使用 Tauri dialog、fs、opener 插件。权限范围只覆盖用户当次选择的文件路径和 `$TEMP/ctms-desktop/**`,不启用 persisted-scope,不开放目录遍历或 shell。外部打开文件时写入随机临时目录,并使用净化后的文件名;启动和退出时清理临时目录,用户主动保存的文件不自动删除。
|
||||
|
||||
下载与预览统一先通过 Axios Bearer 请求取得 Blob,再交给文件适配器。附件全局下载接口只接受 `Authorization` header,不再接受 `?token=`。
|
||||
|
||||
## 系统通知
|
||||
|
||||
通知订阅由用户在个人设置中主动开启。开启时才请求 OS 权限并创建服务端订阅;`enabled_at` 设为当前时间,不补发历史分发记录。关闭后客户端停止领取通知,重新开启时重新设定起点。
|
||||
|
||||
后端新增:
|
||||
|
||||
- `desktop_notification_subscriptions`
|
||||
- `desktop_notification_deliveries`
|
||||
|
||||
API:
|
||||
|
||||
- `GET/PUT /api/v1/desktop-notifications/subscription`
|
||||
- `POST /api/v1/desktop-notifications/claim`
|
||||
- `POST /api/v1/desktop-notifications/ack`
|
||||
- `POST /api/v1/desktop-notifications/{distribution_id}/read`
|
||||
|
||||
claim 跨有效项目查询匹配当前用户或角色的活动文件分发,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。
|
||||
|
||||
系统通知正文只显示通用内容:
|
||||
|
||||
- 标题:`CTMS 文件更新`
|
||||
- 正文:`有新的文件版本待查看`
|
||||
|
||||
项目、文件、版本等详细信息仅在应用内列表显示,避免锁屏泄露。
|
||||
|
||||
## 诊断元数据与 CORS
|
||||
|
||||
Axios 请求附加:
|
||||
|
||||
- `X-CTMS-Client-Type`
|
||||
- `X-CTMS-Client-Version`
|
||||
- `X-CTMS-Client-Platform`
|
||||
- `X-CTMS-Build-Channel`
|
||||
- `X-CTMS-Build-Commit`
|
||||
|
||||
后端安全访问日志保存这些 nullable 字段,并支持按客户端类型/版本筛选。这些字段只用于诊断与排障,不参与授权。
|
||||
|
||||
CORS origin 由环境变量白名单控制,并显式允许桌面 origin、开发 origin 和上述请求头。
|
||||
|
||||
Tauri CSP 禁止远程脚本和 shell 入口,仅允许 HTTPS API、本地开发地址、blob 预览与必要资源。
|
||||
|
||||
## 单实例
|
||||
|
||||
单实例插件必须最先注册。重复启动时只恢复、显示并聚焦主窗口,不处理命令行参数、深链或业务动作。
|
||||
|
||||
## 自动更新与发布
|
||||
|
||||
正式 release 构建启用 updater。客户端从当前 CTMS origin 派生固定清单路径:
|
||||
|
||||
```text
|
||||
/desktop-updates/stable/latest.json
|
||||
```
|
||||
|
||||
生产只允许 HTTPS;本地测试只允许 localhost HTTP。
|
||||
|
||||
更新检查策略:
|
||||
|
||||
- 启动延迟 30 秒检查。
|
||||
- 之后每 6 小时检查。
|
||||
- 发现更新时展示版本和发布说明。
|
||||
- 用户确认后下载、验签、安装并重启。
|
||||
- 用户选择“稍后”后,同版本 24 小时内不再提示。
|
||||
- 不做静默安装,不中断正在录入的业务流程。
|
||||
|
||||
Tauri 配置必须嵌入 updater 公钥并生成 updater artifacts。生产私钥只能存放在组织密钥库或 CI secret,不进仓库。macOS 更新制品为 `.app.tar.gz` 与 `.sig`。
|
||||
|
||||
release tag 流水线应从同一提交构建 Web 与桌面端:
|
||||
|
||||
1. 校验 `frontend/package.json`、Tauri 配置、Cargo manifest/lock 版本一致。
|
||||
2. 构建 macOS Universal。
|
||||
3. 完成 Apple 签名和公证。
|
||||
4. 使用 updater 私钥签名更新包。
|
||||
5. 生成 DMG、更新包、签名、`latest.json` 与校验清单。
|
||||
6. 先上传不可变制品,最后原子替换 `latest.json`。
|
||||
|
||||
`latest.json` 同时提供 `darwin-aarch64` 和 `darwin-x86_64`,指向同一个 Universal 更新制品。
|
||||
|
||||
## Windows 准备
|
||||
|
||||
第二阶段只验证 Windows x64 NSIS 构建兼容,不发布正式 Windows 安装包。
|
||||
|
||||
验证范围:
|
||||
|
||||
- Windows Credential Manager。
|
||||
- 路径净化与临时目录限制。
|
||||
- 系统通知编译兼容。
|
||||
- updater 编译兼容。
|
||||
- WebView2 前置条件。
|
||||
- 用户级安装假设。
|
||||
- 后续代码签名要求。
|
||||
|
||||
## 验收重点
|
||||
|
||||
- `localStorage`、URL、日志和系统通知正文不包含 token。
|
||||
- 附件下载不再接受 query-token。
|
||||
- 业务模块不直接导入 Tauri API。
|
||||
- Web 构建、类型检查和单测不回归。
|
||||
- macOS `.app` 构建可重复。
|
||||
- Keychain 会话、文件入口、通知权限、重复启动聚焦和签名更新链路完成端到端验证。
|
||||
@@ -0,0 +1,144 @@
|
||||
# CTMS 桌面端项目计划书
|
||||
|
||||
## 目的
|
||||
|
||||
本文档是 CTMS 桌面端工作的长期方向约束。每次开始任何桌面端相关任务前,都必须先阅读本文档,包括 Tauri 初始化、macOS 打包、Windows 适配、桌面端存储、文件集成、系统通知、安全边界和发布流程。
|
||||
|
||||
桌面端必须服务于现有 CTMS Web 应用和后端架构。目标是为当前 CTMS 服务提供原生桌面入口,而不是创建一个独立的离线产品。
|
||||
|
||||
## 不可突破的边界
|
||||
|
||||
- 技术路线固定为 Tauri。
|
||||
- 第一开发目标是 macOS 桌面端。
|
||||
- Windows 仍只作为第二阶段兼容性验证目标,未获明确批准前不发布正式安装包。
|
||||
- 当前桌面端工作只允许在第一、二阶段边界内做修复、稳定化、体验收口和发布准备,不新增第三阶段能力。
|
||||
- 不做离线功能。
|
||||
- 不在桌面 App 内嵌本地后端服务。
|
||||
- 不在桌面 App 内嵌或分发本地数据库来保存 CTMS 业务数据。
|
||||
- 不实现离线同步、冲突解决、本地业务数据队列、本地优先工作流。
|
||||
- 不把 CTMS 业务 UI 拆成一套独立的桌面端产品;除非桌面能力确实需要小范围适配层。
|
||||
- FastAPI 后端仍是业务权威来源。权限裁决、审计判断、认证、业务数据持久化都保持在服务端。
|
||||
|
||||
## 当前技术基线
|
||||
|
||||
桌面端工作基于当前 CTMS Web 技术栈:
|
||||
|
||||
- 前端:Vue 3、Vite、TypeScript、Element Plus、Pinia、Vue Router、Axios、ECharts。
|
||||
- 后端:FastAPI、Uvicorn、SQLAlchemy、Alembic。
|
||||
- 数据库:PostgreSQL。
|
||||
- 当前部署:Docker Compose、nginx 托管前端静态资源、nginx 反代 `/api`。
|
||||
|
||||
桌面端应复用现有前端代码和 API 契约。任何共享适配层都应保持小而明确,并可测试。
|
||||
|
||||
## 已完成阶段边界
|
||||
|
||||
第一阶段 macOS 在线桌面壳和第二阶段原生能力主体改造已经形成。详细历史方案见 [`desktop-phase-1-design.md`](desktop-phase-1-design.md) 和 [`desktop-phase-2-design.md`](desktop-phase-2-design.md)。
|
||||
|
||||
后续不再按第一阶段空白项目初始化 Tauri,也不再扩展第二阶段以外的新桌面产品能力。当前允许推进的工作仅包括:
|
||||
|
||||
- 修复既有 Tauri、运行时适配层、文件、通知、凭据、更新、菜单/快捷键和打包问题。
|
||||
- 稳定 Web 与 Desktop 共用业务代码,确保平台差异继续收敛在 `frontend/src/runtime/` 后面。
|
||||
- 完成 macOS 正式发布前的签名、公证、updater 签名、制品发布、CI 门禁和人工回归。
|
||||
- 做 Windows 第二阶段兼容性验证,但不发布正式 Windows 安装包。
|
||||
|
||||
仍然不允许:
|
||||
|
||||
- 离线登录、离线浏览、离线队列、离线同步或本地优先工作流。
|
||||
- 本地 PostgreSQL、SQLite、IndexedDB 业务数据缓存或本地 API 镜像。
|
||||
- 内嵌 Python/FastAPI 后端服务。
|
||||
- 绕过后端做本地权限裁决、本地审计缓存或审计回放。
|
||||
- 为桌面端复制或重写一套独立业务 UI。
|
||||
|
||||
## 架构方向
|
||||
|
||||
使用运行时适配层,不在业务页面里散落平台判断。
|
||||
|
||||
当前已采用并必须继续保持的适配层边界:
|
||||
|
||||
- `platform`:识别 Web、macOS 桌面端、Windows 桌面端。
|
||||
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
|
||||
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
|
||||
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
|
||||
- `files`:隔离浏览器上传下载与原生文件能力。
|
||||
- `notifications`:隔离 Web 通知与桌面系统通知。
|
||||
- `updates`:隔离桌面自动更新检查与安装入口。
|
||||
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
|
||||
- `desktopMenu` 和 `desktopUiPreferences`:承接桌面菜单命令、最近访问和收藏等桌面体验状态。
|
||||
- `clientRuntime`:作为业务侧获取平台能力的聚合入口。
|
||||
|
||||
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
|
||||
|
||||
## 安全与合规方向
|
||||
|
||||
- 将桌面端视为受监管业务系统的在线客户端。
|
||||
- 非本地服务连接优先使用 HTTPS。
|
||||
- 认证与授权决策保留在后端。
|
||||
- 审计敏感决策保留在后端。
|
||||
- 敏感凭据必须继续使用明确批准的安全存储方案。
|
||||
- 不向前端暴露宽泛文件系统访问权限。
|
||||
- Tauri 权限保持最小化,并按功能精确授权。
|
||||
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
|
||||
|
||||
## 分支与工作区
|
||||
|
||||
桌面端工作在以下位置开发:
|
||||
|
||||
- Worktree:`/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop`
|
||||
- 短期分支:Agent 默认使用 `codex/<任务名称>`,也可按任务类型使用 `feature/*`、`fix/*`、`docs/*` 或 `release-prep/*`;所有普通桌面端工作都必须从最新 `dev` 创建
|
||||
|
||||
当前代码状态:Tauri 基线和第二阶段原生能力已经形成,后续桌面端工作默认是第二阶段范围内的修复、稳定化、体验收口和发布准备。`codex/ctms-desktop` 是历史临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。
|
||||
|
||||
## 2026-07-01 审查结论与后续方向
|
||||
|
||||
本轮审查结论:桌面端已经完成从 Tauri 接入基线到第二阶段原生能力的主体改造,不再按空白桌面项目推进。当前重点不是扩展离线或本地业务能力,而是围绕既有在线桌面客户端做准发布稳定化。
|
||||
|
||||
已形成的能力边界:
|
||||
|
||||
- Tauri 工程、macOS App/DMG/updater artifacts 配置已经存在。
|
||||
- `frontend/src/runtime/` 已作为平台能力统一入口,业务代码不得绕过该适配层直接使用 Tauri API。
|
||||
- 桌面服务器地址、API baseURL、客户端元数据请求头、安全 session 存储、原生文件能力、系统通知、单实例、菜单/快捷键和自动更新入口已经形成。
|
||||
- 后端已经包含桌面通知订阅/投递状态、相关 API 和客户端诊断请求头支持。
|
||||
- 已有 `npm run version:check`、`npm run runtime:check`、`npm run desktop:release:check` 等门禁用于约束版本、运行时边界和桌面发布安全边界。
|
||||
|
||||
后续优化优先级:
|
||||
|
||||
1. 发布稳定化:补齐 macOS 签名、公证、组织 updater 私钥签名、release tag 构建变量注入、不可变制品上传和 `latest.json` 原子替换。
|
||||
2. 端到端回归:按 `docs/audits/desktop-release-stabilization-checklist.md` 覆盖服务器配置、服务器切换清会话、Keychain/凭据库、附件上传下载、系统通知、单实例和自动更新失败恢复。
|
||||
3. 安全复审:持续确认 token 不进入 URL、日志、系统通知正文、下载链接或明文持久化;Tauri command、capability、CSP 和 updater 改动必须同步评估发布门禁。
|
||||
4. 桌面体验收口:重点检查登录、服务器设置、个人中心诊断信息、通知开关、更新弹窗和最小窗口 `1180x760` 下的布局稳定性。
|
||||
5. CI 与发布流程:Web 与 Desktop 必须从同一提交、同一语义化版本号和同一正式标签构建;发布候选应执行本文档列出的相关质量门禁。
|
||||
6. Windows 兼容验证:仅作为第二阶段兼容性目标,验证 Credential Manager、路径处理、通知/updater 编译、WebView2 和安装器假设;未获明确批准前不发布正式 Windows 安装包。
|
||||
|
||||
如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。
|
||||
|
||||
## 当前质量门禁
|
||||
|
||||
前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run version:check
|
||||
npm run runtime:check
|
||||
npm run desktop:release:check
|
||||
npm run ui:contract
|
||||
npm run type-check
|
||||
npm run test:unit
|
||||
npm run build
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
正式桌面发布构建仍必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述。文档-only 变更可以不执行完整代码门禁,但必须在结果说明中明确未运行。
|
||||
|
||||
## 每次开发前必须执行的检查
|
||||
|
||||
开始任何桌面端相关任务前:
|
||||
|
||||
- 先阅读本文档。
|
||||
- 确认任务属于第一阶段或第二阶段。
|
||||
- 确认任务不会引入离线能力。
|
||||
- 确认实现不会破坏 Web 运行时。
|
||||
- 确认 Tauri API 使用被隔离在适配层之后,除非有明确记录的理由。
|
||||
- 确认不会重复初始化 Tauri 或绕过既有 `frontend/src/runtime/` 运行时边界。
|
||||
- 涉及 Tauri 权限、CSP、updater、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。
|
||||
|
||||
如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。
|
||||
@@ -0,0 +1,493 @@
|
||||
# CTMS 分支维护与版本更新标准操作规程
|
||||
|
||||
## 一、目的
|
||||
|
||||
本规程用于统一 CTMS 网页端和桌面端的代码提交、分支维护、版本晋级、正式发布及生产热修复流程。
|
||||
|
||||
CTMS 网页端和桌面端属于同一个产品,必须共用:
|
||||
|
||||
- 同一个代码仓库
|
||||
- 同一套业务核心代码
|
||||
- 同一条版本晋级链路
|
||||
- 同一个语义化版本号
|
||||
- 同一个正式发布标签
|
||||
- 同一个源代码提交
|
||||
|
||||
不得为网页端和桌面端分别建立长期开发、测试或发布分支。
|
||||
|
||||
## 二、长期分支职责
|
||||
|
||||
| 分支 | 职责 | 允许进入的内容 | 稳定性要求 |
|
||||
| --- | --- | --- | --- |
|
||||
| `dev` | 日常开发与集成 | 已评审的功能、修复和重构 | 可持续集成 |
|
||||
| `main` | 下一正式版本候选 | 从 `dev` 晋级的完整版本范围、候选版本修复 | 原则上可部署 |
|
||||
| `release` | 当前生产稳定版本 | 从 `main` 验收通过的正式版本、生产热修复 | 最高 |
|
||||
|
||||
默认晋级方向:
|
||||
|
||||
```text
|
||||
功能分支 -> dev -> main -> release -> 正式版本标签
|
||||
```
|
||||
|
||||
禁止以下长期分支:
|
||||
|
||||
```text
|
||||
web-dev
|
||||
desktop-dev
|
||||
web-release
|
||||
desktop-release
|
||||
macos-main
|
||||
windows-main
|
||||
```
|
||||
|
||||
桌面端差异必须放在 `frontend/src/runtime/` 适配层之后,不通过长期分支保存平台差异。
|
||||
|
||||
## 三、临时分支命名
|
||||
|
||||
| 类型 | 命名格式 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 新功能 | `feature/<功能名称>` | `feature/desktop-file-picker` |
|
||||
| 缺陷修复 | `fix/<问题名称>` | `fix/session-timeout` |
|
||||
| 生产热修复 | `hotfix/<问题名称>` | `hotfix/login-loop` |
|
||||
| 文档调整 | `docs/<文档名称>` | `docs/release-sop` |
|
||||
| 发布准备 | `release-prep/<版本号>` | `release-prep/v1.2.0` |
|
||||
| Agent 临时任务 | `codex/<任务名称>` | `codex/desktop-menu-polish` |
|
||||
|
||||
分支名称使用小写英文和连字符,不使用个人姓名、日期或模糊名称。
|
||||
|
||||
## 四、日常功能开发流程
|
||||
|
||||
### 1. 从最新 `dev` 创建分支
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git switch dev
|
||||
git pull --ff-only origin dev
|
||||
git switch -c feature/<功能名称>
|
||||
```
|
||||
|
||||
不得从旧功能分支、`main` 或 `release` 创建普通功能分支。
|
||||
|
||||
### 2. 开发过程中同步 `dev`
|
||||
|
||||
短期分支优先使用变基保持提交清晰:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git rebase origin/dev
|
||||
```
|
||||
|
||||
已经由多人共同使用的分支,不得擅自强制推送。此时可使用合并:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git merge origin/dev
|
||||
```
|
||||
|
||||
### 3. 提交前检查
|
||||
|
||||
前端或桌面端改动至少执行:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run version:check
|
||||
npm run runtime:check
|
||||
npm run desktop:release:check
|
||||
npm run ui:contract
|
||||
npm run type-check
|
||||
npm run test:unit
|
||||
npm run build
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
涉及 Tauri、macOS 打包或桌面适配层时,还必须在 macOS 执行:
|
||||
|
||||
```bash
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
正式桌面发布构建仍必须设置 updater 签名私钥后执行
|
||||
`npm run desktop:build -- --bundles app`。
|
||||
|
||||
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
|
||||
|
||||
### 4. 创建提交
|
||||
|
||||
只暂存本次任务相关文件:
|
||||
|
||||
```bash
|
||||
git status
|
||||
git add <本次任务相关文件>
|
||||
git diff --cached
|
||||
git diff --cached --check
|
||||
git commit -m "<类型>(<范围>): <变更说明>"
|
||||
```
|
||||
|
||||
推荐提交类型:
|
||||
|
||||
| 类型 | 用途 |
|
||||
| --- | --- |
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | 缺陷修复 |
|
||||
| `refactor` | 不改变业务行为的重构 |
|
||||
| `test` | 测试调整 |
|
||||
| `docs` | 文档调整 |
|
||||
| `build` | 构建和依赖调整 |
|
||||
| `ci` | 持续集成调整 |
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
feat(desktop): 增加原生文件选择适配器
|
||||
fix(auth): 修复会话超时后的重复跳转
|
||||
refactor(client): 统一网页端和桌面端运行时入口
|
||||
```
|
||||
|
||||
一次提交只处理一个明确目的。不得将无关格式化、个人配置或临时产物混入提交。
|
||||
|
||||
### 5. 推送并创建合并请求
|
||||
|
||||
```bash
|
||||
git push -u origin feature/<功能名称>
|
||||
```
|
||||
|
||||
创建:
|
||||
|
||||
```text
|
||||
feature/<功能名称> -> dev
|
||||
```
|
||||
|
||||
合并要求:
|
||||
|
||||
- 代码评审通过
|
||||
- 必要测试通过
|
||||
- 客户端质量门禁通过
|
||||
- 没有误提交密钥、环境文件或构建产物
|
||||
- 桌面能力符合第一阶段或第二阶段边界
|
||||
|
||||
功能分支进入 `dev` 可使用合并请求合并或变基合并。提交过于零散时应先整理。
|
||||
|
||||
### 6. 合并后清理
|
||||
|
||||
确认改动已经进入远程 `dev` 后删除临时分支:
|
||||
|
||||
```bash
|
||||
git switch dev
|
||||
git pull --ff-only origin dev
|
||||
git branch -d feature/<功能名称>
|
||||
git push origin --delete feature/<功能名称>
|
||||
```
|
||||
|
||||
工作树正在使用的分支不能直接删除,应先切换分支或移除对应工作树。
|
||||
|
||||
## 五、历史桌面集成分支约束
|
||||
|
||||
`codex/ctms-desktop` 是第一阶段 Tauri 基线使用过的历史临时集成分支,不作为当前桌面端工作线,也不作为长期桌面主线。
|
||||
|
||||
当前约束:
|
||||
|
||||
- 不得继续向 `codex/ctms-desktop` 提交、变基或推送新的桌面端工作。
|
||||
- 如本地或远程仍保留该分支,只能用于追溯历史或在确认已合入 `dev` 后删除。
|
||||
- 后续桌面功能、修复、稳定化和发布准备必须从最新 `dev` 创建短期 `feature/*`、`fix/*`、`docs/*`、`release-prep/*` 或 `codex/*` 分支。
|
||||
- Agent 创建分支默认使用 `codex/<任务名称>`,并在任务合入 `dev` 后删除。
|
||||
- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行分支切换、提交、推送或变基前必须先确认目标基线和处理方式。
|
||||
|
||||
## 六、从 `dev` 晋级到 `main`
|
||||
|
||||
当一个版本范围在 `dev` 完成集成后,创建:
|
||||
|
||||
```text
|
||||
dev -> main
|
||||
```
|
||||
|
||||
进入 `main` 前必须确认:
|
||||
|
||||
- 本版本范围已经冻结
|
||||
- 未完成功能已经排除或关闭入口
|
||||
- 前后端测试通过
|
||||
- 网页端构建通过
|
||||
- macOS 桌面端构建通过
|
||||
- 数据库迁移经过验证
|
||||
- 已知风险和回滚方式已记录
|
||||
|
||||
正式创建晋级合并请求前,应按照第七节完成统一版本号更新,并确保版本提交已经进入 `dev`。
|
||||
|
||||
按照当前仓库治理规则,`dev` 进入 `main` 使用压缩合并,并使用版本候选级提交说明:
|
||||
|
||||
```text
|
||||
release(main): 准备 v1.2.0 候选版本
|
||||
```
|
||||
|
||||
不得从功能分支直接跳过 `dev` 合并到 `main`。
|
||||
|
||||
## 七、统一更新客户端版本
|
||||
|
||||
网页端和桌面端只能使用同一个产品版本号。
|
||||
|
||||
从最新 `dev` 创建发布准备分支:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git switch dev
|
||||
git pull --ff-only origin dev
|
||||
git switch -c release-prep/v1.2.0
|
||||
```
|
||||
|
||||
统一更新版本并提交:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run version:set -- 1.2.0
|
||||
npm run version:check
|
||||
cd ..
|
||||
git add frontend/package.json frontend/package-lock.json frontend/src-tauri/tauri.conf.json
|
||||
git add frontend/src-tauri/Cargo.toml frontend/src-tauri/Cargo.lock
|
||||
git commit -m "build(release): 更新客户端版本至 v1.2.0"
|
||||
git push -u origin release-prep/v1.2.0
|
||||
```
|
||||
|
||||
创建 `release-prep/v1.2.0 -> dev` 合并请求。合并后再执行 `dev -> main` 的版本晋级。
|
||||
|
||||
该命令同步更新:
|
||||
|
||||
- `frontend/package.json`
|
||||
- `frontend/package-lock.json`
|
||||
- `frontend/src-tauri/tauri.conf.json`
|
||||
- `frontend/src-tauri/Cargo.toml`
|
||||
- `frontend/src-tauri/Cargo.lock`
|
||||
|
||||
版本号遵循:
|
||||
|
||||
| 类型 | 示例 | 使用场景 |
|
||||
| --- | --- | --- |
|
||||
| 主版本 | `2.0.0` | 不兼容变更或重大架构调整 |
|
||||
| 次版本 | `1.3.0` | 向后兼容的新功能 |
|
||||
| 修订版本 | `1.2.1` | 向后兼容的缺陷修复 |
|
||||
|
||||
禁止单独设置桌面端版本号。
|
||||
|
||||
## 八、从 `main` 发布到 `release`
|
||||
|
||||
候选版本验收通过后,创建:
|
||||
|
||||
```text
|
||||
main -> release
|
||||
```
|
||||
|
||||
按照当前仓库治理规则,使用普通合并提交,保留候选版本与生产版本之间的关系。
|
||||
|
||||
合并前必须确认:
|
||||
|
||||
- 回归测试通过
|
||||
- 数据库迁移和回滚方案确认
|
||||
- 网页端生产构建通过
|
||||
- 桌面端生产构建通过
|
||||
- 发布说明完成
|
||||
- 生产配置和密钥不在仓库中
|
||||
- 正式版本号已经统一
|
||||
|
||||
合并后立即在 `release` 的准确提交上创建标签:
|
||||
|
||||
```bash
|
||||
git switch release
|
||||
git pull --ff-only origin release
|
||||
git tag -a v1.2.0 -m "CTMS v1.2.0"
|
||||
git push origin v1.2.0
|
||||
```
|
||||
|
||||
网页端和桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。
|
||||
|
||||
发布记录至少包含:
|
||||
|
||||
- 产品版本号
|
||||
- Git 标签
|
||||
- 完整提交编号
|
||||
- 网页端制品编号
|
||||
- 桌面端制品编号
|
||||
- 数据库迁移版本
|
||||
- 发布日期和负责人
|
||||
|
||||
## 九、生产热修复流程
|
||||
|
||||
### 1. 从 `release` 创建热修复分支
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git switch release
|
||||
git pull --ff-only origin release
|
||||
git switch -c hotfix/<问题名称>
|
||||
```
|
||||
|
||||
热修复只能包含解决生产问题所需的最小改动,不得顺带加入新功能或大规模重构。
|
||||
|
||||
### 2. 更新修订版本
|
||||
|
||||
例如从 `1.2.0` 更新到 `1.2.1`:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run version:set -- 1.2.1
|
||||
npm run version:check
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 3. 验证并提交
|
||||
|
||||
```bash
|
||||
git add <热修复相关文件和版本文件>
|
||||
git diff --cached --check
|
||||
git commit -m "fix(<范围>): <生产问题说明>"
|
||||
git push -u origin hotfix/<问题名称>
|
||||
```
|
||||
|
||||
创建:
|
||||
|
||||
```text
|
||||
hotfix/<问题名称> -> release
|
||||
```
|
||||
|
||||
### 4. 合并并创建标签
|
||||
|
||||
热修复合并到 `release` 并验证后创建 `v1.2.1` 标签。
|
||||
|
||||
### 5. 强制回合并
|
||||
|
||||
生产热修复必须立即回合并:
|
||||
|
||||
```text
|
||||
release -> main -> dev
|
||||
```
|
||||
|
||||
不得假设 `main` 或 `dev` 已经包含相同修复。发生冲突时必须立即解决并在合并请求中记录原因。
|
||||
|
||||
## 十、冲突处理规则
|
||||
|
||||
发生冲突时:
|
||||
|
||||
1. 先确认冲突两侧的业务意图。
|
||||
2. 不使用整文件覆盖方式跳过判断。
|
||||
3. 保留双方仍然有效的修改。
|
||||
4. 重新执行受影响测试。
|
||||
5. 在合并请求中记录冲突文件和处理结果。
|
||||
|
||||
禁止使用以下方式处理普通同步冲突:
|
||||
|
||||
```bash
|
||||
git reset --hard
|
||||
git checkout -- <文件>
|
||||
```
|
||||
|
||||
除非已经明确确认可以丢弃本地修改,否则不得执行破坏性命令。
|
||||
|
||||
## 十一、分支保护建议
|
||||
|
||||
### `release`
|
||||
|
||||
- 禁止直接推送
|
||||
- 必须通过合并请求
|
||||
- 至少一名评审人批准
|
||||
- 必须通过状态检查
|
||||
- 正式标签只由发布负责人创建
|
||||
|
||||
### `main`
|
||||
|
||||
- 禁止直接推送
|
||||
- 必须通过合并请求
|
||||
- 必须完成回归和构建检查
|
||||
- 只接收版本候选内容
|
||||
|
||||
### `dev`
|
||||
|
||||
- 优先通过合并请求
|
||||
- 必须通过相关测试
|
||||
- 禁止提交密钥和本地配置
|
||||
- 禁止合入明确不可构建的代码
|
||||
|
||||
## 十二、每周分支维护
|
||||
|
||||
每周至少执行一次:
|
||||
|
||||
```bash
|
||||
git fetch --prune origin
|
||||
git branch -vv
|
||||
git log --oneline --decorate --graph --all -30
|
||||
```
|
||||
|
||||
检查事项:
|
||||
|
||||
- 已合并临时分支是否删除
|
||||
- 是否出现未经批准的长期平台分支
|
||||
- `dev`、`main`、`release` 是否符合各自职责
|
||||
- 生产热修复是否已回合并到 `main` 和 `dev`
|
||||
- 版本文件是否一致
|
||||
- 正式标签是否准确指向 `release`
|
||||
- 持续集成门禁是否持续通过
|
||||
|
||||
## 十三、禁止事项
|
||||
|
||||
- 禁止长期维护网页端和桌面端平行分支
|
||||
- 禁止通过复制代码维护桌面端业务页面
|
||||
- 禁止在业务模块中直接使用 Tauri API
|
||||
- 禁止在不同提交上构建同一版本的网页端和桌面端
|
||||
- 禁止未经 `dev` 和 `main` 直接向 `release` 发布普通功能
|
||||
- 禁止生产热修复只进入 `release` 而不回合并
|
||||
- 禁止在正式构建中包含未提交文件
|
||||
- 禁止提交 `.env`、密钥、证书、令牌和个人配置
|
||||
- 禁止提交 `node_modules`、`dist`、Tauri `target` 等构建产物
|
||||
|
||||
## 十四、发布前最终检查清单
|
||||
|
||||
- [ ] 本次发布范围已经冻结
|
||||
- [ ] `dev` 集成测试通过
|
||||
- [ ] `main` 候选版本验收通过
|
||||
- [ ] 网页端与桌面端版本一致
|
||||
- [ ] `npm run version:check` 通过
|
||||
- [ ] `npm run runtime:check` 通过
|
||||
- [ ] `npm run desktop:release:check` 通过
|
||||
- [ ] `npm run ui:contract` 通过
|
||||
- [ ] `npm run type-check` 通过
|
||||
- [ ] `npm run test:unit` 通过
|
||||
- [ ] `npm run build` 通过
|
||||
- [ ] `npm run desktop:build:app` 通过
|
||||
- [ ] 正式桌面发布构建已使用 updater 签名私钥执行
|
||||
- [ ] 数据库迁移与回滚方案确认
|
||||
- [ ] 发布说明完成
|
||||
- [ ] `main -> release` 合并完成
|
||||
- [ ] 正式标签创建在准确的 `release` 提交上
|
||||
- [ ] 网页端和桌面端从同一标签构建
|
||||
- [ ] 发布记录包含版本、标签和完整提交编号
|
||||
|
||||
## 十五、流程速查
|
||||
|
||||
普通功能:
|
||||
|
||||
```text
|
||||
最新 dev
|
||||
-> feature/*
|
||||
-> 开发、测试、评审
|
||||
-> dev
|
||||
-> 删除临时分支
|
||||
```
|
||||
|
||||
正式发布:
|
||||
|
||||
```text
|
||||
dev
|
||||
-> main
|
||||
-> 统一版本号
|
||||
-> 回归与验收
|
||||
-> release
|
||||
-> 创建 vX.Y.Z 标签
|
||||
-> 同一标签构建网页端和桌面端
|
||||
```
|
||||
|
||||
生产热修复:
|
||||
|
||||
```text
|
||||
release
|
||||
-> hotfix/*
|
||||
-> release
|
||||
-> 创建修订版本标签
|
||||
-> main
|
||||
-> dev
|
||||
```
|
||||
@@ -0,0 +1,177 @@
|
||||
# CTMS Web and Desktop Release Guide
|
||||
|
||||
## Release Unit
|
||||
|
||||
CTMS uses one repository, one promotion path, and one product version. Web and
|
||||
Desktop are build targets from the same source commit, not separately versioned
|
||||
products.
|
||||
|
||||
The release identity consists of:
|
||||
|
||||
- semantic version, for example `1.8.0`
|
||||
- Git tag, for example `v1.8.0`
|
||||
- full Git commit SHA
|
||||
- build channel: `dev`, `main`, or `release`
|
||||
- client type: `web` or `desktop`
|
||||
|
||||
## Runtime Boundary
|
||||
|
||||
Shared Vue business code lives under `frontend/src/`. Platform decisions are
|
||||
exposed through `frontend/src/runtime/index.ts`.
|
||||
|
||||
The runtime contract currently provides:
|
||||
|
||||
- API base URL resolution
|
||||
- Web, macOS, Windows, and Linux runtime identification
|
||||
- app version, source commit, build channel, and client type metadata
|
||||
- explicit capability flags
|
||||
- Desktop server address configuration
|
||||
- secure session storage
|
||||
- file picker/save/open adapters
|
||||
- desktop system notification adapters
|
||||
- desktop updater adapters
|
||||
|
||||
Business modules must use this public runtime entry point. They must not inspect
|
||||
Tauri globals or import Tauri packages directly. Native files, notifications,
|
||||
secure session storage, and automatic updates must remain behind
|
||||
`frontend/src/runtime/` and explicit capability flags.
|
||||
|
||||
## Version Change
|
||||
|
||||
Update every client manifest with one command:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run version:set -- 1.8.0
|
||||
npm run version:check
|
||||
```
|
||||
|
||||
This synchronizes:
|
||||
|
||||
- `frontend/package.json`
|
||||
- `frontend/package-lock.json`
|
||||
- `frontend/src-tauri/tauri.conf.json`
|
||||
- `frontend/src-tauri/Cargo.toml`
|
||||
- `frontend/src-tauri/Cargo.lock`
|
||||
|
||||
Manual edits that leave these files inconsistent fail CI.
|
||||
|
||||
## Stabilization Gates
|
||||
|
||||
Client release candidates must pass the shared Web checks and the Desktop
|
||||
release/security gate before promotion:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run version:check
|
||||
npm run release:env:check
|
||||
npm run runtime:check
|
||||
npm run desktop:release:check
|
||||
npm run ui:contract
|
||||
npm run type-check
|
||||
npm run test:unit
|
||||
npm run build
|
||||
npm run desktop:build:app
|
||||
```
|
||||
|
||||
`release:env:check` verifies build channel and commit metadata, and can be
|
||||
made strict for signed Desktop builds with `REQUIRE_DESKTOP_SIGNING=true`.
|
||||
`desktop:release:check` statically verifies the Tauri bundle, updater public
|
||||
key, CSP, capability scopes, command allowlist, query-token ban, generic system
|
||||
notification boundary, CI gate coverage, and secure session token boundary. The
|
||||
full manual release, security, regression, and Desktop UX checklist lives in
|
||||
`docs/audits/desktop-release-stabilization-checklist.md`.
|
||||
|
||||
## Promotion
|
||||
|
||||
1. Merge feature branches into `dev`.
|
||||
2. Require the shared client/Web and macOS Desktop CI jobs to pass.
|
||||
3. Promote the accepted scope from `dev` to `main`.
|
||||
4. Set the release version and complete regression testing on `main`.
|
||||
5. Promote `main` to `release`.
|
||||
6. Create the matching `vX.Y.Z` tag on the accepted `release` commit.
|
||||
7. Build both Web and Desktop artifacts from that exact tag.
|
||||
|
||||
Build metadata is injected by CI:
|
||||
|
||||
```bash
|
||||
VITE_BUILD_CHANNEL=release
|
||||
VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
|
||||
```
|
||||
|
||||
These values support diagnostics but do not replace the semantic version.
|
||||
|
||||
## Desktop Updater
|
||||
|
||||
Formal Desktop release builds must use Tauri updater signatures. The application
|
||||
embeds the updater public key in `frontend/src-tauri/tauri.conf.json`; the
|
||||
private key must live only in the organization key vault or CI secret store.
|
||||
|
||||
Runtime update checks derive the feed from the currently configured CTMS origin:
|
||||
|
||||
```text
|
||||
/desktop-updates/stable/latest.json
|
||||
```
|
||||
|
||||
Production update feeds must use HTTPS. Local update testing may use HTTP only
|
||||
for `localhost`, `127.0.0.1`, or `::1`.
|
||||
|
||||
The release pipeline must:
|
||||
|
||||
1. build from the accepted release tag and commit;
|
||||
2. build macOS Universal desktop artifacts;
|
||||
3. sign and notarize the macOS app;
|
||||
4. produce updater artifacts and `.sig` files with the updater private key;
|
||||
5. generate `latest.json` and a checksum manifest;
|
||||
6. verify the feed with `npm run desktop:update-feed:check -- --feed <latest.json> --artifacts-dir <artifact-dir>`;
|
||||
7. upload immutable artifacts first;
|
||||
8. atomically replace `latest.json` last.
|
||||
|
||||
For Universal macOS artifacts, `latest.json` must provide both
|
||||
`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
|
||||
update package.
|
||||
|
||||
## Windows Build Readiness
|
||||
|
||||
Second-phase Windows work is limited to CI compatibility validation. A
|
||||
`windows-latest` x64 NSIS build may be produced for verification, but it is not
|
||||
a formal deliverable until Windows code signing and release support are
|
||||
approved.
|
||||
|
||||
Windows validation must cover:
|
||||
|
||||
- WebView2 runtime prerequisite behavior;
|
||||
- user-level installer assumptions;
|
||||
- Windows Credential Manager session storage;
|
||||
- path handling and temporary file cleanup;
|
||||
- notification and updater compilation;
|
||||
- future code-signing requirements.
|
||||
|
||||
## Required Checks
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run version:check
|
||||
export VITE_BUILD_CHANNEL=release
|
||||
export VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
|
||||
npm run release:env:check
|
||||
npm run runtime:check
|
||||
npm run desktop:release:check
|
||||
npm run ui:contract
|
||||
npm run type-check
|
||||
npm run test:unit
|
||||
npm run build
|
||||
npm run desktop:build:app
|
||||
export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
|
||||
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
|
||||
export REQUIRE_DESKTOP_SIGNING=true
|
||||
npm run release:env:check
|
||||
npm run desktop:build -- --bundles app
|
||||
npm run desktop:update-feed:check -- --feed src-tauri/target/release/bundle/latest.json --artifacts-dir src-tauri/target/release/bundle
|
||||
```
|
||||
|
||||
The Desktop build must run on macOS for the current first-phase target. A signed
|
||||
or notarized public release additionally requires the Apple credentials defined
|
||||
by the release owner. A formal second-phase desktop release also requires the
|
||||
updater signing key; unsigned internal builds are not formal distributions.
|
||||
@@ -1,6 +1,9 @@
|
||||
# Frontend feature flags and timeline overrides
|
||||
VITE_RUNTIME_ENV=production
|
||||
VITE_ALLOW_INSECURE_DEV_LOGIN=false
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
|
||||
VITE_HMR_CLIENT_PORT=
|
||||
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
|
||||
VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3
|
||||
VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"name": "ctms-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"axios": "^1.6.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
@@ -18,6 +24,7 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
@@ -1354,6 +1361,293 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
|
||||
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
"integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
|
||||
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
||||
@@ -7,11 +7,28 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"desktop:dev": "tauri dev",
|
||||
"desktop:build": "tauri build",
|
||||
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
|
||||
"desktop:bundle:dmg": "tauri build --bundles dmg",
|
||||
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
|
||||
"release:env:check": "node scripts/verify-release-build-env.mjs",
|
||||
"version:check": "node scripts/client-version.mjs --check",
|
||||
"version:set": "node scripts/client-version.mjs --set",
|
||||
"runtime:check": "node scripts/verify-runtime-boundary.mjs",
|
||||
"desktop:release:check": "node scripts/verify-desktop-release.mjs",
|
||||
"test:unit": "vitest run --environment jsdom",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"ui:contract": "node scripts/verify-ui-contract.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"axios": "^1.6.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
@@ -22,6 +39,7 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const paths = {
|
||||
packageJson: new URL("../package.json", import.meta.url),
|
||||
packageLock: new URL("../package-lock.json", import.meta.url),
|
||||
tauriConfig: new URL("../src-tauri/tauri.conf.json", import.meta.url),
|
||||
cargoToml: new URL("../src-tauri/Cargo.toml", import.meta.url),
|
||||
cargoLock: new URL("../src-tauri/Cargo.lock", import.meta.url),
|
||||
};
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
|
||||
const writeJson = async (path, value) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
|
||||
const readCargoPackageVersion = async () => {
|
||||
const cargo = await readFile(paths.cargoToml, "utf8");
|
||||
const packageSection = cargo.match(/\[package\]([\s\S]*?)(?=\n\[|$)/)?.[1];
|
||||
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||
if (!version) throw new Error("Cannot find [package].version in src-tauri/Cargo.toml");
|
||||
return version;
|
||||
};
|
||||
|
||||
const readCargoLockPackageVersion = async () => {
|
||||
const cargoLock = await readFile(paths.cargoLock, "utf8");
|
||||
const packageSection = cargoLock
|
||||
.match(/\[\[package\]\]([\s\S]*?)(?=\n\[\[package\]\]|$)/g)
|
||||
?.find((section) => /^name\s*=\s*"ctms-desktop"$/m.test(section));
|
||||
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||
if (!version) throw new Error("Cannot find ctms-desktop version in src-tauri/Cargo.lock");
|
||||
return version;
|
||||
};
|
||||
|
||||
const readVersions = async () => {
|
||||
const [packageJson, packageLock, tauriConfig, cargoVersion, cargoLockVersion] = await Promise.all([
|
||||
readJson(paths.packageJson),
|
||||
readJson(paths.packageLock),
|
||||
readJson(paths.tauriConfig),
|
||||
readCargoPackageVersion(),
|
||||
readCargoLockPackageVersion(),
|
||||
]);
|
||||
return {
|
||||
"package.json": packageJson.version,
|
||||
"package-lock.json": packageLock.version,
|
||||
"package-lock.json root": packageLock.packages?.[""]?.version,
|
||||
"tauri.conf.json": tauriConfig.version,
|
||||
"Cargo.toml": cargoVersion,
|
||||
"Cargo.lock": cargoLockVersion,
|
||||
};
|
||||
};
|
||||
|
||||
const assertVersionsMatch = async () => {
|
||||
const versions = await readVersions();
|
||||
const uniqueVersions = new Set(Object.values(versions));
|
||||
if (uniqueVersions.size !== 1 || uniqueVersions.has(undefined)) {
|
||||
const details = Object.entries(versions)
|
||||
.map(([file, version]) => ` ${file}: ${version ?? "<missing>"}`)
|
||||
.join("\n");
|
||||
throw new Error(`Client versions are not synchronized:\n${details}`);
|
||||
}
|
||||
const [version] = uniqueVersions;
|
||||
console.log(`Client version ${version} is synchronized.`);
|
||||
};
|
||||
|
||||
const setVersion = async (version) => {
|
||||
if (!SEMVER_PATTERN.test(version)) {
|
||||
throw new Error(`Invalid semantic version: ${version}`);
|
||||
}
|
||||
|
||||
const [packageJson, packageLock, tauriConfig, tauriConfigSource, cargo, cargoLock] = await Promise.all([
|
||||
readJson(paths.packageJson),
|
||||
readJson(paths.packageLock),
|
||||
readJson(paths.tauriConfig),
|
||||
readFile(paths.tauriConfig, "utf8"),
|
||||
readFile(paths.cargoToml, "utf8"),
|
||||
readFile(paths.cargoLock, "utf8"),
|
||||
]);
|
||||
|
||||
packageJson.version = version;
|
||||
packageLock.version = version;
|
||||
packageLock.packages[""].version = version;
|
||||
|
||||
const tauriVersionPattern = /("version"\s*:\s*")[^"]+(")/;
|
||||
const packageSectionPattern = /(\[package\][\s\S]*?^version\s*=\s*")[^"]+(")/m;
|
||||
const lockPackagePattern =
|
||||
/(\[\[package\]\]\nname\s*=\s*"ctms-desktop"\nversion\s*=\s*")[^"]+(")/m;
|
||||
if (typeof tauriConfig.version !== "string" || !tauriVersionPattern.test(tauriConfigSource)) {
|
||||
throw new Error("Cannot update version in src-tauri/tauri.conf.json");
|
||||
}
|
||||
if (!packageSectionPattern.test(cargo)) {
|
||||
throw new Error("Cannot update [package].version in src-tauri/Cargo.toml");
|
||||
}
|
||||
if (!lockPackagePattern.test(cargoLock)) {
|
||||
throw new Error("Cannot update ctms-desktop version in src-tauri/Cargo.lock");
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
writeJson(paths.packageJson, packageJson),
|
||||
writeJson(paths.packageLock, packageLock),
|
||||
writeFile(paths.tauriConfig, tauriConfigSource.replace(tauriVersionPattern, `$1${version}$2`)),
|
||||
writeFile(paths.cargoToml, cargo.replace(packageSectionPattern, `$1${version}$2`)),
|
||||
writeFile(paths.cargoLock, cargoLock.replace(lockPackagePattern, `$1${version}$2`)),
|
||||
]);
|
||||
console.log(`Updated CTMS Web and Desktop client version to ${version} in ${frontendDir}`);
|
||||
await assertVersionsMatch();
|
||||
};
|
||||
|
||||
const [, , command = "--check", value] = process.argv;
|
||||
|
||||
try {
|
||||
if (command === "--check") {
|
||||
await assertVersionsMatch();
|
||||
} else if (command === "--set" && value) {
|
||||
await setVersion(value);
|
||||
} else {
|
||||
throw new Error("Usage: node scripts/client-version.mjs [--check | --set <semver>]");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { extname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const rootDir = resolve(frontendDir, "..");
|
||||
const sourceDir = resolve(frontendDir, "src");
|
||||
const tauriDir = resolve(frontendDir, "src-tauri");
|
||||
const failures = [];
|
||||
|
||||
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const walk = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
return entry.isDirectory() ? walk(path) : path;
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
};
|
||||
|
||||
const permissionIdentifier = (permission) =>
|
||||
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
|
||||
|
||||
const assertPathScope = (permission, expectedPrefix, description) => {
|
||||
const allow = Array.isArray(permission.allow) ? permission.allow : [];
|
||||
assert(allow.length > 0, `${description} must define an explicit allow list.`);
|
||||
for (const item of allow) {
|
||||
const path = item?.path;
|
||||
assert(
|
||||
typeof path === "string" && path.startsWith(expectedPrefix),
|
||||
`${description} may only allow paths under ${expectedPrefix}; found ${path ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyTauriConfig = async () => {
|
||||
const tauriConfig = await readJson(resolve(tauriDir, "tauri.conf.json"));
|
||||
const targets = tauriConfig.bundle?.targets;
|
||||
const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean);
|
||||
const csp = tauriConfig.app?.security?.csp || "";
|
||||
const cspTokens = csp.split(/[;\s]+/).filter(Boolean);
|
||||
const mainWindow = tauriConfig.app?.windows?.find((window) => window.label === "main") || tauriConfig.app?.windows?.[0];
|
||||
|
||||
assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds.");
|
||||
assert(targetList.includes("app"), "Tauri bundle targets must include app.");
|
||||
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
|
||||
assert(
|
||||
tauriConfig.bundle?.createUpdaterArtifacts === true,
|
||||
"Tauri must create updater artifacts for signed desktop release builds.",
|
||||
);
|
||||
assert(
|
||||
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
|
||||
"Tauri updater public key must be configured.",
|
||||
);
|
||||
assert(csp.includes("default-src 'self'"), "Tauri CSP must keep default-src restricted to self.");
|
||||
assert(csp.includes("object-src 'none'"), "Tauri CSP must disable object-src.");
|
||||
assert(!csp.includes("'unsafe-eval'"), "Tauri CSP must not allow unsafe-eval.");
|
||||
assert(
|
||||
!cspTokens.some((token) => token === "*" || token.includes("://*")),
|
||||
"Tauri CSP must not use wildcard sources.",
|
||||
);
|
||||
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
|
||||
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
|
||||
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
|
||||
};
|
||||
|
||||
const verifyCapabilities = async () => {
|
||||
const capabilitiesDir = resolve(tauriDir, "capabilities");
|
||||
const files = (await readdir(capabilitiesDir)).filter((file) => file.endsWith(".json"));
|
||||
assert(files.length > 0, "At least one Tauri capability file must exist.");
|
||||
|
||||
const bannedPermissions = new Set([
|
||||
"shell:default",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"fs:default",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
]);
|
||||
|
||||
for (const file of files) {
|
||||
const capability = await readJson(resolve(capabilitiesDir, file));
|
||||
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
|
||||
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
|
||||
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`);
|
||||
assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`);
|
||||
}
|
||||
|
||||
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
|
||||
assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`);
|
||||
if (fsScope && typeof fsScope !== "string") {
|
||||
assertPathScope(fsScope, "$TEMP/ctms-desktop/", `${file}: fs:scope`);
|
||||
}
|
||||
|
||||
const openerScope = permissions.find((permission) => permissionIdentifier(permission) === "opener:allow-open-path");
|
||||
assert(Boolean(openerScope), `${file}: opener:allow-open-path must be explicitly scoped.`);
|
||||
if (openerScope && typeof openerScope !== "string") {
|
||||
assertPathScope(openerScope, "$TEMP/ctms-desktop/", `${file}: opener:allow-open-path`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifyRustBoundary = async () => {
|
||||
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
|
||||
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
|
||||
for (const token of forbiddenRust) {
|
||||
assert(!libSource.includes(token), `Rust desktop boundary must not include ${token}.`);
|
||||
}
|
||||
|
||||
const singleInstanceIndex = libSource.indexOf("tauri_plugin_single_instance::init");
|
||||
const dialogIndex = libSource.indexOf("tauri_plugin_dialog::init");
|
||||
assert(singleInstanceIndex >= 0, "Single-instance plugin must be registered.");
|
||||
assert(
|
||||
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
|
||||
"Single-instance plugin must be registered before other desktop plugins.",
|
||||
);
|
||||
|
||||
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
|
||||
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
|
||||
const allowedCommands = [
|
||||
"credentials::credential_get",
|
||||
"credentials::credential_set",
|
||||
"credentials::credential_delete",
|
||||
"updates::desktop_update_check",
|
||||
"updates::desktop_update_install",
|
||||
];
|
||||
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
|
||||
const missing = allowedCommands.filter((command) => !commands.includes(command));
|
||||
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
|
||||
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
|
||||
};
|
||||
|
||||
const verifySourceSafety = async () => {
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]);
|
||||
const files = [
|
||||
...(await walk(sourceDir)),
|
||||
...(await walk(resolve(tauriDir, "src"))),
|
||||
].filter((path) => sourceExtensions.has(extname(path)));
|
||||
|
||||
for (const path of files) {
|
||||
const source = await readFile(path, "utf8");
|
||||
const file = relative(rootDir, path);
|
||||
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
|
||||
assert(
|
||||
!/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source),
|
||||
`${file}: token-related values must not be written to console logs.`,
|
||||
);
|
||||
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
|
||||
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
|
||||
}
|
||||
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
|
||||
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifyNotificationBoundary = async () => {
|
||||
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
|
||||
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
|
||||
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
|
||||
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
|
||||
};
|
||||
|
||||
const verifyUpdaterBoundary = async () => {
|
||||
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8");
|
||||
assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
|
||||
assert(source.includes("desktop updates require HTTPS outside localhost"), "Desktop updater must reject non-local HTTP update feeds.");
|
||||
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
|
||||
};
|
||||
|
||||
const verifyWorkflowGates = async () => {
|
||||
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
|
||||
const requiredCommands = [
|
||||
"npm run version:check",
|
||||
"npm run runtime:check",
|
||||
"npm run desktop:release:check",
|
||||
"npm run ui:contract",
|
||||
"npm run type-check",
|
||||
"npm run test:unit",
|
||||
"npm run build",
|
||||
"npm run desktop:build:app",
|
||||
"npm run release:env:check",
|
||||
];
|
||||
|
||||
for (const command of requiredCommands) {
|
||||
assert(workflow.includes(command), `Client quality gates workflow must run ${command}.`);
|
||||
}
|
||||
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
|
||||
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
|
||||
};
|
||||
|
||||
await verifyTauriConfig();
|
||||
await verifyCapabilities();
|
||||
await verifyRustBoundary();
|
||||
await verifySourceSafety();
|
||||
await verifyNotificationBoundary();
|
||||
await verifyUpdaterBoundary();
|
||||
await verifyWorkflowGates();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Desktop release gate passed.");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const failures = [];
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const optionValue = (name) => {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
};
|
||||
|
||||
const feedPath = resolve(
|
||||
frontendDir,
|
||||
optionValue("--feed") || process.env.DESKTOP_UPDATE_FEED || "src-tauri/target/release/bundle/latest.json",
|
||||
);
|
||||
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
|
||||
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const assertFileExists = async (path, description) => {
|
||||
try {
|
||||
await access(path);
|
||||
} catch {
|
||||
fail(`${description} does not exist: ${path}`);
|
||||
}
|
||||
};
|
||||
|
||||
let feed;
|
||||
try {
|
||||
feed = JSON.parse(await readFile(feedPath, "utf8"));
|
||||
} catch (error) {
|
||||
fail(`Cannot read desktop update feed ${feedPath}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (feed) {
|
||||
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
|
||||
const platforms = feed.platforms || {};
|
||||
const darwinArm = platforms["darwin-aarch64"];
|
||||
const darwinIntel = platforms["darwin-x86_64"];
|
||||
|
||||
assert(normalizedFeedVersion === packageInfo.version, `latest.json version must match package version ${packageInfo.version}.`);
|
||||
assert(Boolean(feed.pub_date || feed.pubDate), "latest.json must include a publication date.");
|
||||
assert(Boolean(darwinArm), "latest.json must include darwin-aarch64.");
|
||||
assert(Boolean(darwinIntel), "latest.json must include darwin-x86_64.");
|
||||
|
||||
const entries = [
|
||||
["darwin-aarch64", darwinArm],
|
||||
["darwin-x86_64", darwinIntel],
|
||||
];
|
||||
|
||||
for (const [platform, entry] of entries) {
|
||||
const rawUrl = entry?.url;
|
||||
const signature = entry?.signature;
|
||||
assert(typeof signature === "string" && signature.length > 80, `${platform} must include an updater signature.`);
|
||||
assert(typeof rawUrl === "string" && rawUrl.length > 0, `${platform} must include an artifact URL.`);
|
||||
if (!rawUrl) continue;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
fail(`${platform} artifact URL is invalid: ${rawUrl}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
assert(url.protocol === "https:", `${platform} artifact URL must use HTTPS.`);
|
||||
assert(!/[?&]token=/i.test(url.search), `${platform} artifact URL must not contain token query parameters.`);
|
||||
assert(!url.pathname.endsWith("/latest.json"), `${platform} artifact URL must point to an immutable artifact, not latest.json.`);
|
||||
assert(url.pathname.includes(packageInfo.version), `${platform} artifact URL must include version ${packageInfo.version}.`);
|
||||
if (expectedBaseUrl) {
|
||||
assert(rawUrl.startsWith(expectedBaseUrl), `${platform} artifact URL must start with ${expectedBaseUrl}.`);
|
||||
}
|
||||
|
||||
if (artifactDir) {
|
||||
const artifactPath = resolve(artifactDir, basename(url.pathname));
|
||||
await assertFileExists(artifactPath, `${platform} updater artifact`);
|
||||
await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`);
|
||||
}
|
||||
}
|
||||
|
||||
if (darwinArm?.url && darwinIntel?.url) {
|
||||
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact.");
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Desktop update feed check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Desktop update feed check passed.");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const failures = [];
|
||||
|
||||
const allowedChannels = new Set(["dev", "main", "release", "local"]);
|
||||
const fullShaPattern = /^[0-9a-f]{40}$/i;
|
||||
const semverTag = `v${packageInfo.version}`;
|
||||
|
||||
const env = process.env;
|
||||
const channel = env.VITE_BUILD_CHANNEL || "local";
|
||||
const commit = env.VITE_BUILD_COMMIT || "local";
|
||||
const isCi = env.CI === "true" || env.GITHUB_ACTIONS === "true";
|
||||
const isTagBuild = env.GITHUB_REF_TYPE === "tag";
|
||||
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const requireEnv = (name) => {
|
||||
assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
|
||||
};
|
||||
|
||||
assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...allowedChannels].join(", ")}.`);
|
||||
|
||||
if (isCi || isReleaseBuild) {
|
||||
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
|
||||
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
|
||||
}
|
||||
|
||||
if (env.GITHUB_SHA) {
|
||||
assert(
|
||||
commit === env.GITHUB_SHA || commit === "local",
|
||||
"VITE_BUILD_COMMIT must match GITHUB_SHA when GitHub Actions provides a source commit.",
|
||||
);
|
||||
}
|
||||
|
||||
if (isTagBuild) {
|
||||
assert(env.GITHUB_REF_NAME === semverTag, `Release tag must be ${semverTag}; found ${env.GITHUB_REF_NAME || "<missing>"}.`);
|
||||
assert(channel === "release", "Release tag builds must set VITE_BUILD_CHANNEL=release.");
|
||||
}
|
||||
|
||||
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
|
||||
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
|
||||
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
|
||||
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
|
||||
requireEnv("APPLE_ID");
|
||||
requireEnv("APPLE_PASSWORD");
|
||||
requireEnv("APPLE_TEAM_ID");
|
||||
assert(
|
||||
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
|
||||
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Release build environment check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Release build environment check passed.");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { extname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const sourceDir = resolve(frontendDir, "src");
|
||||
const runtimeDir = resolve(sourceDir, "runtime");
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
|
||||
const violations = [];
|
||||
|
||||
const walk = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
return entry.isDirectory() ? walk(path) : path;
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
};
|
||||
|
||||
for (const path of await walk(sourceDir)) {
|
||||
if (!sourceExtensions.has(extname(path)) || path.startsWith(`${runtimeDir}/`)) continue;
|
||||
|
||||
const source = await readFile(path, "utf8");
|
||||
const file = relative(frontendDir, path);
|
||||
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
|
||||
violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`);
|
||||
}
|
||||
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
|
||||
violations.push(`${file}: import platform behavior through src/runtime/index.ts`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`Runtime boundary violations:\n${violations.map((item) => ` ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Runtime boundary is respected.");
|
||||
}
|
||||
@@ -32,38 +32,71 @@ const missing = [
|
||||
];
|
||||
|
||||
const pageContractChecks = [
|
||||
"src/views/ia/ProjectMilestones.vue",
|
||||
"src/views/ia/SubjectManagement.vue",
|
||||
"src/views/ia/RiskIssueSae.vue",
|
||||
"src/views/ia/RiskIssuePd.vue",
|
||||
"src/views/ia/RiskIssueMonitoringVisits.vue"
|
||||
{
|
||||
file: "src/views/ia/ProjectMilestones.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/SubjectManagement.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"],
|
||||
["subject-table"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/RiskIssueSae.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"],
|
||||
["risk-table"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/RiskIssuePd.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"],
|
||||
["risk-table"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/RiskIssueMonitoringVisits.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "monitoring-toolbar", "template-toolbar", "toolbar"],
|
||||
["ctms-table-card", "table-card", "monitoring-table", "issue-table-panel"],
|
||||
["issue-table"]
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const requiredPageClasses = [
|
||||
"ctms-page-shell",
|
||||
"unified-action-bar",
|
||||
"ctms-table-card"
|
||||
];
|
||||
|
||||
for (const file of pageContractChecks) {
|
||||
for (const { file, groups } of pageContractChecks) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const className of requiredPageClasses) {
|
||||
if (!content.includes(className)) {
|
||||
missing.push(`${file}:${className}`);
|
||||
for (const group of groups) {
|
||||
if (!group.some((className) => content.includes(className))) {
|
||||
missing.push(`${file}:${group.join("|")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const overview = readFileSync("src/views/ia/ProjectOverview.vue", "utf8");
|
||||
const requiredOverviewClasses = [
|
||||
"ctms-page-shell",
|
||||
"kpi",
|
||||
"unified-section"
|
||||
const requiredOverviewGroups = [
|
||||
["ctms-page-shell", "page"],
|
||||
["kpi", "overview-card"],
|
||||
["unified-section", "overview-container"]
|
||||
];
|
||||
|
||||
for (const className of requiredOverviewClasses) {
|
||||
if (!overview.includes(className)) {
|
||||
missing.push(`src/views/ia/ProjectOverview.vue:${className}`);
|
||||
for (const group of requiredOverviewGroups) {
|
||||
if (!group.some((className) => overview.includes(className))) {
|
||||
missing.push(`src/views/ia/ProjectOverview.vue:${group.join("|")}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "ctms-desktop"
|
||||
version = "0.1.0"
|
||||
description = "CTMS desktop client"
|
||||
authors = ["Huapont"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "ctms_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
time = { version = "=0.3.36", default-features = false, features = ["std", "parsing", "formatting", "macros"] }
|
||||
url = "2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "4.1.2", default-features = false, features = ["v1", "apple-native-keyring-store"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
keyring = { version = "4.1.2", default-features = false, features = ["v1", "windows-native-keyring-store"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for the CTMS desktop window.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-remove",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "$TEMP/ctms-desktop/**" }
|
||||
]
|
||||
},
|
||||
"notification:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{ "path": "$TEMP/ctms-desktop/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 293 B |
|
After Width: | Height: | Size: 659 B |
|
After Width: | Height: | Size: 99 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 233 B |
|
After Width: | Height: | Size: 322 B |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 846 B |
|
After Width: | Height: | Size: 114 B |
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 194 B |
|
After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,114 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
|
||||
|
||||
fn credential_account(server_origin: &str) -> Result<String, String> {
|
||||
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?;
|
||||
let is_https = parsed.scheme() == "https";
|
||||
let is_local_http = parsed.scheme() == "http"
|
||||
&& matches!(
|
||||
parsed.host_str(),
|
||||
Some("localhost") | Some("127.0.0.1") | Some("::1")
|
||||
);
|
||||
if !is_https && !is_local_http {
|
||||
return Err("非本地服务必须使用 HTTPS".to_string());
|
||||
}
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
return Err("服务器地址不能包含凭据".to_string());
|
||||
}
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
let digest = Sha256::digest(origin.as_bytes());
|
||||
Ok(format!("{digest:x}"))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
fn get_entry(server_origin: &str) -> Result<keyring::Entry, String> {
|
||||
let account = credential_account(server_origin)?;
|
||||
keyring::Entry::new(CREDENTIAL_SERVICE, &account)
|
||||
.map_err(|error| format!("无法访问系统凭据库:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_get(server_origin: String) -> Result<Option<String>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
return match entry.get_password() {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(error) => Err(format!("读取系统凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_set(server_origin: String, token: String) -> Result<(), String> {
|
||||
if token.trim().is_empty() {
|
||||
return Err("拒绝保存空凭据".to_string());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
return get_entry(&server_origin)?
|
||||
.set_password(&token)
|
||||
.map_err(|error| format!("保存系统凭据失败:{error}"));
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("保存系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_delete(server_origin: String) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
return match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(error) => Err(format!("删除系统凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("删除系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::credential_account;
|
||||
|
||||
#[test]
|
||||
fn account_is_stable_for_same_origin() {
|
||||
assert_eq!(
|
||||
credential_account("https://ctms.example.com/path").unwrap(),
|
||||
credential_account("https://ctms.example.com/other").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_insecure_remote_origin() {
|
||||
assert!(credential_account("http://ctms.example.com").is_err());
|
||||
assert!(credential_account("http://localhost:8000").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
mod credentials;
|
||||
mod updates;
|
||||
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use tauri::{Emitter, Manager, Runtime};
|
||||
|
||||
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
|
||||
|
||||
fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
Menu::with_items(
|
||||
handle,
|
||||
&[
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"文件",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.commandPalette",
|
||||
"打开命令面板",
|
||||
true,
|
||||
Some("CmdOrCtrl+K"),
|
||||
)?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.serverSettings",
|
||||
"服务器设置",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::close_window(handle, None)?,
|
||||
&PredefinedMenuItem::quit(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"编辑",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::undo(handle, None)?,
|
||||
&PredefinedMenuItem::redo(handle, None)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::cut(handle, None)?,
|
||||
&PredefinedMenuItem::copy(handle, None)?,
|
||||
&PredefinedMenuItem::paste(handle, None)?,
|
||||
&PredefinedMenuItem::select_all(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"视图",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.refresh",
|
||||
"刷新当前视图",
|
||||
true,
|
||||
Some("CmdOrCtrl+R"),
|
||||
)?,
|
||||
&PredefinedMenuItem::fullscreen(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"导航",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.back",
|
||||
"返回",
|
||||
true,
|
||||
Some("CmdOrCtrl+["),
|
||||
)?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.forward",
|
||||
"前进",
|
||||
true,
|
||||
Some("CmdOrCtrl+]"),
|
||||
)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"窗口",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::minimize(handle, None)?,
|
||||
&PredefinedMenuItem::maximize(handle, None)?,
|
||||
&PredefinedMenuItem::close_window(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"帮助",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.preferences",
|
||||
"桌面偏好",
|
||||
true,
|
||||
Some("CmdOrCtrl+,"),
|
||||
)?,
|
||||
],
|
||||
)?,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn emit_menu_command<R: Runtime>(app: &tauri::AppHandle<R>, command: &str) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.emit(DESKTOP_MENU_COMMAND_EVENT, command);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.menu(desktop_menu)
|
||||
.on_menu_event(|app, event| {
|
||||
let command = event.id().as_ref();
|
||||
if command.starts_with("ctms.desktop.") {
|
||||
emit_menu_command(app, command);
|
||||
}
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(updates::PendingUpdate::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
credentials::credential_get,
|
||||
credentials::credential_set,
|
||||
credentials::credential_delete,
|
||||
updates::desktop_update_check,
|
||||
updates::desktop_update_install,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CTMS desktop application");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
ctms_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PendingUpdate(pub Mutex<Option<Update>>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DesktopUpdateMetadata {
|
||||
version: String,
|
||||
current_version: String,
|
||||
notes: Option<String>,
|
||||
date: Option<String>,
|
||||
}
|
||||
|
||||
fn normalized_origin(raw: &str) -> Result<Url, String> {
|
||||
let parsed = Url::parse(raw).map_err(|_| "invalid server origin".to_string())?;
|
||||
let scheme = parsed.scheme();
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
return Err("server origin must not include credentials".to_string());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "server origin must include a host".to_string())?;
|
||||
let localhost = matches!(host, "localhost" | "127.0.0.1" | "::1");
|
||||
if scheme != "https" && !(scheme == "http" && localhost) {
|
||||
return Err("desktop updates require HTTPS outside localhost".to_string());
|
||||
}
|
||||
let port = parsed.port().map(|value| format!(":{value}")).unwrap_or_default();
|
||||
Url::parse(&format!("{scheme}://{host}{port}/")).map_err(|_| "invalid normalized origin".to_string())
|
||||
}
|
||||
|
||||
fn update_endpoint(server_origin: &str) -> Result<Url, String> {
|
||||
let origin = normalized_origin(server_origin)?;
|
||||
origin
|
||||
.join("desktop-updates/stable/latest.json")
|
||||
.map_err(|_| "invalid update endpoint".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_update_check(
|
||||
app: AppHandle,
|
||||
pending_update: State<'_, PendingUpdate>,
|
||||
server_origin: String,
|
||||
) -> Result<Option<DesktopUpdateMetadata>, String> {
|
||||
let endpoint = update_endpoint(&server_origin)?;
|
||||
let update = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![endpoint])
|
||||
.map_err(|err| err.to_string())?
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let metadata = update.as_ref().map(|update| DesktopUpdateMetadata {
|
||||
version: update.version.clone(),
|
||||
current_version: update.current_version.clone(),
|
||||
notes: update.body.clone(),
|
||||
date: update.date.map(|value| value.to_string()),
|
||||
});
|
||||
|
||||
let mut guard = pending_update
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "pending update lock poisoned".to_string())?;
|
||||
*guard = update;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_update_install(
|
||||
app: AppHandle,
|
||||
pending_update: State<'_, PendingUpdate>,
|
||||
) -> Result<(), String> {
|
||||
let update = {
|
||||
let mut guard = pending_update
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "pending update lock poisoned".to_string())?;
|
||||
guard
|
||||
.take()
|
||||
.ok_or_else(|| "there is no pending update".to_string())?
|
||||
};
|
||||
|
||||
update
|
||||
.download_and_install(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
app.restart();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::update_endpoint;
|
||||
|
||||
#[test]
|
||||
fn update_endpoint_uses_fixed_https_path() {
|
||||
let endpoint = update_endpoint("https://ctms.example.com/app/").unwrap();
|
||||
assert_eq!(
|
||||
endpoint.as_str(),
|
||||
"https://ctms.example.com/desktop-updates/stable/latest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_endpoint_allows_localhost_http_only() {
|
||||
assert!(update_endpoint("http://localhost:8888").is_ok());
|
||||
assert!(update_endpoint("http://ctms.example.com").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CTMS",
|
||||
"version": "0.1.0",
|
||||
"identifier": "cn.huapont.ctms.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "CTMS",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 1180,
|
||||
"minHeight": 760
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"],
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwQjI5MUZFMjQ2NUM5QwpSV1NjWEViaUh5a0xBdE52U2Rhb29mZlVYZ3lnWGlDVGs1WE1RUGoyeWtSWW9pNzBnNW9qUGNaaAo="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { initSessionManager } from "./session/sessionManager";
|
||||
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
|
||||
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
|
||||
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
|
||||
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
applyDesktopThemePreference();
|
||||
}
|
||||
initSessionManager();
|
||||
initDesktopNotificationManager();
|
||||
initDesktopUpdateManager();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -19,3 +19,8 @@ export const uploadAttachment = (
|
||||
};
|
||||
|
||||
export const deleteAttachment = (attachmentId: string) => apiDelete(`/api/v1/attachments/${attachmentId}`);
|
||||
|
||||
export const downloadAttachment = (attachmentId: string) =>
|
||||
apiGet<Blob>(`/api/v1/attachments/${attachmentId}/download`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
@@ -68,4 +68,12 @@ export const updateProfile = (payload: {
|
||||
password?: string;
|
||||
}) => apiPatch<UserMeResponse>("/api/v1/auth/me", payload);
|
||||
|
||||
export const uploadAvatar = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return apiPost<UserMeResponse>("/api/v1/auth/me/avatar", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import axios from "axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT, getAppMetadataHeaders } from "../runtime";
|
||||
|
||||
const authClient = axios.create({
|
||||
baseURL: "/",
|
||||
baseURL: clientRuntime.apiBaseUrl(),
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
export const refreshAuthClientBaseUrl = (): void => {
|
||||
authClient.defaults.baseURL = clientRuntime.apiBaseUrl();
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshAuthClientBaseUrl);
|
||||
}
|
||||
|
||||
authClient.interceptors.request.use((config) => {
|
||||
config.headers = config.headers || {};
|
||||
Object.assign(config.headers, getAppMetadataHeaders());
|
||||
return config;
|
||||
});
|
||||
|
||||
export type ExtendResponse = {
|
||||
accessToken: string;
|
||||
expiresAt: string;
|
||||
|
||||
@@ -3,12 +3,21 @@ import { ElMessage } from "element-plus";
|
||||
import { getToken } from "../utils/auth";
|
||||
import type { ApiError } from "../types/api";
|
||||
import { TEXT } from "../locales";
|
||||
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT, getAppMetadataHeaders } from "../runtime";
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: "/",
|
||||
baseURL: clientRuntime.apiBaseUrl(),
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
export const refreshApiBaseUrl = (): void => {
|
||||
instance.defaults.baseURL = clientRuntime.apiBaseUrl();
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshApiBaseUrl);
|
||||
}
|
||||
|
||||
const NETWORK_RETRY_LIMIT = 10;
|
||||
const NETWORK_RETRY_DELAY_MS = 30000;
|
||||
|
||||
@@ -16,6 +25,7 @@ export type ApiRequestConfig = AxiosRequestConfig & {
|
||||
suppressErrorMessage?: boolean;
|
||||
_retry?: boolean;
|
||||
_networkRetryCount?: number;
|
||||
disableNetworkRetry?: boolean;
|
||||
};
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
@@ -35,13 +45,14 @@ const clearInvalidStudyAndNavigate = async () => {
|
||||
|
||||
const forceAuthExpiredLogout = async () => {
|
||||
const { forceLogout, LOGOUT_REASON_AUTH_EXPIRED } = await import("../session/sessionManager");
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
};
|
||||
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
|
||||
config.headers = config.headers || {};
|
||||
Object.assign(config.headers, getAppMetadataHeaders());
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
@@ -62,7 +73,7 @@ instance.interceptors.response.use(
|
||||
// 认证相关的错误由具体页面自行处理,避免重复提示
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (!status && error.config) {
|
||||
if (!status && error.config && !(error.config as ApiRequestConfig).disableNetworkRetry) {
|
||||
const config = error.config as ApiRequestConfig;
|
||||
const retryCount = config._networkRetryCount || 0;
|
||||
if (retryCount < NETWORK_RETRY_LIMIT) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { apiGet, apiPost, apiPut } from "./axios";
|
||||
import type { NotificationItem } from "../types/notifications";
|
||||
|
||||
export interface DesktopNotificationSubscription {
|
||||
enabled: boolean;
|
||||
enabled_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationClaim {
|
||||
claim_token?: string | null;
|
||||
lease_expires_at?: string | null;
|
||||
items: NotificationItem[];
|
||||
}
|
||||
|
||||
export const getDesktopNotificationSubscription = () =>
|
||||
apiGet<DesktopNotificationSubscription>("/api/v1/desktop-notifications/subscription", {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const setDesktopNotificationSubscription = (enabled: boolean) =>
|
||||
apiPut<DesktopNotificationSubscription>("/api/v1/desktop-notifications/subscription", { enabled }, {
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const claimDesktopNotifications = (limit = 20) =>
|
||||
apiPost<DesktopNotificationClaim>("/api/v1/desktop-notifications/claim", { limit }, {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const acknowledgeDesktopNotifications = (claimToken: string, deliveredIds: string[]) =>
|
||||
apiPost<void>("/api/v1/desktop-notifications/ack", {
|
||||
claim_token: claimToken,
|
||||
delivered_ids: deliveredIds,
|
||||
}, {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const markDesktopNotificationRead = (distributionId: string) =>
|
||||
apiPost<void>(`/api/v1/desktop-notifications/${distributionId}/read`, undefined, {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
@@ -71,6 +71,8 @@ export const fetchAccessLogs = (params: {
|
||||
export const fetchSecurityAccessLogs = (params?: {
|
||||
status_min?: number;
|
||||
auth_status?: string;
|
||||
client_type?: string;
|
||||
client_version?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { auditExportColumns } from "./auditExportColumns";
|
||||
import { formatAuditRows } from "./auditExportFormatter";
|
||||
import type { AuditEvent } from "..";
|
||||
import { saveFile } from "../../runtime";
|
||||
|
||||
const BOM = "\ufeff";
|
||||
|
||||
@@ -19,13 +20,13 @@ export interface AuditExportOptions {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export const exportAuditCsv = (events: AuditEvent[], options: AuditExportOptions) => {
|
||||
export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportOptions) => {
|
||||
const rows = formatAuditRows(events);
|
||||
const csv = buildCsv(rows);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
await saveFile({
|
||||
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
|
||||
mimeType: "text/csv;charset=utf-8",
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visibleProxy"
|
||||
class="desktop-command-dialog"
|
||||
width="640px"
|
||||
align-center
|
||||
:show-close="false"
|
||||
:close-on-click-modal="true"
|
||||
destroy-on-close
|
||||
@opened="focusSearch"
|
||||
>
|
||||
<div class="desktop-command-palette">
|
||||
<div class="command-search-row">
|
||||
<el-icon><Search /></el-icon>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="query"
|
||||
class="command-search-input"
|
||||
autocomplete="off"
|
||||
placeholder="搜索模块、项目或桌面操作"
|
||||
@keydown.enter.prevent="runFirstCommand"
|
||||
@keydown.esc.prevent="visibleProxy = false"
|
||||
/>
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
|
||||
<div class="command-list" role="listbox">
|
||||
<template v-if="groupedCommands.length">
|
||||
<section v-for="group in groupedCommands" :key="group.name" class="command-group">
|
||||
<div class="command-group-title">{{ group.name }}</div>
|
||||
<button
|
||||
v-for="command in group.items"
|
||||
:key="command.id"
|
||||
type="button"
|
||||
class="command-item"
|
||||
@click="runCommand(command)"
|
||||
>
|
||||
<span class="command-item-title">{{ command.title }}</span>
|
||||
<kbd v-if="command.shortcut">{{ command.shortcut }}</kbd>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
<div v-else class="command-empty">没有匹配的命令</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import { getVisibleDesktopCommands, type DesktopCommand } from "../types/desktopCommands";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
commands: DesktopCommand[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
}>();
|
||||
|
||||
const query = ref("");
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
|
||||
const visibleProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const filteredCommands = computed(() => getVisibleDesktopCommands(props.commands, query.value));
|
||||
|
||||
const groupedCommands = computed(() => {
|
||||
const map = new Map<string, DesktopCommand[]>();
|
||||
filteredCommands.value.forEach((command) => {
|
||||
const items = map.get(command.group) || [];
|
||||
items.push(command);
|
||||
map.set(command.group, items);
|
||||
});
|
||||
return Array.from(map.entries()).map(([name, items]) => ({ name, items }));
|
||||
});
|
||||
|
||||
const focusSearch = () => {
|
||||
nextTick(() => searchInputRef.value?.focus());
|
||||
};
|
||||
|
||||
const runCommand = async (command: DesktopCommand) => {
|
||||
visibleProxy.value = false;
|
||||
await command.run();
|
||||
};
|
||||
|
||||
const runFirstCommand = () => {
|
||||
const [first] = filteredCommands.value;
|
||||
if (first) void runCommand(first);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
query.value = "";
|
||||
focusSearch();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-command-palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.command-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d9e2ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.command-search-input {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.command-list {
|
||||
max-height: min(58vh, 520px);
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.command-group + .command-group {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.command-group-title {
|
||||
padding: 6px 8px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.command-item:hover,
|
||||
.command-item:focus-visible {
|
||||
background: #eef4ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-item-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
min-width: 28px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.command-empty {
|
||||
padding: 36px 12px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.desktop-command-dialog .el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-command-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
||||
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
|
||||
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
|
||||
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8");
|
||||
|
||||
describe("desktop layout shell", () => {
|
||||
it("routes desktop and web shells through the runtime flag", () => {
|
||||
const source = readLayoutSource();
|
||||
|
||||
expect(source).toContain("const isDesktop = isTauriRuntime()");
|
||||
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
|
||||
expect(source).toContain("<WebLayout v-else />");
|
||||
});
|
||||
|
||||
it("uses route-only desktop preference storage", () => {
|
||||
const source = readDesktopLayoutSource();
|
||||
|
||||
expect(source).toContain("readDesktopRecentRoutes");
|
||||
expect(source).toContain("readDesktopFavoriteRoutes");
|
||||
expect(source).toContain("recordDesktopRecentRoute");
|
||||
expect(source).toContain("currentDesktopRoutePreference");
|
||||
});
|
||||
|
||||
it("shares active route mapping between web and desktop shells", () => {
|
||||
const webLayout = readWebLayoutSource();
|
||||
const desktopLayout = readDesktopLayoutSource();
|
||||
const navigation = readNavigationSource();
|
||||
|
||||
expect(webLayout).toContain("getActiveLayoutPath(route.path)");
|
||||
expect(desktopLayout).toContain("getActiveLayoutPath(route.path)");
|
||||
expect(navigation).toContain("export const getActiveLayoutPath");
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,7 @@ import type {
|
||||
} from "@/types/api";
|
||||
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
||||
import { saveFile } from "@/runtime";
|
||||
|
||||
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
|
||||
showSecurityLog: false,
|
||||
@@ -311,7 +312,8 @@ const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
|
||||
const account = row.account_label || "未知账号";
|
||||
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
|
||||
const userAgent = row.user_agent || "-";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
const client = [row.client_type, row.client_version, row.client_platform].filter(Boolean).join("/") || "unknown";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client=${client} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
};
|
||||
|
||||
const terminalLogRows = computed(() =>
|
||||
@@ -515,14 +517,9 @@ const openSecurityLogDialog = () => {
|
||||
scrollSecurityTerminalToBottom();
|
||||
};
|
||||
|
||||
const downloadLogFile = (fileName: string, lines: string[]) => {
|
||||
const downloadLogFile = async (fileName: string, lines: string[]) => {
|
||||
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
|
||||
};
|
||||
|
||||
const downloadInterfaceLog = () => {
|
||||
|
||||
@@ -70,7 +70,7 @@ const continueSession = () => {
|
||||
};
|
||||
|
||||
const logoutNow = () => {
|
||||
forceLogout(LOGOUT_REASON_MANUAL);
|
||||
void forceLogout(LOGOUT_REASON_MANUAL);
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
<el-input v-model="contentProxy" type="textarea" rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
<div v-if="allowAttachments" class="upload-row">
|
||||
<div class="upload-label">{{ TEXT.common.labels.attachments }}</div>
|
||||
<el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
|
||||
<el-upload v-if="!nativeFiles" v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
|
||||
<el-button size="small" class="upload-button">{{ TEXT.common.actions.upload }}</el-button>
|
||||
</el-upload>
|
||||
<el-button v-else size="small" class="upload-button" @click="pickNativeAttachments">
|
||||
{{ TEXT.common.actions.upload }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button v-if="showCancel" size="small" @click="$emit('cancel')">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -26,6 +29,7 @@
|
||||
import { computed } from "vue";
|
||||
import type { UploadUserFile } from "element-plus";
|
||||
import { displayDateTime, displayUser } from "../utils/display";
|
||||
import { clientRuntime, pickFiles } from "../runtime";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -46,6 +50,7 @@ const emit = defineEmits<{
|
||||
(e: "cancel"): void;
|
||||
(e: "clear-quote"): void;
|
||||
}>();
|
||||
const nativeFiles = clientRuntime.capabilities().nativeFiles;
|
||||
|
||||
const contentProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -58,6 +63,21 @@ const fileListProxy = computed({
|
||||
});
|
||||
|
||||
const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
|
||||
|
||||
const pickNativeAttachments = async () => {
|
||||
const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments });
|
||||
const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
|
||||
const additions: UploadUserFile[] = files
|
||||
.filter((file) => !existing.has(`${file.name}:${file.size}`))
|
||||
.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
raw: file as any,
|
||||
status: "ready",
|
||||
uid: Date.now() + Math.floor(Math.random() * 100000),
|
||||
}));
|
||||
emit("update:fileList", [...props.fileList, ...additions]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, reactive, watch } from "vue";
|
||||
import { downloadAttachment } from "../api/attachments";
|
||||
import { saveFile } from "../runtime";
|
||||
import { displayDateTime, displayUser } from "../utils/display";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
@@ -68,13 +71,37 @@ const quoteContent = (item: any) =>
|
||||
const contentText = (item: any) =>
|
||||
item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
|
||||
|
||||
const attachmentUrl = (id: string) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return token ? `/api/v1/attachments/${id}/download?token=${token}` : `/api/v1/attachments/${id}/download`;
|
||||
const attachmentObjectUrls = reactive<Record<string, string>>({});
|
||||
const attachmentUrl = (id: string) => attachmentObjectUrls[id] || "";
|
||||
|
||||
const revokeAttachmentUrls = () => {
|
||||
Object.values(attachmentObjectUrls).forEach((url) => URL.revokeObjectURL(url));
|
||||
Object.keys(attachmentObjectUrls).forEach((key) => delete attachmentObjectUrls[key]);
|
||||
};
|
||||
|
||||
const download = (id: string) => {
|
||||
window.open(attachmentUrl(id), "_blank");
|
||||
const loadImageUrls = async () => {
|
||||
revokeAttachmentUrls();
|
||||
const files = Object.values(props.attachmentsMap).flat().filter(isImage);
|
||||
await Promise.all(files.map(async (file) => {
|
||||
try {
|
||||
const response = await downloadAttachment(file.id);
|
||||
attachmentObjectUrls[file.id] = URL.createObjectURL(
|
||||
new Blob([response.data], { type: response.headers?.["content-type"] || file.content_type }),
|
||||
);
|
||||
} catch {
|
||||
attachmentObjectUrls[file.id] = "";
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const download = async (id: string) => {
|
||||
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
|
||||
const response = await downloadAttachment(id);
|
||||
await saveFile({
|
||||
suggestedName: file?.filename || "attachment",
|
||||
mimeType: response.headers?.["content-type"] || file?.content_type,
|
||||
data: response.data,
|
||||
});
|
||||
};
|
||||
|
||||
const isImage = (file: any) => {
|
||||
@@ -88,6 +115,9 @@ const isHighlighted = (item: any) => {
|
||||
if (!props.highlightIds?.length) return false;
|
||||
return props.highlightIds.includes(item?.id);
|
||||
};
|
||||
|
||||
watch(() => props.attachmentsMap, () => void loadImageUrls(), { deep: true, immediate: true });
|
||||
onBeforeUnmount(revokeAttachmentUrls);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<span>{{ headerTitle }}</span>
|
||||
<div v-if="canShowUploader && tableUploadGroup" class="immediate-upload">
|
||||
<el-upload
|
||||
v-if="!nativeFiles"
|
||||
:http-request="uploadImmediate"
|
||||
:show-file-list="false"
|
||||
:limit="1"
|
||||
@@ -13,6 +14,9 @@
|
||||
>
|
||||
<el-button type="primary" :loading="isImmediateUploading">{{ TEXT.common.actions.upload }}</el-button>
|
||||
</el-upload>
|
||||
<el-button v-else type="primary" :loading="isImmediateUploading" @click="pickImmediateNative">
|
||||
{{ TEXT.common.actions.upload }}
|
||||
</el-button>
|
||||
<el-progress v-if="tableProgress > 0 && tableProgress < 100" :percentage="tableProgress" :stroke-width="6" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,6 +45,7 @@
|
||||
{{ TEXT.common.labels.preview }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="openExternally(scope.row)">打开</el-button>
|
||||
<el-button
|
||||
v-if="canDelete(scope.row)"
|
||||
link
|
||||
@@ -58,6 +63,7 @@
|
||||
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
|
||||
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
|
||||
<el-upload
|
||||
v-if="!nativeFiles"
|
||||
class="attachment-card-upload"
|
||||
:show-file-list="false"
|
||||
multiple
|
||||
@@ -79,6 +85,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div
|
||||
v-else
|
||||
class="attachment-upload-card"
|
||||
:class="{
|
||||
'is-disabled': !canUploadGroup(group),
|
||||
'attachment-upload-card--centered': centerUploadCardContent,
|
||||
}"
|
||||
@click="pickPendingNative(group)"
|
||||
>
|
||||
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
|
||||
<div class="upload-trigger">
|
||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||
<span class="upload-text">{{ group.uploadText || "点击上传文件" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingMap[group.key]?.length" class="pending-list">
|
||||
<div
|
||||
v-for="item in pendingMap[group.key]"
|
||||
@@ -122,7 +143,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { UploadFilled } from "@element-plus/icons-vue";
|
||||
import { fetchAttachments, deleteAttachment, uploadAttachment } from "../../api/attachments";
|
||||
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
|
||||
import { formatFileSize } from "./attachmentUtils";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -131,6 +152,7 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
|
||||
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { clientRuntime, openFile, pickFiles, saveFile } from "../../runtime";
|
||||
|
||||
type AttachmentEntityGroup = {
|
||||
entityType: string;
|
||||
@@ -182,6 +204,7 @@ const previewType = ref<"image" | "pdf" | "other">("other");
|
||||
const previewTitle = ref(TEXT.common.labels.preview);
|
||||
const previewError = ref("");
|
||||
const previewLoading = ref(false);
|
||||
const nativeFiles = clientRuntime.capabilities().nativeFiles;
|
||||
const headerTitle = computed(() => props.title || TEXT.common.labels.attachments);
|
||||
const displayMode = computed(() => props.mode || "table");
|
||||
const maxSize = computed(() => props.maxSizeMb ?? 50);
|
||||
@@ -240,10 +263,9 @@ const validateFile = (file: File) => {
|
||||
|
||||
const pendingFileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
|
||||
|
||||
const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
const queuePendingFile = (fileType: string, file: File) => {
|
||||
const group = uploadGroups.value.find((item) => item.key === fileType);
|
||||
if (!group || !canUploadGroup(group)) return;
|
||||
const file = uploadFile?.raw as File | undefined;
|
||||
if (!file || !validateFile(file)) return;
|
||||
const next = pendingMap[fileType] || [];
|
||||
if (!next.some((item) => pendingFileKey(item.file) === pendingFileKey(file))) {
|
||||
@@ -251,6 +273,17 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
const file = uploadFile?.raw as File | undefined;
|
||||
if (file) queuePendingFile(fileType, file);
|
||||
};
|
||||
|
||||
const pickPendingNative = async (group: UploadGroup) => {
|
||||
if (!canUploadGroup(group)) return;
|
||||
const selected = await pickFiles({ multiple: true, title: group.label });
|
||||
selected.forEach((file) => queuePendingFile(group.key, file));
|
||||
};
|
||||
|
||||
const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
|
||||
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
|
||||
};
|
||||
@@ -317,6 +350,11 @@ const uploadImmediate = async (options: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pickImmediateNative = async () => {
|
||||
const [file] = await pickFiles({ multiple: false, title: headerTitle.value });
|
||||
if (file) await uploadImmediate({ file });
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!props.studyId || !props.entityId) return;
|
||||
loading.value = true;
|
||||
@@ -352,25 +390,35 @@ const uploaderLabel = (row: any) => {
|
||||
return row.uploaded_by_id || row.uploaded_by || TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const getDownloadUrl = (row: any) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
|
||||
const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
|
||||
const response = await downloadAttachment(row.id);
|
||||
return new Blob([response.data], {
|
||||
type: response.headers?.["content-type"] || row?.content_type || "application/octet-stream",
|
||||
});
|
||||
};
|
||||
|
||||
const getPreviewUrl = (row: any) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return token ? `/api/v1/attachments/${row.id}/preview?token=${token}` : `/api/v1/attachments/${row.id}/preview`;
|
||||
const download = async (row: any) => {
|
||||
try {
|
||||
await saveFile({
|
||||
suggestedName: row?.filename || "download",
|
||||
mimeType: row?.content_type,
|
||||
data: await fetchAttachmentBlob(row),
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error(TEXT.common.messages.downloadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const download = (row: any) => {
|
||||
const url = getDownloadUrl(row);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = row?.filename || "download";
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
const openExternally = async (row: any) => {
|
||||
try {
|
||||
await openFile({
|
||||
suggestedName: row?.filename || "attachment",
|
||||
mimeType: row?.content_type,
|
||||
data: await fetchAttachmentBlob(row),
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error(TEXT.common.messages.previewNotSupported);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
|
||||
@@ -386,37 +434,29 @@ const detectPreviewType = (row: any) => {
|
||||
return "other";
|
||||
};
|
||||
|
||||
const previewImage = async (downloadUrl: string) => {
|
||||
const previewImage = async (row: any) => {
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(downloadUrl, { headers });
|
||||
if (!response.ok) throw new Error("preview failed");
|
||||
const blob = await response.blob();
|
||||
const blob = await fetchAttachmentBlob(row);
|
||||
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value);
|
||||
previewObjectUrl.value = URL.createObjectURL(blob);
|
||||
previewUrl.value = previewObjectUrl.value;
|
||||
} catch {
|
||||
previewUrl.value = downloadUrl;
|
||||
previewError.value = TEXT.common.messages.previewNotSupported;
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const preview = (row: any) => {
|
||||
const preview = async (row: any) => {
|
||||
previewError.value = "";
|
||||
previewType.value = detectPreviewType(row);
|
||||
previewTitle.value = row?.filename || TEXT.common.labels.preview;
|
||||
const downloadUrl = getDownloadUrl(row);
|
||||
if (previewType.value === "image") {
|
||||
if (previewType.value === "image" || previewType.value === "pdf") {
|
||||
previewUrl.value = "";
|
||||
previewImage(downloadUrl);
|
||||
} else if (previewType.value === "pdf") {
|
||||
previewUrl.value = getPreviewUrl(row);
|
||||
await previewImage(row);
|
||||
} else {
|
||||
previewUrl.value = downloadUrl;
|
||||
previewUrl.value = "";
|
||||
}
|
||||
if (previewType.value === "other") {
|
||||
previewError.value = TEXT.common.messages.previewNotSupported;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
export type LayoutNavigationIcon =
|
||||
| "audit"
|
||||
| "calendar"
|
||||
| "check"
|
||||
| "coin"
|
||||
| "dashboard"
|
||||
| "document"
|
||||
| "flag"
|
||||
| "folder"
|
||||
| "key"
|
||||
| "monitor"
|
||||
| "notebook"
|
||||
| "project"
|
||||
| "settings"
|
||||
| "subject"
|
||||
| "users";
|
||||
|
||||
export type LayoutNavigationItem = {
|
||||
label: string;
|
||||
path: string;
|
||||
group: string;
|
||||
icon: LayoutNavigationIcon;
|
||||
keywords?: string[];
|
||||
children?: LayoutNavigationItem[];
|
||||
};
|
||||
|
||||
export const getActiveLayoutPath = (path: string) => {
|
||||
if (path.startsWith("/project/milestones")) return "/project/milestones";
|
||||
if (path.startsWith("/project/")) return "/project/overview";
|
||||
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
|
||||
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
||||
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
|
||||
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
|
||||
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
|
||||
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
||||
return "/startup/meeting-auth";
|
||||
}
|
||||
if (path.startsWith("/subjects")) return "/subjects";
|
||||
if (path.startsWith("/risk-issues/sae")) return "/risk-issues/sae";
|
||||
if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd";
|
||||
if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits";
|
||||
if (path.startsWith("/risk-issues")) return "/risk-issues/sae";
|
||||
if (path.startsWith("/etmf")) return "/etmf";
|
||||
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
|
||||
if (path.startsWith("/knowledge/precautions")) return "/knowledge/precautions";
|
||||
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
||||
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
|
||||
if (path.startsWith("/projects/")) return "/admin/projects";
|
||||
if (path.startsWith("/admin/projects/")) return "/admin/projects";
|
||||
if (path.startsWith("/admin/system-monitoring") || path.startsWith("/admin/permission-monitoring")) return "/admin/system-monitoring";
|
||||
if (path.startsWith("/admin/permissions/")) return path;
|
||||
return path;
|
||||
};
|
||||
|
||||
export const buildAdminNavigationItems = (options: {
|
||||
hasUser: boolean;
|
||||
isAdmin: boolean;
|
||||
canAccessAdminPermissions: boolean;
|
||||
}): LayoutNavigationItem[] => {
|
||||
if (!options.hasUser) return [];
|
||||
|
||||
const items: LayoutNavigationItem[] = [];
|
||||
if (options.isAdmin) {
|
||||
items.push({
|
||||
label: TEXT.menu.accountManagement,
|
||||
path: "/admin/users",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "users",
|
||||
keywords: ["user", "account"],
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
label: TEXT.menu.projectManagement,
|
||||
path: "/admin/projects",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "project",
|
||||
keywords: ["project"],
|
||||
});
|
||||
if (options.isAdmin) {
|
||||
items.push(
|
||||
{
|
||||
label: TEXT.menu.auditLogs,
|
||||
path: "/admin/audit-logs",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "audit",
|
||||
keywords: ["audit"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.systemMonitoring,
|
||||
path: "/admin/system-monitoring",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "monitor",
|
||||
keywords: ["monitoring"],
|
||||
},
|
||||
{
|
||||
label: "邮件服务",
|
||||
path: "/admin/email-settings",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "settings",
|
||||
keywords: ["email"],
|
||||
},
|
||||
);
|
||||
}
|
||||
if (options.canAccessAdminPermissions) {
|
||||
items.push({
|
||||
label: TEXT.menu.permissionManagement,
|
||||
path: "/admin/permissions/system",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "key",
|
||||
keywords: ["permission"],
|
||||
children: [
|
||||
{
|
||||
label: "系统级权限",
|
||||
path: "/admin/permissions/system",
|
||||
group: TEXT.menu.permissionManagement,
|
||||
icon: "key",
|
||||
keywords: ["permission", "system"],
|
||||
},
|
||||
{
|
||||
label: "项目权限配置",
|
||||
path: "/admin/permissions/project",
|
||||
group: TEXT.menu.permissionManagement,
|
||||
icon: "key",
|
||||
keywords: ["permission", "project"],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
export const buildProjectNavigationItems = (options: {
|
||||
hasCurrentStudy: boolean;
|
||||
hasAnyProjectModuleAccess: boolean;
|
||||
canAccessProjectPath: (path: string) => boolean;
|
||||
}): LayoutNavigationItem[] => {
|
||||
if (!options.hasCurrentStudy || !options.hasAnyProjectModuleAccess) return [];
|
||||
|
||||
const items: LayoutNavigationItem[] = [
|
||||
{
|
||||
label: TEXT.menu.projectOverview,
|
||||
path: "/project/overview",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "dashboard",
|
||||
keywords: ["overview"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.projectMilestones,
|
||||
path: "/project/milestones",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "calendar",
|
||||
keywords: ["milestone"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.feeContracts,
|
||||
path: "/fees/contracts",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "coin",
|
||||
keywords: ["fee", "contract"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.materialManagement,
|
||||
path: "/drug/shipments",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "folder",
|
||||
children: [
|
||||
{
|
||||
label: TEXT.menu.drugShipments,
|
||||
path: "/drug/shipments",
|
||||
group: TEXT.menu.materialManagement,
|
||||
icon: "folder",
|
||||
keywords: ["drug", "shipment"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.materialEquipment,
|
||||
path: "/materials/equipment",
|
||||
group: TEXT.menu.materialManagement,
|
||||
icon: "folder",
|
||||
keywords: ["material", "equipment"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.fileVersionManagement,
|
||||
path: "/file-versions",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "document",
|
||||
keywords: ["document", "file", "version"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.startupFeasibilityEthics,
|
||||
path: "/startup/feasibility-ethics",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "calendar",
|
||||
keywords: ["startup", "ethics"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.startupMeetingAuth,
|
||||
path: "/startup/meeting-auth",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "check",
|
||||
keywords: ["meeting", "training"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.subjects,
|
||||
path: "/subjects",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "subject",
|
||||
keywords: ["subject"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.riskIssues,
|
||||
path: "/risk-issues/sae",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "flag",
|
||||
children: [
|
||||
{
|
||||
label: TEXT.menu.riskIssueSae,
|
||||
path: "/risk-issues/sae",
|
||||
group: TEXT.menu.riskIssues,
|
||||
icon: "flag",
|
||||
keywords: ["sae", "risk"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.riskIssuePd,
|
||||
path: "/risk-issues/pd",
|
||||
group: TEXT.menu.riskIssues,
|
||||
icon: "flag",
|
||||
keywords: ["pd", "risk"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.riskIssueMonitoringVisits,
|
||||
path: "/risk-issues/monitoring-visits",
|
||||
group: TEXT.menu.riskIssues,
|
||||
icon: "flag",
|
||||
keywords: ["monitoring", "visit"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.etmf,
|
||||
path: "/etmf",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "folder",
|
||||
keywords: ["etmf"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.sharedLibrary,
|
||||
path: "/knowledge/medical-consult",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "notebook",
|
||||
children: [
|
||||
{
|
||||
label: TEXT.menu.knowledgeMedicalConsult,
|
||||
path: "/knowledge/medical-consult",
|
||||
group: TEXT.menu.sharedLibrary,
|
||||
icon: "notebook",
|
||||
keywords: ["knowledge", "medical"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.knowledgeNotes,
|
||||
path: "/knowledge/precautions",
|
||||
group: TEXT.menu.sharedLibrary,
|
||||
icon: "notebook",
|
||||
keywords: ["knowledge", "note"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.knowledgeSupportFiles,
|
||||
path: "/knowledge/support-files",
|
||||
group: TEXT.menu.sharedLibrary,
|
||||
icon: "notebook",
|
||||
keywords: ["knowledge", "support"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.knowledgeInstructionFiles,
|
||||
path: "/knowledge/instruction-files",
|
||||
group: TEXT.menu.sharedLibrary,
|
||||
icon: "notebook",
|
||||
keywords: ["knowledge", "instruction"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item.children) return options.canAccessProjectPath(item.path) ? item : null;
|
||||
const children = item.children.filter((child) => options.canAccessProjectPath(child.path));
|
||||
if (!children.length) return null;
|
||||
return { ...item, path: children[0].path, children };
|
||||
})
|
||||
.filter(Boolean) as LayoutNavigationItem[];
|
||||
};
|
||||
|
||||
export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) =>
|
||||
items.flatMap((item) => [item, ...(item.children || [])]);
|
||||
@@ -0,0 +1,23 @@
|
||||
export const DESKTOP_REFRESH_CURRENT_VIEW_EVENT = "ctms:desktop-refresh-current-view";
|
||||
|
||||
export interface DesktopRefreshEventDetail {
|
||||
handled: boolean;
|
||||
}
|
||||
|
||||
export const dispatchDesktopRefreshCurrentView = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const detail: DesktopRefreshEventDetail = { handled: false };
|
||||
window.dispatchEvent(new CustomEvent<DesktopRefreshEventDetail>(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, { detail }));
|
||||
return detail.handled;
|
||||
};
|
||||
|
||||
export const onDesktopRefreshCurrentView = (handler: () => void | Promise<void>): (() => void) => {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const listener = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DesktopRefreshEventDetail>).detail;
|
||||
if (detail) detail.handled = true;
|
||||
void handler();
|
||||
};
|
||||
window.addEventListener(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, listener);
|
||||
return () => window.removeEventListener(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, listener);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isEditableShortcutTarget } from "./useDesktopShortcuts";
|
||||
|
||||
describe("desktop shortcut target detection", () => {
|
||||
it("does not hijack form controls", () => {
|
||||
const input = document.createElement("input");
|
||||
const textarea = document.createElement("textarea");
|
||||
const editable = document.createElement("div");
|
||||
editable.setAttribute("contenteditable", "true");
|
||||
|
||||
expect(isEditableShortcutTarget(input)).toBe(true);
|
||||
expect(isEditableShortcutTarget(textarea)).toBe(true);
|
||||
expect(isEditableShortcutTarget(editable)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows shortcuts outside editable areas", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(isEditableShortcutTarget(button)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
|
||||
export interface DesktopShortcutHandlers {
|
||||
openCommandPalette: () => void;
|
||||
closeActiveLayer?: () => boolean;
|
||||
navigateBack?: () => void;
|
||||
navigateForward?: () => void;
|
||||
refreshCurrentView?: () => void;
|
||||
}
|
||||
|
||||
export const isEditableShortcutTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (["input", "textarea", "select"].includes(tag)) return true;
|
||||
if ((target as HTMLElement).isContentEditable) return true;
|
||||
if (target.getAttribute("contenteditable") === "true") return true;
|
||||
return Boolean(target.closest('[contenteditable="true"], .el-input, .el-textarea, .el-select'));
|
||||
};
|
||||
|
||||
export const useDesktopShortcuts = (enabled: () => boolean, handlers: DesktopShortcutHandlers) => {
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (!enabled() || isEditableShortcutTarget(event.target)) return;
|
||||
const modifier = event.metaKey || event.ctrlKey;
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
if (!modifier && key === "escape") {
|
||||
if (handlers.closeActiveLayer?.()) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modifier) return;
|
||||
|
||||
if (key === "k") {
|
||||
event.preventDefault();
|
||||
handlers.openCommandPalette();
|
||||
return;
|
||||
}
|
||||
if (key === "[") {
|
||||
event.preventDefault();
|
||||
handlers.navigateBack?.();
|
||||
return;
|
||||
}
|
||||
if (key === "]") {
|
||||
event.preventDefault();
|
||||
handlers.navigateForward?.();
|
||||
return;
|
||||
}
|
||||
if (key === "r") {
|
||||
event.preventDefault();
|
||||
handlers.refreshCurrentView?.();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
return { onKeydown };
|
||||
};
|
||||
@@ -1,5 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
|
||||
readonly VITE_BUILD_COMMIT?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
|
||||
@@ -12,22 +12,33 @@ import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { getToken } from "./utils/auth";
|
||||
import { useStudyStore } from "./store/study";
|
||||
import { cleanupTemporaryFiles, initializeSecureSessionStorage, shouldRequireDesktopServerUrl } from "./runtime";
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
dayjs.locale("zh-cn");
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
const bootstrap = async () => {
|
||||
await initializeSecureSessionStorage().catch((error) => {
|
||||
console.error("Secure session storage initialization failed", error);
|
||||
});
|
||||
await cleanupTemporaryFiles().catch((error) => {
|
||||
console.warn("Desktop temporary file cleanup failed", error);
|
||||
});
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
dayjs.locale("zh-cn");
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
|
||||
// 初始化项目上下文
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.loadCurrentStudy();
|
||||
if (getToken()) {
|
||||
// 初始化项目上下文
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.loadCurrentStudy();
|
||||
if (getToken() && !shouldRequireDesktopServerUrl()) {
|
||||
await studyStore.rehydrateStudyForLastUser();
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
app.use(router);
|
||||
await router.isReady();
|
||||
app.use(router);
|
||||
await router.isReady();
|
||||
|
||||
app.mount("#app");
|
||||
app.mount("#app");
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import { hasDesktopServerUrl, isTauriRuntime, shouldRequireDesktopServerUrl } from "../runtime";
|
||||
|
||||
const DESKTOP_SETTINGS_PATH = "/desktop/server-settings";
|
||||
|
||||
export const resolveDesktopRouteRedirect = (to: RouteLocationNormalized): string | null => {
|
||||
const isSettingsRoute = to.path === DESKTOP_SETTINGS_PATH;
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
return isSettingsRoute ? "/login" : null;
|
||||
}
|
||||
|
||||
if (shouldRequireDesktopServerUrl()) {
|
||||
return isSettingsRoute ? null : DESKTOP_SETTINGS_PATH;
|
||||
}
|
||||
|
||||
if (isSettingsRoute || hasDesktopServerUrl()) return null;
|
||||
return DESKTOP_SETTINGS_PATH;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import Layout from "../components/Layout.vue";
|
||||
import Login from "../views/Login.vue";
|
||||
import Register from "../views/Register.vue";
|
||||
import ForgotPassword from "../views/ForgotPassword.vue";
|
||||
import DesktopServerSettings from "../views/DesktopServerSettings.vue";
|
||||
import StudyHome from "../views/StudyHome.vue";
|
||||
import FaqDetail from "../views/FaqDetail.vue";
|
||||
import AuditLogs from "../views/admin/AuditLogs.vue";
|
||||
@@ -55,6 +56,7 @@ import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
|
||||
import SubjectForm from "../views/subjects/SubjectForm.vue";
|
||||
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
|
||||
import { TEXT } from "../locales";
|
||||
import { resolveDesktopRouteRedirect } from "./desktopGuard";
|
||||
|
||||
const SYSTEM_PERMISSION_READ = "system:permissions:read";
|
||||
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
|
||||
@@ -78,6 +80,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: ForgotPassword,
|
||||
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
|
||||
},
|
||||
{
|
||||
path: "/desktop/server-settings",
|
||||
name: "DesktopServerSettings",
|
||||
component: DesktopServerSettings,
|
||||
meta: { public: true, title: "服务器设置" },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: Layout,
|
||||
@@ -504,6 +512,12 @@ const ensureProjectPermissionAccess = async (
|
||||
};
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const desktopRedirect = resolveDesktopRouteRedirect(to);
|
||||
if (desktopRedirect) {
|
||||
next({ path: desktopRedirect });
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
const getToken = () => auth.token;
|
||||
@@ -513,7 +527,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
|
||||
auth.logout();
|
||||
await auth.logout();
|
||||
next({ path: "/login" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getDesktopServerUrl } from "./desktopServerConfig";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export const resolveApiBaseUrl = (): string => {
|
||||
if (!isTauriRuntime()) return "/";
|
||||
return getDesktopServerUrl() || "/";
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import packageInfo from "../../package.json";
|
||||
import { clientRuntime } from "./clientRuntime";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("client runtime", () => {
|
||||
it("reports a web runtime with desktop capabilities disabled", () => {
|
||||
expect(getAppMetadata()).toMatchObject({
|
||||
version: packageInfo.version,
|
||||
commit: "local",
|
||||
channel: "local",
|
||||
clientType: "web",
|
||||
platform: "web",
|
||||
});
|
||||
expect(clientRuntime.capabilities()).toEqual({
|
||||
serverConfiguration: false,
|
||||
nativeFiles: false,
|
||||
systemNotifications: false,
|
||||
secureSessionStorage: false,
|
||||
automaticUpdates: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes only the implemented desktop capability", () => {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
|
||||
expect(getAppMetadata().clientType).toBe("desktop");
|
||||
expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
|
||||
expect(clientRuntime.capabilities().secureSessionStorage).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts release build metadata only from the expected CI values", () => {
|
||||
vi.stubEnv("VITE_BUILD_CHANNEL", "release");
|
||||
vi.stubEnv("VITE_BUILD_COMMIT", "0123456789abcdef0123456789abcdef01234567");
|
||||
|
||||
expect(getAppMetadata()).toMatchObject({
|
||||
channel: "release",
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose arbitrary build metadata strings", () => {
|
||||
vi.stubEnv("VITE_BUILD_CHANNEL", "feature/demo");
|
||||
vi.stubEnv("VITE_BUILD_COMMIT", "token=secret");
|
||||
|
||||
expect(getAppMetadata()).toMatchObject({
|
||||
channel: "local",
|
||||
commit: "local",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import packageInfo from "../../package.json";
|
||||
import { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
||||
|
||||
export type ClientType = "web" | "desktop";
|
||||
export type BuildChannel = "dev" | "main" | "release" | "local";
|
||||
|
||||
export interface AppMetadata {
|
||||
version: string;
|
||||
commit: string;
|
||||
channel: BuildChannel;
|
||||
clientType: ClientType;
|
||||
platform: RuntimePlatform;
|
||||
}
|
||||
|
||||
const BUILD_CHANNELS = new Set<BuildChannel>(["dev", "main", "release", "local"]);
|
||||
const BUILD_COMMIT_PATTERN = /^[0-9a-f]{7,40}$/i;
|
||||
|
||||
const resolveBuildChannel = (value: string | undefined): BuildChannel =>
|
||||
value && BUILD_CHANNELS.has(value as BuildChannel) ? (value as BuildChannel) : "local";
|
||||
|
||||
const resolveBuildCommit = (value: string | undefined): string =>
|
||||
value && BUILD_COMMIT_PATTERN.test(value) ? value : "local";
|
||||
|
||||
export const getAppMetadata = (): AppMetadata => ({
|
||||
version: packageInfo.version,
|
||||
commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT),
|
||||
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
|
||||
clientType: isTauriRuntime() ? "desktop" : "web",
|
||||
platform: getRuntimePlatform(),
|
||||
});
|
||||
|
||||
export const getAppMetadataHeaders = (): Record<string, string> => {
|
||||
const metadata = getAppMetadata();
|
||||
return {
|
||||
"X-CTMS-Client-Type": metadata.clientType,
|
||||
"X-CTMS-Client-Version": metadata.version,
|
||||
"X-CTMS-Client-Platform": metadata.platform,
|
||||
"X-CTMS-Build-Channel": metadata.channel,
|
||||
"X-CTMS-Build-Commit": metadata.commit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { resolveApiBaseUrl } from "./apiBaseUrl";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
import * as files from "./files";
|
||||
import * as notifications from "./notifications";
|
||||
import * as updates from "./updates";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
import {
|
||||
clearSessionToken,
|
||||
getSessionToken,
|
||||
initializeSecureSessionStorage,
|
||||
isSecureSessionStorageAvailable,
|
||||
setSessionToken,
|
||||
} from "./secureSessionStorage";
|
||||
|
||||
export interface RuntimeCapabilities {
|
||||
serverConfiguration: boolean;
|
||||
nativeFiles: boolean;
|
||||
systemNotifications: boolean;
|
||||
secureSessionStorage: boolean;
|
||||
automaticUpdates: boolean;
|
||||
}
|
||||
|
||||
export interface ClientRuntime {
|
||||
apiBaseUrl(): string;
|
||||
metadata: typeof getAppMetadata;
|
||||
capabilities(): RuntimeCapabilities;
|
||||
secureSessionStorage: {
|
||||
initialize: typeof initializeSecureSessionStorage;
|
||||
get: typeof getSessionToken;
|
||||
set: typeof setSessionToken;
|
||||
clear: typeof clearSessionToken;
|
||||
};
|
||||
files: typeof files;
|
||||
notifications: typeof notifications;
|
||||
updates: typeof updates;
|
||||
}
|
||||
|
||||
export const clientRuntime: ClientRuntime = {
|
||||
apiBaseUrl: resolveApiBaseUrl,
|
||||
metadata: getAppMetadata,
|
||||
secureSessionStorage: {
|
||||
initialize: initializeSecureSessionStorage,
|
||||
get: getSessionToken,
|
||||
set: setSessionToken,
|
||||
clear: clearSessionToken,
|
||||
},
|
||||
files,
|
||||
notifications,
|
||||
updates,
|
||||
capabilities: () => ({
|
||||
serverConfiguration: isTauriRuntime(),
|
||||
nativeFiles: isTauriRuntime(),
|
||||
systemNotifications: isTauriRuntime(),
|
||||
secureSessionStorage: isTauriRuntime() && isSecureSessionStorageAvailable(),
|
||||
automaticUpdates: updates.isDesktopUpdaterAvailable(),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export const DESKTOP_MENU_COMMAND_EVENT = "ctms:desktop-menu-command";
|
||||
|
||||
export type DesktopMenuCommand =
|
||||
| "ctms.desktop.commandPalette"
|
||||
| "ctms.desktop.preferences"
|
||||
| "ctms.desktop.serverSettings"
|
||||
| "ctms.desktop.refresh"
|
||||
| "ctms.desktop.back"
|
||||
| "ctms.desktop.forward";
|
||||
|
||||
type Unlisten = () => void;
|
||||
|
||||
export const listenDesktopMenuCommand = async (
|
||||
handler: (command: DesktopMenuCommand | string) => void,
|
||||
): Promise<Unlisten> => {
|
||||
if (!isTauriRuntime()) return () => {};
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
return listen<string>(DESKTOP_MENU_COMMAND_EVENT, (event) => {
|
||||
if (typeof event.payload === "string") handler(event.payload);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||
DESKTOP_SERVER_URL_KEY,
|
||||
getDesktopServerUrl,
|
||||
normalizeDesktopServerUrl,
|
||||
setDesktopServerUrl,
|
||||
shouldRequireDesktopServerUrl,
|
||||
} from "./desktopServerConfig";
|
||||
|
||||
const setTauriRuntime = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
} else {
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
}
|
||||
};
|
||||
|
||||
const createMemoryStorage = (): Storage => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: vi.fn(() => store.clear()),
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null),
|
||||
removeItem: vi.fn((key: string) => store.delete(key)),
|
||||
setItem: vi.fn((key: string, value: string) => store.set(key, value)),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: createMemoryStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
setTauriRuntime(false);
|
||||
});
|
||||
|
||||
describe("desktop server config", () => {
|
||||
it("normalizes server URLs to an origin with trailing slash", () => {
|
||||
expect(normalizeDesktopServerUrl("https://ctms.example.com/api")).toEqual({
|
||||
ok: true,
|
||||
url: "https://ctms.example.com/",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local HTTP but rejects non-local HTTP", () => {
|
||||
expect(normalizeDesktopServerUrl("http://localhost:8000")).toEqual({
|
||||
ok: true,
|
||||
url: "http://localhost:8000/",
|
||||
});
|
||||
expect(normalizeDesktopServerUrl("http://ctms.example.com").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("stores valid desktop server URLs and emits a change event", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, listener);
|
||||
|
||||
const result = setDesktopServerUrl("https://ctms.example.com");
|
||||
|
||||
expect(result).toEqual({ ok: true, url: "https://ctms.example.com/" });
|
||||
expect(window.localStorage.getItem(DESKTOP_SERVER_URL_KEY)).toBe("https://ctms.example.com/");
|
||||
expect(getDesktopServerUrl()).toBe("https://ctms.example.com/");
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("requires a server URL only inside the Tauri runtime", () => {
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||
setTauriRuntime(true);
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(true);
|
||||
setDesktopServerUrl("https://ctms.example.com");
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export const DESKTOP_SERVER_URL_KEY = "ctms_desktop_server_url";
|
||||
export const DESKTOP_SERVER_URL_CHANGED_EVENT = "ctms:desktop-server-url-changed";
|
||||
|
||||
export type DesktopServerUrlValidationResult =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
const LOCAL_HTTP_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||
|
||||
const getStorage = (): Storage | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const emitServerUrlChanged = (previous: string | null, current: string | null): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, {
|
||||
detail: { previous, current },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||
const raw = value.trim();
|
||||
if (!raw) return { ok: false, reason: "请输入服务器地址" };
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return { ok: false, reason: "服务器地址格式不正确" };
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
return { ok: false, reason: "服务器地址不能包含用户名或密码" };
|
||||
}
|
||||
|
||||
const isHttps = parsed.protocol === "https:";
|
||||
const isLocalHttp = parsed.protocol === "http:" && LOCAL_HTTP_HOSTS.has(parsed.hostname);
|
||||
if (!isHttps && !isLocalHttp) {
|
||||
return { ok: false, reason: "非本地服务必须使用 HTTPS" };
|
||||
}
|
||||
|
||||
return { ok: true, url: `${parsed.origin}/` };
|
||||
};
|
||||
|
||||
export const getDesktopServerUrl = (): string | null => {
|
||||
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
|
||||
if (!stored) return null;
|
||||
const result = normalizeDesktopServerUrl(stored);
|
||||
return result.ok ? result.url : null;
|
||||
};
|
||||
|
||||
export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl());
|
||||
|
||||
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||
const result = normalizeDesktopServerUrl(value);
|
||||
if (!result.ok) return result;
|
||||
const storage = getStorage();
|
||||
if (!storage) return { ok: false, reason: "当前环境无法保存服务器地址" };
|
||||
const previous = getDesktopServerUrl();
|
||||
storage.setItem(DESKTOP_SERVER_URL_KEY, result.url);
|
||||
emitServerUrlChanged(previous, result.url);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const clearDesktopServerUrl = (): void => {
|
||||
const previous = getDesktopServerUrl();
|
||||
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
|
||||
emitServerUrlChanged(previous, null);
|
||||
};
|
||||
|
||||
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DESKTOP_FAVORITE_ROUTES_KEY,
|
||||
DESKTOP_RECENT_ROUTES_KEY,
|
||||
DESKTOP_THEME_KEY,
|
||||
applyDesktopThemePreference,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopRecentRoutes,
|
||||
readDesktopThemePreference,
|
||||
recordDesktopRecentRoute,
|
||||
setDesktopThemePreference,
|
||||
toggleDesktopFavoriteRoute,
|
||||
} from "./desktopUiPreferences";
|
||||
|
||||
const installLocalStorageMock = () => {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("desktop UI preferences", () => {
|
||||
beforeEach(() => {
|
||||
installLocalStorageMock();
|
||||
document.documentElement.removeAttribute("data-ctms-theme");
|
||||
document.documentElement.style.colorScheme = "";
|
||||
});
|
||||
|
||||
it("stores only route metadata for recent desktop routes", () => {
|
||||
recordDesktopRecentRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
|
||||
|
||||
expect(readDesktopRecentRoutes()).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "/subjects",
|
||||
title: "受试者",
|
||||
group: "当前项目",
|
||||
}),
|
||||
]);
|
||||
expect(window.localStorage.getItem(DESKTOP_RECENT_ROUTES_KEY)).not.toContain("token");
|
||||
});
|
||||
|
||||
it("deduplicates favorites by path", () => {
|
||||
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者" });
|
||||
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者" });
|
||||
toggleDesktopFavoriteRoute({ path: "/file-versions", title: "文件版本" });
|
||||
|
||||
expect(readDesktopFavoriteRoutes()).toHaveLength(1);
|
||||
expect(readDesktopFavoriteRoutes()[0].path).toBe("/file-versions");
|
||||
expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("subject_no");
|
||||
});
|
||||
|
||||
it("stores and applies only the desktop theme enum", () => {
|
||||
expect(readDesktopThemePreference()).toBe("light");
|
||||
|
||||
setDesktopThemePreference("dark");
|
||||
|
||||
expect(readDesktopThemePreference()).toBe("dark");
|
||||
expect(window.localStorage.getItem(DESKTOP_THEME_KEY)).toBe("dark");
|
||||
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("dark");
|
||||
expect(document.documentElement.style.colorScheme).toBe("dark");
|
||||
|
||||
applyDesktopThemePreference("light");
|
||||
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("light");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
export const DESKTOP_RECENT_ROUTES_KEY = "ctms_desktop_recent_routes";
|
||||
export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes";
|
||||
export const DESKTOP_THEME_KEY = "ctms_desktop_theme";
|
||||
export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed";
|
||||
|
||||
export type DesktopThemePreference = "light" | "dark";
|
||||
|
||||
export interface DesktopRoutePreference {
|
||||
path: string;
|
||||
title: string;
|
||||
group?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const MAX_RECENT_ROUTES = 8;
|
||||
const MAX_FAVORITE_ROUTES = 12;
|
||||
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light";
|
||||
const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme";
|
||||
|
||||
const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
|
||||
const sanitizeRoutePreference = (value: Partial<DesktopRoutePreference> | null | undefined): DesktopRoutePreference | null => {
|
||||
const path = typeof value?.path === "string" ? value.path.trim() : "";
|
||||
const title = typeof value?.title === "string" ? value.title.trim() : "";
|
||||
if (!path.startsWith("/") || !title) return null;
|
||||
return {
|
||||
path,
|
||||
title: title.slice(0, 80),
|
||||
group: typeof value?.group === "string" ? value.group.slice(0, 40) : undefined,
|
||||
updatedAt: typeof value?.updatedAt === "string" ? value.updatedAt : new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const readRoutePreferences = (key: string): DesktopRoutePreference[] => {
|
||||
if (!isStorageAvailable()) return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((item) => sanitizeRoutePreference(item))
|
||||
.filter(Boolean) as DesktopRoutePreference[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeRoutePreferences = (key: string, routes: DesktopRoutePreference[]) => {
|
||||
if (!isStorageAvailable()) return;
|
||||
window.localStorage.setItem(key, JSON.stringify(routes));
|
||||
};
|
||||
|
||||
const sanitizeDesktopThemePreference = (value: unknown): DesktopThemePreference =>
|
||||
value === "dark" ? "dark" : DEFAULT_DESKTOP_THEME;
|
||||
|
||||
export const readDesktopThemePreference = (): DesktopThemePreference => {
|
||||
if (!isStorageAvailable()) return DEFAULT_DESKTOP_THEME;
|
||||
return sanitizeDesktopThemePreference(window.localStorage.getItem(DESKTOP_THEME_KEY));
|
||||
};
|
||||
|
||||
export const applyDesktopThemePreference = (theme: DesktopThemePreference = readDesktopThemePreference()): DesktopThemePreference => {
|
||||
const nextTheme = sanitizeDesktopThemePreference(theme);
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.setAttribute(DESKTOP_THEME_ATTRIBUTE, nextTheme);
|
||||
document.documentElement.style.colorScheme = nextTheme;
|
||||
}
|
||||
return nextTheme;
|
||||
};
|
||||
|
||||
export const setDesktopThemePreference = (theme: DesktopThemePreference): DesktopThemePreference => {
|
||||
const nextTheme = sanitizeDesktopThemePreference(theme);
|
||||
if (isStorageAvailable()) {
|
||||
window.localStorage.setItem(DESKTOP_THEME_KEY, nextTheme);
|
||||
}
|
||||
applyDesktopThemePreference(nextTheme);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(DESKTOP_THEME_CHANGED_EVENT, { detail: nextTheme }));
|
||||
}
|
||||
return nextTheme;
|
||||
};
|
||||
|
||||
export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY);
|
||||
|
||||
export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY);
|
||||
|
||||
export const recordDesktopRecentRoute = (route: Pick<DesktopRoutePreference, "path" | "title" | "group">): DesktopRoutePreference[] => {
|
||||
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() });
|
||||
if (!item) return readDesktopRecentRoutes();
|
||||
const next = [
|
||||
item,
|
||||
...readDesktopRecentRoutes().filter((existing) => existing.path !== item.path),
|
||||
].slice(0, MAX_RECENT_ROUTES);
|
||||
writeRoutePreferences(DESKTOP_RECENT_ROUTES_KEY, next);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const isDesktopFavoriteRoute = (path: string): boolean =>
|
||||
readDesktopFavoriteRoutes().some((item) => item.path === path);
|
||||
|
||||
export const toggleDesktopFavoriteRoute = (
|
||||
route: Pick<DesktopRoutePreference, "path" | "title" | "group">,
|
||||
): DesktopRoutePreference[] => {
|
||||
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() });
|
||||
if (!item) return readDesktopFavoriteRoutes();
|
||||
const current = readDesktopFavoriteRoutes();
|
||||
const exists = current.some((existing) => existing.path === item.path);
|
||||
const next = exists
|
||||
? current.filter((existing) => existing.path !== item.path)
|
||||
: [item, ...current].slice(0, MAX_FAVORITE_ROUTES);
|
||||
writeRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY, next);
|
||||
return next;
|
||||
};
|
||||