发布候选:整合桌面端界面与发布稳定化里程碑
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

This commit is contained in:
Cheng Zhou
2026-07-01 10:53:24 +08:00
parent b283cf1e5c
commit b491b6a146
132 changed files with 17337 additions and 2375 deletions
+159
View File
@@ -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 }}
+5
View File
@@ -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/
+56
View File
@@ -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 构建只能作为内部验证构建描述。
+10
View File
@@ -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)
+1 -4
View File
@@ -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"))))
+3 -1
View File
@@ -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
)
+19 -1
View File
@@ -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),
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, 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"])
+12
View File
@@ -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()
]
+4
View File
@@ -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
+17 -3
View File
@@ -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(
+28
View File
@@ -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()
+18 -1
View File
@@ -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)),
+16
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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 端到端人工回归矩阵、最小窗口体验验收和系统通知/自动更新真实环境验证。
+6 -13
View File
@@ -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` | 认证/会话数据(非业务主数据) |
+30
View File
@@ -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
+331
View File
@@ -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 创建项目与向现有前端加入 Taurihttps://tauri.app/start/create-project/
- Tauri + Vite 配置:https://tauri.app/start/frontend/vite/
- Tauri capabilitieshttps://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/
+160
View File
@@ -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 存入系统凭据库:
- macOSKeychain。
- WindowsCredential 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 会话、文件入口、通知权限、重复启动聚焦和签名更新链路完成端到端验证。
+144
View File
@@ -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、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。
如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。
+493
View File
@@ -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
```
+177
View File
@@ -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.
+3
View File
@@ -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
+294
View File
@@ -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",
+18
View File
@@ -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",
+122
View File
@@ -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;
}
+218
View File
@@ -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.");
}
+55 -22
View File
@@ -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("|")}`);
}
}
+5737
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"]
+3
View File
@@ -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/**" }
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+114
View File
@@ -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());
}
}
+152
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
ctms_desktop_lib::run()
}
+116
View File
@@ -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());
}
}
+37
View File
@@ -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
View File
@@ -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>
+5
View File
@@ -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",
});
+8
View File
@@ -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;
+16 -1
View File
@@ -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;
+15 -4
View File
@@ -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) {
+45
View File
@@ -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,
});
+2
View File
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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");
});
});
File diff suppressed because it is too large Load Diff
@@ -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(
+21 -1
View File
@@ -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>
+35 -5
View File
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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 };
};
+9
View File
@@ -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>;
+26 -15
View File
@@ -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()) {
await studyStore.rehydrateStudyForLastUser();
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
// 初始化项目上下文
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();
+19
View File
@@ -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;
};
+15 -1
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform";
export const resolveApiBaseUrl = (): string => {
if (!isTauriRuntime()) return "/";
return getDesktopServerUrl() || "/";
};
+56
View File
@@ -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",
});
});
});
+41
View File
@@ -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,
};
};
+57
View File
@@ -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(),
}),
};
+23
View File
@@ -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;
};

Some files were not shown because too many files have changed in this diff Show More