From 9cac75e85c878bc6bd91c1ba9acf2096c9d2b3ed Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Wed, 1 Jul 2026 10:49:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E3=80=81=E5=8F=91=E5=B8=83=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E4=B8=8E=E9=82=AE=E7=AE=B1=E5=9F=9F=E5=90=8D=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/client-quality-gates.yml | 74 +- AGENTS.md | 34 +- backend/app/api/v1/auth.py | 4 +- backend/tests/test_registration.py | 16 + docker-compose.dev.yaml | 8 +- ...desktop-release-stabilization-checklist.md | 44 +- docs/branch-governance.md | 12 +- docs/desktop-project-plan.md | 134 +- docs/guides/branch-maintenance-sop-zh.md | 34 +- docs/guides/client-release.md | 20 +- frontend/package.json | 2 + frontend/scripts/verify-desktop-release.mjs | 48 + .../scripts/verify-desktop-update-feed.mjs | 98 + frontend/scripts/verify-release-build-env.mjs | 66 + frontend/src/App.vue | 4 + frontend/src/components/DesktopLayout.vue | 1667 +++++++++++ .../src/components/Layout.desktop.test.ts | 22 +- frontend/src/components/Layout.vue | 2519 +--------------- frontend/src/components/WebLayout.vue | 2535 +++++++++++++++++ frontend/src/components/layout/navigation.ts | 298 ++ frontend/src/runtime/appMetadata.test.ts | 23 +- frontend/src/runtime/appMetadata.ts | 6 +- .../src/runtime/desktopUiPreferences.test.ts | 20 + frontend/src/runtime/desktopUiPreferences.ts | 35 + frontend/src/runtime/index.ts | 6 + frontend/src/styles/main.css | 132 + frontend/src/views/DesktopPreferences.vue | 133 +- frontend/src/views/Login.test.ts | 12 + frontend/src/views/Login.vue | 53 +- frontend/src/views/Register.vue | 8 +- frontend/src/views/admin/Projects.test.ts | 6 +- frontend/vite.config.ts | 3 +- 32 files changed, 5392 insertions(+), 2684 deletions(-) create mode 100644 frontend/scripts/verify-desktop-update-feed.mjs create mode 100644 frontend/scripts/verify-release-build-env.mjs create mode 100644 frontend/src/components/DesktopLayout.vue create mode 100644 frontend/src/components/WebLayout.vue create mode 100644 frontend/src/components/layout/navigation.ts diff --git a/.github/workflows/client-quality-gates.yml b/.github/workflows/client-quality-gates.yml index 5572ff92..539a335f 100644 --- a/.github/workflows/client-quality-gates.yml +++ b/.github/workflows/client-quality-gates.yml @@ -3,14 +3,13 @@ name: Client Quality Gates on: pull_request: paths: + - "AGENTS.md" - "frontend/**" - - "frontend/scripts/**" - ".github/workflows/client-quality-gates.yml" - "docs/branch-governance.md" - - "docs/audits/desktop-release-stabilization-checklist.md" - - "docs/guides/client-release.md" - - "docs/guides/branch-maintenance-sop-zh.md" - - "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" @@ -19,10 +18,19 @@ on: - dev - main - release + tags: + - "v*" paths: + - "AGENTS.md" - "frontend/**" - - "frontend/scripts/**" - ".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: @@ -45,6 +53,26 @@ jobs: - 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 @@ -54,6 +82,9 @@ jobs: - 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 @@ -63,8 +94,8 @@ jobs: - name: Build Web artifact run: npm run build env: - VITE_BUILD_CHANNEL: ${{ github.ref_name }} - VITE_BUILD_COMMIT: ${{ github.sha }} + VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }} + VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }} desktop: name: macOS Desktop @@ -89,6 +120,26 @@ jobs: - 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 @@ -98,8 +149,11 @@ jobs: - 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: ${{ github.ref_name }} - VITE_BUILD_COMMIT: ${{ github.sha }} + VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }} + VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }} diff --git a/AGENTS.md b/AGENTS.md index 4f2e0baf..c9c85ffa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,17 @@ 处理 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 安装包。 ## 分支与发布治理 @@ -17,10 +27,30 @@ - CTMS 网页端和桌面端属于同一个产品,共用 `dev`、`main`、`release` 分支,不创建 `web-dev`、`desktop-dev`、`web-release`、`desktop-release` 等长期平行分支。 - 默认晋级路径为短期任务分支进入 `dev`,再由 `dev` 晋级到 `main`,最后由 `main` 发布到 `release`。 - Agent 创建分支时默认使用 `codex/<任务名称>`;分支必须从最新 `dev` 创建,并在合并到 `dev` 后删除。 -- `codex/ctms-desktop` 仅是当前桌面端临时集成分支。Tauri 基线合并到 `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 构建只能作为内部验证构建描述。 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index f1e6a76b..7dd9450c 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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]) diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index c8356ae8..4801b30a 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -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 diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index f9903306..80b45dba 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -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 diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index 28978175..5a4448d2 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -2,7 +2,7 @@ 状态: `active` 适用范围: Web 与 macOS Desktop 统一客户端发布 -最后更新: `2026-06-30` +最后更新: `2026-07-01` 本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。 @@ -14,6 +14,7 @@ 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 @@ -26,11 +27,12 @@ npm run desktop:build:app 正式发布还必须确认: - [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。 -- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=` 由 CI 注入。 +- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=` 由 CI 注入,且 `npm run release:env:check` 通过。 - [ ] macOS app 已签名和公证。 - [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。 -- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY` 和 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 后执行 `npm run desktop:build -- --bundles app`。 -- [ ] 不可变制品先上传,`latest.json` 最后原子替换。 +- [ ] 设置 `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 --artifacts-dir `。 +- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。 - [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。 ## 2. 安全边界复审 @@ -46,6 +48,8 @@ npm run desktop:build:app - [ ] 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 门禁。 人工复审还必须确认: @@ -82,6 +86,7 @@ npm run desktop:build:app - [ ] 通知开关显示 OS 权限状态。 - [ ] 手动检查更新能反馈“已是最新版本”、未启用更新或检查失败。 - [ ] 关键弹窗、表单、按钮在最小窗口尺寸 `1180x760` 下不重叠、不溢出。 +- [ ] 更新弹窗只显示版本、发布日期和通用 release notes,不展示 token、下载链接或业务详情。 ## 5. 不允许项 @@ -89,3 +94,34 @@ npm run desktop:build:app - [ ] 不在桌面端保存 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 端到端人工回归矩阵、最小窗口体验验收和系统通知/自动更新真实环境验证。 diff --git a/docs/branch-governance.md b/docs/branch-governance.md index f4e4bf44..29d9eacb 100644 --- a/docs/branch-governance.md +++ b/docs/branch-governance.md @@ -89,11 +89,13 @@ Rules: `desktop-release` branches. - Web and Desktop changes both follow `feature/*` -> `dev` -> `main` -> `release`. -- A platform-specific feature branch is allowed while work is in progress, for - example `feature/desktop-file-picker`, but it must merge back into `dev`. -- `codex/ctms-desktop` is a temporary desktop integration branch. After the - Tauri baseline is accepted into `dev`, new desktop work must use short-lived - feature branches from the current `dev`. +- 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 diff --git a/docs/desktop-project-plan.md b/docs/desktop-project-plan.md index 515d60d3..6b1c5e8a 100644 --- a/docs/desktop-project-plan.md +++ b/docs/desktop-project-plan.md @@ -10,8 +10,8 @@ - 技术路线固定为 Tauri。 - 第一开发目标是 macOS 桌面端。 -- Windows 需要作为长期适配约束保留,但不是第一阶段交付目标。 -- 当前只做第一阶段和第二阶段。 +- Windows 仍只作为第二阶段兼容性验证目标,未获明确批准前不发布正式安装包。 +- 当前桌面端工作只允许在第一、二阶段边界内做修复、稳定化、体验收口和发布准备,不新增第三阶段能力。 - 不做离线功能。 - 不在桌面 App 内嵌本地后端服务。 - 不在桌面 App 内嵌或分发本地数据库来保存 CTMS 业务数据。 @@ -30,83 +30,41 @@ 桌面端应复用现有前端代码和 API 契约。任何共享适配层都应保持小而明确,并可测试。 -## 第一阶段:macOS 在线桌面客户端 +## 已完成阶段边界 -第一阶段交付一个面向现有 CTMS 服务的 macOS 桌面壳。 +第一阶段 macOS 在线桌面壳和第二阶段原生能力主体改造已经形成。详细历史方案见 [`desktop-phase-1-design.md`](desktop-phase-1-design.md) 和 [`desktop-phase-2-design.md`](desktop-phase-2-design.md)。 -详细方案见 [`desktop-phase-1-design.md`](desktop-phase-1-design.md)。 +后续不再按第一阶段空白项目初始化 Tauri,也不再扩展第二阶段以外的新桌面产品能力。当前允许推进的工作仅包括: -范围: +- 修复既有 Tauri、运行时适配层、文件、通知、凭据、更新、菜单/快捷键和打包问题。 +- 稳定 Web 与 Desktop 共用业务代码,确保平台差异继续收敛在 `frontend/src/runtime/` 后面。 +- 完成 macOS 正式发布前的签名、公证、updater 签名、制品发布、CI 门禁和人工回归。 +- 做 Windows 第二阶段兼容性验证,但不发布正式 Windows 安装包。 -- 在现有前端工程中加入 Tauri 项目结构。 -- 将当前 Vue/Vite 应用运行在 Tauri 桌面窗口中。 -- 连接已有 CTMS 后端,支持 HTTP/HTTPS。 -- 为桌面端提供可配置的服务端地址处理。 -- 保持当前登录、角色权限、项目上下文和审计行为。 -- 所有业务数据读写仍发生在服务端。 -- 产出 macOS 开发构建,并明确后续签名、公证、正式发布路径。 +仍然不允许: -不做: - -- 离线登录。 -- 离线浏览项目。 -- 本地 PostgreSQL、SQLite、IndexedDB 业务数据持久化或本地 API 镜像。 -- 桌面端重写 CTMS 页面。 -- Windows 安装包交付。 -- 自动更新实现,除非被明确提升到第二阶段任务。 - -退出标准: - -- macOS App 可以启动 CTMS UI。 -- macOS App 可以连接指定 CTMS 后端。 -- 登录和常规在线流程与 Web 端行为一致。 -- Web 构建仍可用,不被 Tauri API 强耦合。 -- 桌面端运行时边界已文档化。 - -## 第二阶段:桌面端能力增强 - -第二阶段在不改变在线优先产品边界的前提下,增加原生桌面能力。 - -详细方案见 [`desktop-phase-2-design.md`](desktop-phase-2-design.md)。 - -范围: - -- 在附件等场景中引入原生文件选择、下载、打开能力。 -- 基于服务端在线数据提供系统通知能力。 -- 使用 Tauri 兼容方式实现安全的桌面端 token/session 存储。 -- 单实例启动行为。 -- 在支持、审计或排障需要时暴露桌面 App 版本、平台、客户端类型等元数据。 -- 为签名后的桌面版本设计并实现自动更新。 -- 为 Windows 适配做准备,包括路径处理、安装器假设、CI 打包设计。 - -不做: - -- 离线队列。 -- 业务数据后台同步。 -- 用于离线使用的本地业务数据缓存。 -- 绕过后端的本地权限裁决。 -- 本地审计日志缓存后再回放。 - -退出标准: - -- 桌面专属能力隔离在适配层之后。 -- Web 运行时不依赖桌面 API,仍可正常工作。 -- 桌面端 session 存储和文件操作有清晰安全边界。 -- macOS 打包路径可重复执行。 -- Windows 打包要求在实现前已经文档化。 +- 离线登录、离线浏览、离线队列、离线同步或本地优先工作流。 +- 本地 PostgreSQL、SQLite、IndexedDB 业务数据缓存或本地 API 镜像。 +- 内嵌 Python/FastAPI 后端服务。 +- 绕过后端做本地权限裁决、本地审计缓存或审计回放。 +- 为桌面端复制或重写一套独立业务 UI。 ## 架构方向 使用运行时适配层,不在业务页面里散落平台判断。 -建议的适配层边界: +当前已采用并必须继续保持的适配层边界: - `platform`:识别 Web、macOS 桌面端、Windows 桌面端。 - `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。 -- `storage`:隔离浏览器存储与桌面安全存储。 +- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。 +- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。 - `files`:隔离浏览器上传下载与原生文件能力。 - `notifications`:隔离 Web 通知与桌面系统通知。 +- `updates`:隔离桌面自动更新检查与安装入口。 - `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。 +- `desktopMenu` 和 `desktopUiPreferences`:承接桌面菜单命令、最近访问和收藏等桌面体验状态。 +- `clientRuntime`:作为业务侧获取平台能力的聚合入口。 业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。 @@ -116,7 +74,7 @@ - 非本地服务连接优先使用 HTTPS。 - 认证与授权决策保留在后端。 - 审计敏感决策保留在后端。 -- 第二阶段开始处理敏感凭据时,必须使用明确批准的安全存储方案。 +- 敏感凭据必须继续使用明确批准的安全存储方案。 - 不向前端暴露宽泛文件系统访问权限。 - Tauri 权限保持最小化,并按功能精确授权。 - 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。 @@ -126,10 +84,50 @@ 桌面端工作在以下位置开发: - Worktree:`/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop` -- 短期分支:`codex/<任务名称>`,必须从最新 `dev` 创建 +- 短期分支:Agent 默认使用 `codex/<任务名称>`,也可按任务类型使用 `feature/*`、`fix/*`、`docs/*` 或 `release-prep/*`;所有普通桌面端工作都必须从最新 `dev` 创建 -`codex/ctms-desktop` 仅是第一阶段 Tauri 基线临时集成分支。基线合入 -`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 变更可以不执行完整代码门禁,但必须在结果说明中明确未运行。 ## 每次开发前必须执行的检查 @@ -140,5 +138,7 @@ - 确认任务不会引入离线能力。 - 确认实现不会破坏 Web 运行时。 - 确认 Tauri API 使用被隔离在适配层之后,除非有明确记录的理由。 +- 确认不会重复初始化 Tauri 或绕过既有 `frontend/src/runtime/` 运行时边界。 +- 涉及 Tauri 权限、CSP、updater、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。 如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。 diff --git a/docs/guides/branch-maintenance-sop-zh.md b/docs/guides/branch-maintenance-sop-zh.md index 3376af1c..fccf2236 100644 --- a/docs/guides/branch-maintenance-sop-zh.md +++ b/docs/guides/branch-maintenance-sop-zh.md @@ -51,7 +51,7 @@ windows-main | 生产热修复 | `hotfix/<问题名称>` | `hotfix/login-loop` | | 文档调整 | `docs/<文档名称>` | `docs/release-sop` | | 发布准备 | `release-prep/<版本号>` | `release-prep/v1.2.0` | -| 临时集成 | `codex/<任务名称>` | `codex/ctms-desktop` | +| Agent 临时任务 | `codex/<任务名称>` | `codex/desktop-menu-polish` | 分支名称使用小写英文和连字符,不使用个人姓名、日期或模糊名称。 @@ -180,33 +180,17 @@ git push origin --delete feature/<功能名称> 工作树正在使用的分支不能直接删除,应先切换分支或移除对应工作树。 -## 五、当前桌面集成分支处理流程 +## 五、历史桌面集成分支约束 -`codex/ctms-desktop` 是临时桌面集成分支,不作为长期桌面主线。 +`codex/ctms-desktop` 是第一阶段 Tauri 基线使用过的历史临时集成分支,不作为当前桌面端工作线,也不作为长期桌面主线。 -处理步骤: +当前约束: -```bash -git status -git add <本次桌面端和治理优化相关文件> -git diff --cached --check -git commit -m "refactor(client): 统一网页端和桌面端发布流程" -git fetch origin -git rebase origin/dev -git push origin codex/ctms-desktop -``` - -创建: - -```text -codex/ctms-desktop -> dev -``` - -合并并验证 `dev` 后: - -- 删除远程 `codex/ctms-desktop` -- 不再从该分支继续开发 -- 后续桌面功能从最新 `dev` 创建短期 `feature/*` 分支 +- 不得继续向 `codex/ctms-desktop` 提交、变基或推送新的桌面端工作。 +- 如本地或远程仍保留该分支,只能用于追溯历史或在确认已合入 `dev` 后删除。 +- 后续桌面功能、修复、稳定化和发布准备必须从最新 `dev` 创建短期 `feature/*`、`fix/*`、`docs/*`、`release-prep/*` 或 `codex/*` 分支。 +- Agent 创建分支默认使用 `codex/<任务名称>`,并在任务合入 `dev` 后删除。 +- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行分支切换、提交、推送或变基前必须先确认目标基线和处理方式。 ## 六、从 `dev` 晋级到 `main` diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md index 01ef4959..001e8987 100644 --- a/docs/guides/client-release.md +++ b/docs/guides/client-release.md @@ -64,6 +64,7 @@ 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 @@ -73,10 +74,12 @@ 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, and secure -session token boundary. The full manual release, security, regression, and -Desktop UX checklist lives in +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 @@ -120,8 +123,9 @@ The release pipeline must: 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. upload immutable artifacts first; -7. atomically replace `latest.json` last. +6. verify the feed with `npm run desktop:update-feed:check -- --feed --artifacts-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 @@ -149,6 +153,9 @@ Windows validation must cover: 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 @@ -158,7 +165,10 @@ 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 diff --git a/frontend/package.json b/frontend/package.json index c6de4f07..d5102bc0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "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", diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index a97584e9..6b790a3b 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -48,6 +48,7 @@ const verifyTauriConfig = async () => { 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."); @@ -68,6 +69,8 @@ const verifyTauriConfig = async () => { "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 () => { @@ -151,16 +154,61 @@ const verifySourceSafety = async () => { 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")}`); diff --git a/frontend/scripts/verify-desktop-update-feed.mjs b/frontend/scripts/verify-desktop-update-feed.mjs new file mode 100644 index 00000000..cb18e00c --- /dev/null +++ b/frontend/scripts/verify-desktop-update-feed.mjs @@ -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."); +} diff --git a/frontend/scripts/verify-release-build-env.mjs b/frontend/scripts/verify-release-build-env.mjs new file mode 100644 index 00000000..db684972 --- /dev/null +++ b/frontend/scripts/verify-release-build-env.mjs @@ -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 || ""}.`); + 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."); +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 76eb2b8a..da554f13 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,8 +10,12 @@ 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(); diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue new file mode 100644 index 00000000..122612b3 --- /dev/null +++ b/frontend/src/components/DesktopLayout.vue @@ -0,0 +1,1667 @@ + + + + + diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts index 1a1a3abb..0c946954 100644 --- a/frontend/src/components/Layout.desktop.test.ts +++ b/frontend/src/components/Layout.desktop.test.ts @@ -3,23 +3,35 @@ 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("keeps desktop-only controls behind the Tauri runtime flag", () => { + it("routes desktop and web shells through the runtime flag", () => { const source = readLayoutSource(); expect(source).toContain("const isDesktop = isTauriRuntime()"); - expect(source).toContain('v-if="isDesktop"'); - expect(source).toContain("DesktopCommandPalette"); - expect(source).toContain("DesktopPreferences"); + expect(source).toContain(""); + expect(source).toContain(""); }); it("uses route-only desktop preference storage", () => { - const source = readLayoutSource(); + 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"); + }); }); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 0a7736cd..286f510d 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -1,2521 +1,12 @@ - - diff --git a/frontend/src/components/WebLayout.vue b/frontend/src/components/WebLayout.vue new file mode 100644 index 00000000..303303bb --- /dev/null +++ b/frontend/src/components/WebLayout.vue @@ -0,0 +1,2535 @@ + + + + + diff --git a/frontend/src/components/layout/navigation.ts b/frontend/src/components/layout/navigation.ts new file mode 100644 index 00000000..ec072c4d --- /dev/null +++ b/frontend/src/components/layout/navigation.ts @@ -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 || [])]); diff --git a/frontend/src/runtime/appMetadata.test.ts b/frontend/src/runtime/appMetadata.test.ts index 30f66b59..b57af8ca 100644 --- a/frontend/src/runtime/appMetadata.test.ts +++ b/frontend/src/runtime/appMetadata.test.ts @@ -1,10 +1,11 @@ -import { afterEach, describe, expect, it } from "vitest"; +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", () => { @@ -32,4 +33,24 @@ describe("client runtime", () => { 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", + }); + }); }); diff --git a/frontend/src/runtime/appMetadata.ts b/frontend/src/runtime/appMetadata.ts index c227a6a8..fe17f89f 100644 --- a/frontend/src/runtime/appMetadata.ts +++ b/frontend/src/runtime/appMetadata.ts @@ -13,13 +13,17 @@ export interface AppMetadata { } const BUILD_CHANNELS = new Set(["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: import.meta.env.VITE_BUILD_COMMIT || "local", + commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT), channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL), clientType: isTauriRuntime() ? "desktop" : "web", platform: getRuntimePlatform(), diff --git a/frontend/src/runtime/desktopUiPreferences.test.ts b/frontend/src/runtime/desktopUiPreferences.test.ts index 8c460cfe..9058964e 100644 --- a/frontend/src/runtime/desktopUiPreferences.test.ts +++ b/frontend/src/runtime/desktopUiPreferences.test.ts @@ -2,9 +2,13 @@ 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"; @@ -24,6 +28,8 @@ const installLocalStorageMock = () => { 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", () => { @@ -48,4 +54,18 @@ describe("desktop UI preferences", () => { 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"); + }); }); diff --git a/frontend/src/runtime/desktopUiPreferences.ts b/frontend/src/runtime/desktopUiPreferences.ts index 8f076717..44d204bf 100644 --- a/frontend/src/runtime/desktopUiPreferences.ts +++ b/frontend/src/runtime/desktopUiPreferences.ts @@ -1,5 +1,9 @@ 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; @@ -10,6 +14,8 @@ export interface DesktopRoutePreference { 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"; @@ -45,6 +51,35 @@ const writeRoutePreferences = (key: string, routes: DesktopRoutePreference[]) => 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); diff --git a/frontend/src/runtime/index.ts b/frontend/src/runtime/index.ts index afcb9817..2668a416 100644 --- a/frontend/src/runtime/index.ts +++ b/frontend/src/runtime/index.ts @@ -11,12 +11,18 @@ export { export { DESKTOP_FAVORITE_ROUTES_KEY, DESKTOP_RECENT_ROUTES_KEY, + DESKTOP_THEME_CHANGED_EVENT, + DESKTOP_THEME_KEY, + applyDesktopThemePreference, isDesktopFavoriteRoute, readDesktopFavoriteRoutes, readDesktopRecentRoutes, + readDesktopThemePreference, recordDesktopRecentRoute, + setDesktopThemePreference, toggleDesktopFavoriteRoute, type DesktopRoutePreference, + type DesktopThemePreference, } from "./desktopUiPreferences"; export { DESKTOP_MENU_COMMAND_EVENT, diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 8c3bb04a..71b31715 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -70,6 +70,54 @@ --el-text-color-regular: var(--ctms-text-regular); } +:root[data-ctms-theme="dark"] { + --ctms-primary: #8fb7d4; + --ctms-primary-hover: #a7c9df; + --ctms-primary-active: #c3dced; + --ctms-primary-light: #203145; + --ctms-brand-900: #d8e7f4; + --ctms-brand-700: #b4d0e5; + + --ctms-success: #7dd3a8; + --ctms-warning: #f1c271; + --ctms-danger: #f28b8b; + --ctms-info: #a6b4c5; + + --ctms-text-main: #f8fafc; + --ctms-text-regular: #dbe5f1; + --ctms-text-secondary: #9aaabd; + --ctms-text-disabled: #66788d; + + --ctms-bg-base: #0f172a; + --ctms-bg-card: #172033; + --ctms-bg-muted: #111827; + --ctms-neutral-100: #182337; + --ctms-neutral-300: #334155; + --ctms-border-color: #26364a; + --ctms-border-color-hover: #3d5068; + + --ctms-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.24); + --ctms-shadow: 0 8px 20px rgba(0, 0, 0, 0.32); + --ctms-shadow-md: 0 12px 26px rgba(0, 0, 0, 0.36); + --ctms-shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.42); + + --el-color-primary: var(--ctms-primary); + --el-color-primary-light-3: #567894; + --el-color-primary-light-5: #3c5871; + --el-color-primary-light-7: #283c50; + --el-color-primary-light-8: #203145; + --el-color-primary-dark-2: #c3dced; + --el-border-color: var(--ctms-border-color); + --el-text-color-primary: var(--ctms-text-main); + --el-text-color-regular: var(--ctms-text-regular); + --el-bg-color: var(--ctms-bg-card); + --el-bg-color-overlay: #172033; + --el-fill-color-blank: #172033; + --el-fill-color-light: #111827; + --el-fill-color-lighter: #0f172a; + --el-mask-color: rgba(0, 0, 0, 0.62); +} + /* 全局重置 */ html, body, @@ -85,6 +133,90 @@ body { -webkit-font-smoothing: antialiased; } +:root[data-ctms-theme="dark"] body { + background: var(--ctms-bg-base); + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-button--default { + background-color: #172033; + border-color: #334155; + color: var(--ctms-text-regular); +} + +:root[data-ctms-theme="dark"] .el-button--default:hover { + border-color: #4b6580; + background-color: #1f2d3d; + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-card, +:root[data-ctms-theme="dark"] .el-alert, +:root[data-ctms-theme="dark"] .el-popper, +:root[data-ctms-theme="dark"] .el-dropdown-menu, +:root[data-ctms-theme="dark"] .el-popover.el-popper, +:root[data-ctms-theme="dark"] .el-dialog { + border-color: var(--ctms-border-color); + background-color: var(--ctms-bg-card); + color: var(--ctms-text-regular); +} + +:root[data-ctms-theme="dark"] .el-card__header { + border-bottom-color: var(--ctms-border-color); + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-input__wrapper, +:root[data-ctms-theme="dark"] .el-select .el-input__wrapper { + background-color: #172033; + box-shadow: 0 0 0 1px #334155 inset; +} + +:root[data-ctms-theme="dark"] .el-input__inner { + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-input__inner::placeholder { + color: var(--ctms-text-disabled); +} + +:root[data-ctms-theme="dark"] .el-table { + background-color: transparent; + color: var(--ctms-text-regular); +} + +:root[data-ctms-theme="dark"] .el-table th.el-table__cell { + background-color: #111827; + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-table tr, +:root[data-ctms-theme="dark"] .el-table td.el-table__cell { + background-color: #172033; + border-bottom-color: var(--ctms-border-color); +} + +:root[data-ctms-theme="dark"] .el-table--enable-row-hover .el-table__row:hover>td.el-table__cell { + background-color: #1f2d3d; +} + +:root[data-ctms-theme="dark"] .el-dropdown-menu__item { + color: var(--ctms-text-regular); +} + +:root[data-ctms-theme="dark"] .el-dropdown-menu__item:not(.is-disabled):focus, +:root[data-ctms-theme="dark"] .el-dropdown-menu__item:not(.is-disabled):hover { + background-color: #1f2d3d; + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .ctms-state, +:root[data-ctms-theme="dark"] .ctms-table-card, +:root[data-ctms-theme="dark"] .ctms-section-card { + border-color: var(--ctms-border-color); + background: var(--ctms-bg-card); +} + /* Element Plus 全局覆盖 */ /* 按钮美化 */ diff --git a/frontend/src/views/DesktopPreferences.vue b/frontend/src/views/DesktopPreferences.vue index a1ac851d..102103e9 100644 --- a/frontend/src/views/DesktopPreferences.vue +++ b/frontend/src/views/DesktopPreferences.vue @@ -3,9 +3,9 @@

CTMS Desktop

-

桌面偏好

+

系统偏好

- +
@@ -17,6 +17,29 @@
+
+
外观
+
+
+
界面主题
+
切换桌面工作台与系统弹窗的明暗配色
+
+
+ +
+
+
+
通知与更新
@@ -63,7 +86,7 @@ import { computed, onMounted, ref } from "vue"; import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; -import { Close } from "@element-plus/icons-vue"; +import { Close, Moon, Sunny } from "@element-plus/icons-vue"; import { getDesktopNotificationSubscription, setDesktopNotificationSubscription, @@ -74,7 +97,10 @@ import { getDesktopServerUrl, getNotificationPermission, isDesktopUpdaterAvailable, + readDesktopThemePreference, requestNotificationPermission, + setDesktopThemePreference, + type DesktopThemePreference, type NotificationPermissionState, } from "../runtime"; import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager"; @@ -92,13 +118,19 @@ const desktopUpdaterAvailable = isDesktopUpdaterAvailable(); const desktopNotificationsEnabled = ref(false); const desktopNotificationLoading = ref(false); const desktopUpdateChecking = ref(false); +const desktopTheme = ref(readDesktopThemePreference()); const notificationPermission = ref("unsupported"); +const themeOptions = [ + { value: "light" as const, label: "明亮", icon: Sunny }, + { value: "dark" as const, label: "暗黑", icon: Moon }, +]; const clientMetadataRows = computed(() => [ { label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` }, { label: "平台", value: clientMetadata.platform }, { label: "构建通道", value: clientMetadata.channel }, { label: "提交", value: clientMetadata.commit }, + { label: "主题", value: desktopTheme.value === "dark" ? "暗黑" : "明亮" }, { label: "服务器", value: desktopServerUrl.value || "未配置" }, { label: "能力", @@ -168,6 +200,10 @@ const checkDesktopUpdateNow = async () => { } }; +const setTheme = (theme: DesktopThemePreference) => { + desktopTheme.value = setDesktopThemePreference(theme); +}; + const copyClientMetadata = async () => { const text = clientMetadataRows.value.map((row) => `${row.label}: ${row.value}`).join("\n"); await navigator.clipboard?.writeText(text); @@ -273,6 +309,45 @@ code { gap: 10px; } +.theme-segmented { + display: inline-grid; + grid-template-columns: repeat(2, minmax(76px, 1fr)); + gap: 4px; + padding: 4px; + border: 1px solid #dbe6f2; + border-radius: 9px; + background: #f1f5f9; +} + +.theme-option { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 76px; + height: 30px; + padding: 0 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: #64748b; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; +} + +.theme-option:hover { + color: #0f172a; +} + +.theme-option.active { + background: #ffffff; + color: #24496f; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); +} + .metadata-panel { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -299,4 +374,56 @@ dd { font-size: 12px; overflow-wrap: anywhere; } + +:global([data-ctms-theme="dark"] .desktop-preferences-dialog .el-dialog__body) { + background: #111827; +} + +:global([data-ctms-theme="dark"]) .desktop-preferences { + color: #e5edf7; +} + +:global([data-ctms-theme="dark"]) .preferences-kicker { + color: #93c5fd; +} + +:global([data-ctms-theme="dark"]) h3, +:global([data-ctms-theme="dark"]) .row-title, +:global([data-ctms-theme="dark"]) dd, +:global([data-ctms-theme="dark"]) code { + color: #f8fafc; +} + +:global([data-ctms-theme="dark"]) .section-title, +:global([data-ctms-theme="dark"]) .server-label, +:global([data-ctms-theme="dark"]) .row-desc, +:global([data-ctms-theme="dark"]) dt { + color: #94a3b8; +} + +:global([data-ctms-theme="dark"]) .server-card, +:global([data-ctms-theme="dark"]) .preference-row, +:global([data-ctms-theme="dark"]) .metadata-panel { + border-color: #26364a; + background: #172033; +} + +:global([data-ctms-theme="dark"]) .theme-segmented { + border-color: #26364a; + background: #0f172a; +} + +:global([data-ctms-theme="dark"]) .theme-option { + color: #94a3b8; +} + +:global([data-ctms-theme="dark"]) .theme-option:hover { + color: #f8fafc; +} + +:global([data-ctms-theme="dark"]) .theme-option.active { + background: #243247; + color: #bfdbfe; + box-shadow: none; +} diff --git a/frontend/src/views/Login.test.ts b/frontend/src/views/Login.test.ts index 22101e6e..ac5cd5c0 100644 --- a/frontend/src/views/Login.test.ts +++ b/frontend/src/views/Login.test.ts @@ -95,4 +95,16 @@ describe("Login protocol agreement", () => { expect(source).toContain("width: clamp(480px, 34vw, 560px);"); expect(source).toContain("max-width: calc(100vw - 40px);"); }); + + it("uses only server-configured email domains on web and desktop", () => { + const source = readLoginView(); + + expect(source).toContain('fetchEmailDomains()'); + expect(source).toContain(':disabled="availableEmailDomains.length === 0"'); + expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)"); + expect(source).toContain("configuredEmailDomains.value.includes(domain)"); + expect(source).not.toContain("preservedEmailDomain"); + expect(source).not.toContain("showDomainSelect"); + expect(source).not.toContain("email-domain-input"); + }); }); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 122f337f..1ce337fa 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -159,22 +159,14 @@
@@ -201,12 +193,9 @@ 登 录 - +
忘记密码? - - 服务器设置 - 新用户注册
@@ -282,7 +271,6 @@ const AGREE_PROTOCOL_KEY = "ctms_agree_protocol"; const formRef = ref(); const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", agreeProtocol: false }); const configuredEmailDomains = ref([]); -const preservedEmailDomain = ref(""); const rules: FormRules = { email: [ @@ -305,13 +293,7 @@ const refreshDesktopServerUrl = () => { }; const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, ""); -const availableEmailDomains = computed(() => Array.from(new Set([ - ...configuredEmailDomains.value, - preservedEmailDomain.value, -].filter(Boolean)))); -const showDomainSelect = computed( - () => configuredEmailDomains.value.length > 0 || Boolean(preservedEmailDomain.value) -); +const availableEmailDomains = computed(() => configuredEmailDomains.value); const syncEmailFromParts = () => { const local = form.emailLocal.trim().toLowerCase(); @@ -325,14 +307,13 @@ const applyEmailValue = (email: string) => { const separator = normalized.lastIndexOf("@"); if (separator > 0) { form.emailLocal = normalized.slice(0, separator); - form.emailDomain = normalizeDomain(normalized.slice(separator + 1)); - preservedEmailDomain.value = configuredEmailDomains.value.includes(form.emailDomain) - ? "" - : form.emailDomain; + const domain = normalizeDomain(normalized.slice(separator + 1)); + form.emailDomain = configuredEmailDomains.value.includes(domain) + ? domain + : configuredEmailDomains.value[0] || ""; } else { form.emailLocal = normalized; form.emailDomain = configuredEmailDomains.value[0] || ""; - preservedEmailDomain.value = ""; } syncEmailFromParts(); }; @@ -343,8 +324,12 @@ const loadEmailDomains = async () => { configuredEmailDomains.value = Array.from(new Set( data.items.map(normalizeDomain).filter(Boolean) )); + if (!configuredEmailDomains.value.includes(form.emailDomain)) { + form.emailDomain = configuredEmailDomains.value[0] || ""; + } } catch { configuredEmailDomains.value = []; + form.emailDomain = ""; } }; @@ -980,8 +965,7 @@ const onSubmit = async () => { padding: 6px 0; } -.email-local-field::placeholder, -.email-domain-input::placeholder { +.email-local-field::placeholder { color: #94a3b8; } @@ -1013,10 +997,9 @@ const onSubmit = async () => { cursor: pointer; } -.email-domain-input { - width: 150px; - padding-right: 4px; - cursor: text; +.email-domain-field:disabled { + color: #94a3b8; + cursor: not-allowed; } .email-domain-chevron { @@ -1101,7 +1084,7 @@ const onSubmit = async () => { margin-top: 20px; } -.forgot-link, .register-link, .server-settings-link { +.forgot-link, .register-link { font-size: 13px; color: #2563eb; text-decoration: none; @@ -1109,7 +1092,7 @@ const onSubmit = async () => { transition: color 0.15s; } -.forgot-link:hover, .register-link:hover, .server-settings-link:hover { +.forgot-link:hover, .register-link:hover { color: #1d4ed8; text-decoration: underline; } diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue index aae3cb4e..05003554 100644 --- a/frontend/src/views/Register.vue +++ b/frontend/src/views/Register.vue @@ -449,12 +449,14 @@ const loadEmailDomains = async () => { try { const { data } = await fetchEmailDomains(); emailDomains.value = normalizeDomains(data.items); - if (!form.emailDomain && emailDomains.value.length > 0) { - form.emailDomain = emailDomains.value[0]; - syncEmailFromParts(); + if (!emailDomains.value.includes(form.emailDomain)) { + form.emailDomain = emailDomains.value[0] || ""; } + syncEmailFromParts(); } catch { emailDomains.value = []; + form.emailDomain = ""; + syncEmailFromParts(); } }; diff --git a/frontend/src/views/admin/Projects.test.ts b/frontend/src/views/admin/Projects.test.ts index 0994bd83..87356132 100644 --- a/frontend/src/views/admin/Projects.test.ts +++ b/frontend/src/views/admin/Projects.test.ts @@ -4,7 +4,11 @@ import { resolve } from "node:path"; const readProjects = () => readFileSync(resolve(__dirname, "./Projects.vue"), "utf8"); const readRouter = () => readFileSync(resolve(__dirname, "../../router/index.ts"), "utf8"); -const readLayout = () => readFileSync(resolve(__dirname, "../../components/Layout.vue"), "utf8"); +const readLayout = () => [ + "../../components/WebLayout.vue", + "../../components/DesktopLayout.vue", + "../../components/layout/navigation.ts", +].map(path => readFileSync(resolve(__dirname, path), "utf8")).join("\n"); describe("project management access", () => { it("shows project management to all signed-in users while keeping system operations admin-only", () => { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bb1a51af..f64ae8f9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,8 +2,9 @@ import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import { fileURLToPath } from "node:url"; -const devApiProxyTarget = process.env.VITE_DEV_API_PROXY_TARGET || "http://backend:8000"; const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT || ""); +const defaultDevApiProxyTarget = hmrClientPort ? "http://backend:8000" : "http://127.0.0.1:8000"; +const devApiProxyTarget = process.env.VITE_DEV_API_PROXY_TARGET || defaultDevApiProxyTarget; const tauriDevHost = process.env.TAURI_DEV_HOST; const tauriPlatform = process.env.TAURI_ENV_PLATFORM; const tauriDebug = process.env.TAURI_ENV_DEBUG === "true";