Compare commits

3 Commits

Author SHA1 Message Date
Cheng Zhou 7c721d4e5c refactor(client): unify web and desktop release workflow
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-06-30 20:25:07 +08:00
Cheng Zhou d1a6c957f7 feat(desktop): implement phase 1 tauri client 2026-06-30 17:25:03 +08:00
Cheng Zhou 4654a812a0 docs: add desktop project plan 2026-06-30 16:45:30 +08:00
57 changed files with 6621 additions and 27 deletions
@@ -0,0 +1,85 @@
name: Client Quality Gates
on:
pull_request:
paths:
- "frontend/**"
- ".github/workflows/client-quality-gates.yml"
- "docs/branch-governance.md"
- "docs/guides/client-release.md"
push:
branches:
- dev
- main
- release
paths:
- "frontend/**"
- ".github/workflows/client-quality-gates.yml"
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: Check synchronized client version
run: npm run version:check
- name: Check runtime boundary
run: npm run runtime:check
- 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: ${{ github.ref_name }}
VITE_BUILD_COMMIT: ${{ github.sha }}
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: Build macOS application
run: npm run desktop:build -- --bundles app
env:
VITE_BUILD_CHANNEL: ${{ github.ref_name }}
VITE_BUILD_COMMIT: ${{ github.sha }}
+5
View File
@@ -53,6 +53,10 @@ pyrightconfig.json
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
frontend/src-tauri/target/
frontend/src-tauri/gen/schemas/
frontend/src-tauri/icons/android/
frontend/src-tauri/icons/ios/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
@@ -82,4 +86,5 @@ backend/app/uploads/
# Git worktrees # Git worktrees
.worktrees/ .worktrees/
worktrees/
.install-logs/ .install-logs/
+26
View File
@@ -0,0 +1,26 @@
# Agent Instructions
处理 CTMS 桌面端任务前,必须先阅读 `docs/desktop-project-plan.md`。桌面端任务包括但不限于 Tauri、macOS、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` 仅是当前桌面端临时集成分支。Tauri 基线合并到 `dev` 后,不得继续将其作为长期桌面主线。
- 生产热修复从 `release` 创建,合并到 `release` 后必须依次回合并到 `main` 和 `dev`。
- 网页端和桌面端必须使用同一个语义化版本号、正式标签和源代码提交。修改客户端版本时使用 `frontend/package.json` 中的 `version:set` 和 `version:check` 命令。
- 平台差异必须收敛在 `frontend/src/runtime/` 之后,不能通过长期分支或复制业务代码维护桌面差异。
- 未经用户明确要求,不执行提交、推送、合并、变基、打标签、删除分支或强制更新远程分支。
- 执行用户明确要求的 Git 操作前,先检查工作区和目标分支,只暂存本次任务相关文件,不覆盖或撤销用户已有改动。
- 分支治理规则发生变化时,必须同步更新上述治理文档,不能只修改 `AGENTS.md`。
+10
View File
@@ -40,8 +40,18 @@
- 后端 API:同域 `/api/v1/*` - 后端 API:同域 `/api/v1/*`
- `nginx` 负责托管前端静态资源,并将 `/api` 与 `/health` 转发到 `backend` - `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` - 分支治理规范:`docs/branch-governance.md`
- 分支维护中文 SOP:`docs/guides/branch-maintenance-sop-zh.md`
- 分支环境安装配置:`docs/guides/branch-environment-installation.md` - 分支环境安装配置:`docs/guides/branch-environment-installation.md`
- 发布检查清单:`docs/guides/release-checklist.md` - 发布检查清单:`docs/guides/release-checklist.md`
+1
View File
@@ -35,6 +35,7 @@ services:
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com} NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
VITE_RUNTIME_ENV: ${ENV:-development} VITE_RUNTIME_ENV: ${ENV:-development}
VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-true} VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-true}
VITE_HMR_CLIENT_PORT: ${VITE_HMR_CLIENT_PORT:-8888}
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5173/"] test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5173/"]
interval: 2s 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): 长期分支治理规则 - [`branch-governance.md`](branch-governance.md): 长期分支治理规则
- [`guides/release-checklist.md`](guides/release-checklist.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/storage-persistence-governance.md`](audits/storage-persistence-governance.md): 重要数据落库治理基线
- [`audits/module-level-permissions-transition.md`](audits/module-level-permissions-transition.md): 模块级权限迁移状态与约束 - [`audits/module-level-permissions-transition.md`](audits/module-level-permissions-transition.md): 模块级权限迁移状态与约束
+28
View File
@@ -78,6 +78,27 @@ Meaning:
Direct promotion that skips stages is discouraged and must be justified in writing. 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 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`.
- 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 ## 4. Branch Entry Rules
### Changes allowed into `dev` ### Changes allowed into `dev`
@@ -214,6 +235,13 @@ Rules:
- tags are created on `release`, not on `dev` - tags are created on `release`, not on `dev`
- a tag must point to the exact production release commit - a tag must point to the exact production release commit
- patch hotfixes on `release` should increment the patch version - 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 ## 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 创建项目与向现有前端加入 Tauri:https://tauri.app/start/create-project/
- Tauri + Vite 配置:https://tauri.app/start/frontend/vite/
- Tauri capabilities:https://tauri.app/security/capabilities/
- Tauri DMG 分发:https://tauri.app/distribute/dmg/
- Tauri macOS 签名与公证:https://tauri.app/distribute/sign/macos/
- Tauri updater 资料,供第二阶段使用:https://tauri.app/plugin/updater/
+141
View File
@@ -0,0 +1,141 @@
# 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 在线桌面客户端
第一阶段交付一个面向现有 CTMS 服务的 macOS 桌面壳。
详细方案见 [`desktop-phase-1-design.md`](desktop-phase-1-design.md)。
范围:
- 在现有前端工程中加入 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 强耦合。
- 桌面端运行时边界已文档化。
## 第二阶段:桌面端能力增强
第二阶段在不改变在线优先产品边界的前提下,增加原生桌面能力。
范围:
- 在附件等场景中引入原生文件选择、下载、打开能力。
- 基于服务端在线数据提供系统通知能力。
- 使用 Tauri 兼容方式实现安全的桌面端 token/session 存储。
- 单实例启动行为。
- 在支持、审计或排障需要时暴露桌面 App 版本、平台、客户端类型等元数据。
- 为签名后的桌面版本设计并实现自动更新。
- 为 Windows 适配做准备,包括路径处理、安装器假设、CI 打包设计。
不做:
- 离线队列。
- 业务数据后台同步。
- 用于离线使用的本地业务数据缓存。
- 绕过后端的本地权限裁决。
- 本地审计日志缓存后再回放。
退出标准:
- 桌面专属能力隔离在适配层之后。
- Web 运行时不依赖桌面 API,仍可正常工作。
- 桌面端 session 存储和文件操作有清晰安全边界。
- macOS 打包路径可重复执行。
- Windows 打包要求在实现前已经文档化。
## 架构方向
使用运行时适配层,不在业务页面里散落平台判断。
建议的适配层边界:
- `platform`:识别 Web、macOS 桌面端、Windows 桌面端。
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
- `storage`:隔离浏览器存储与桌面安全存储。
- `files`:隔离浏览器上传下载与原生文件能力。
- `notifications`:隔离 Web 通知与桌面系统通知。
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
## 安全与合规方向
- 将桌面端视为受监管业务系统的在线客户端。
- 非本地服务连接优先使用 HTTPS。
- 认证与授权决策保留在后端。
- 审计敏感决策保留在后端。
- 第二阶段开始处理敏感凭据时,必须使用明确批准的安全存储方案。
- 不向前端暴露宽泛文件系统访问权限。
- Tauri 权限保持最小化,并按功能精确授权。
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
## 分支与工作区
桌面端工作在以下位置开发:
- Worktree:`/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop`
- 分支:`codex/ctms-desktop`
除非发布计划另有说明,桌面端分支应持续与 `dev` 对齐。
## 每次开发前必须执行的检查
开始任何桌面端相关任务前:
- 先阅读本文档。
- 确认任务属于第一阶段或第二阶段。
- 确认任务不会引入离线能力。
- 确认实现不会破坏 Web 运行时。
- 确认 Tauri API 使用被隔离在适配层之后,除非有明确记录的理由。
如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。
+500
View File
@@ -0,0 +1,500 @@
# 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` |
| 临时集成 | `codex/<任务名称>` | `codex/ctms-desktop` |
分支名称使用小写英文和连字符,不使用个人姓名、日期或模糊名称。
## 四、日常功能开发流程
### 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 type-check
npm run test:unit
npm run build
```
涉及 Tauri、macOS 打包或桌面适配层时,还必须在 macOS 执行:
```bash
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` 是临时桌面集成分支,不作为长期桌面主线。
处理步骤:
```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/*` 分支
## 六、从 `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 type-check` 通过
- [ ] `npm run test:unit` 通过
- [ ] `npm run build` 通过
- [ ] macOS 桌面端构建通过
- [ ] 数据库迁移与回滚方案确认
- [ ] 发布说明完成
- [ ] `main -> release` 合并完成
- [ ] 正式标签创建在准确的 `release` 提交上
- [ ] 网页端和桌面端从同一标签构建
- [ ] 发布记录包含版本、标签和完整提交编号
## 十五、流程速查
普通功能:
```text
最新 dev
-> feature/*
-> 开发、测试、评审
-> dev
-> 删除临时分支
```
正式发布:
```text
dev
-> main
-> 统一版本号
-> 回归与验收
-> release
-> 创建 vX.Y.Z 标签
-> 同一标签构建网页端和桌面端
```
生产热修复:
```text
release
-> hotfix/*
-> release
-> 创建修订版本标签
-> main
-> dev
```
+89
View File
@@ -0,0 +1,89 @@
# 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
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 remain disabled until their
second-phase adapters are implemented and reviewed.
## 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.
## 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.
## Required Checks
```bash
cd frontend
npm ci
npm run version:check
npm run runtime:check
npm run type-check
npm run test:unit
npm run build
npm run desktop:build -- --bundles app
```
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; an unsigned internal build is not a formal distribution.
+3
View File
@@ -1,6 +1,9 @@
# Frontend feature flags and timeline overrides # Frontend feature flags and timeline overrides
VITE_RUNTIME_ENV=production VITE_RUNTIME_ENV=production
VITE_ALLOW_INSECURE_DEV_LOGIN=false 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_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3
VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6 VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6
+233
View File
@@ -18,6 +18,7 @@
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
@@ -1354,6 +1355,238 @@
"win32" "win32"
] ]
}, },
"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/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+8
View File
@@ -7,6 +7,13 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri",
"desktop:dev": "tauri dev",
"desktop:build": "tauri build",
"desktop:bundle:dmg": "tauri build --bundles dmg",
"version:check": "node scripts/client-version.mjs --check",
"version:set": "node scripts/client-version.mjs --set",
"runtime:check": "node scripts/verify-runtime-boundary.mjs",
"test:unit": "vitest run --environment jsdom", "test:unit": "vitest run --environment jsdom",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"ui:contract": "node scripts/verify-ui-contract.mjs" "ui:contract": "node scripts/verify-ui-contract.mjs"
@@ -22,6 +29,7 @@
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6", "@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;
}
@@ -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.");
}
+4367
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[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 = [] }
time = { version = "=0.3.36", default-features = false, features = ["std", "parsing", "formatting", "macros"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the CTMS desktop window.",
"windows": ["main"],
"permissions": ["core:default"]
}
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

+5
View File
@@ -0,0 +1,5 @@
pub fn run() {
tauri::Builder::default()
.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()
}
+31
View File
@@ -0,0 +1,31 @@
{
"$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": null
}
},
"bundle": {
"active": true,
"targets": ["app", "dmg"]
}
}
+10 -1
View File
@@ -1,11 +1,20 @@
import axios from "axios"; import axios from "axios";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
const authClient = axios.create({ const authClient = axios.create({
baseURL: "/", baseURL: clientRuntime.apiBaseUrl(),
timeout: 15000, timeout: 15000,
}); });
export const refreshAuthClientBaseUrl = (): void => {
authClient.defaults.baseURL = clientRuntime.apiBaseUrl();
};
if (typeof window !== "undefined") {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshAuthClientBaseUrl);
}
export type ExtendResponse = { export type ExtendResponse = {
accessToken: string; accessToken: string;
expiresAt: string; expiresAt: string;
+10 -1
View File
@@ -3,12 +3,21 @@ import { ElMessage } from "element-plus";
import { getToken } from "../utils/auth"; import { getToken } from "../utils/auth";
import type { ApiError } from "../types/api"; import type { ApiError } from "../types/api";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
const instance: AxiosInstance = axios.create({ const instance: AxiosInstance = axios.create({
baseURL: "/", baseURL: clientRuntime.apiBaseUrl(),
timeout: 15000, 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_LIMIT = 10;
const NETWORK_RETRY_DELAY_MS = 30000; const NETWORK_RETRY_DELAY_MS = 30000;
+9
View File
@@ -1,5 +1,14 @@
/// <reference types="vite/client" /> /// <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" { declare module "*.vue" {
import type { DefineComponent } from "vue"; import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>; const component: DefineComponent<{}, {}, any>;
+20 -15
View File
@@ -12,22 +12,27 @@ import App from "./App.vue";
import router from "./router"; import router from "./router";
import { getToken } from "./utils/auth"; import { getToken } from "./utils/auth";
import { useStudyStore } from "./store/study"; import { useStudyStore } from "./store/study";
import { shouldRequireDesktopServerUrl } from "./runtime";
const app = createApp(App); const bootstrap = async () => {
const pinia = createPinia(); const app = createApp(App);
app.use(pinia); const pinia = createPinia();
dayjs.locale("zh-cn"); app.use(pinia);
app.use(ElementPlus, { locale: zhCn }); dayjs.locale("zh-cn");
app.use(ElementPlus, { locale: zhCn });
// 初始化项目上下文 // 初始化项目上下文
const studyStore = useStudyStore(); const studyStore = useStudyStore();
studyStore.loadCurrentStudy(); studyStore.loadCurrentStudy();
if (getToken()) { if (getToken() && !shouldRequireDesktopServerUrl()) {
await studyStore.rehydrateStudyForLastUser(); await studyStore.rehydrateStudyForLastUser();
await studyStore.loadCurrentStudyPermissions().catch(() => {}); await studyStore.loadCurrentStudyPermissions().catch(() => {});
} }
app.use(router); app.use(router);
await router.isReady(); 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;
};
+14
View File
@@ -10,6 +10,7 @@ import Layout from "../components/Layout.vue";
import Login from "../views/Login.vue"; import Login from "../views/Login.vue";
import Register from "../views/Register.vue"; import Register from "../views/Register.vue";
import ForgotPassword from "../views/ForgotPassword.vue"; import ForgotPassword from "../views/ForgotPassword.vue";
import DesktopServerSettings from "../views/DesktopServerSettings.vue";
import StudyHome from "../views/StudyHome.vue"; import StudyHome from "../views/StudyHome.vue";
import FaqDetail from "../views/FaqDetail.vue"; import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.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 SubjectForm from "../views/subjects/SubjectForm.vue";
import SubjectDetail from "../views/subjects/SubjectDetail.vue"; import SubjectDetail from "../views/subjects/SubjectDetail.vue";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { resolveDesktopRouteRedirect } from "./desktopGuard";
const SYSTEM_PERMISSION_READ = "system:permissions:read"; const SYSTEM_PERMISSION_READ = "system:permissions:read";
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config"; const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
@@ -78,6 +80,12 @@ const routes: RouteRecordRaw[] = [
component: ForgotPassword, component: ForgotPassword,
meta: { public: true, title: TEXT.modules.auth.forgotTitle }, meta: { public: true, title: TEXT.modules.auth.forgotTitle },
}, },
{
path: "/desktop/server-settings",
name: "DesktopServerSettings",
component: DesktopServerSettings,
meta: { public: true, title: "服务器设置" },
},
{ {
path: "/", path: "/",
component: Layout, component: Layout,
@@ -504,6 +512,12 @@ const ensureProjectPermissionAccess = async (
}; };
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const desktopRedirect = resolveDesktopRouteRedirect(to);
if (desktopRedirect) {
next({ path: desktopRedirect });
return;
}
const auth = useAuthStore(); const auth = useAuthStore();
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const getToken = () => auth.token; const getToken = () => auth.token;
+7
View File
@@ -0,0 +1,7 @@
import { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform";
export const resolveApiBaseUrl = (): string => {
if (!isTauriRuntime()) return "/";
return getDesktopServerUrl() || "/";
};
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it } from "vitest";
import packageInfo from "../../package.json";
import { clientRuntime } from "./clientRuntime";
import { getAppMetadata } from "./appMetadata";
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
});
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);
});
});
+26
View File
@@ -0,0 +1,26 @@
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 resolveBuildChannel = (value: string | undefined): BuildChannel =>
value && BUILD_CHANNELS.has(value as BuildChannel) ? (value as BuildChannel) : "local";
export const getAppMetadata = (): AppMetadata => ({
version: packageInfo.version,
commit: import.meta.env.VITE_BUILD_COMMIT || "local",
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
clientType: isTauriRuntime() ? "desktop" : "web",
platform: getRuntimePlatform(),
});
+29
View File
@@ -0,0 +1,29 @@
import { resolveApiBaseUrl } from "./apiBaseUrl";
import { getAppMetadata } from "./appMetadata";
import { isTauriRuntime } from "./platform";
export interface RuntimeCapabilities {
serverConfiguration: boolean;
nativeFiles: boolean;
systemNotifications: boolean;
secureSessionStorage: boolean;
automaticUpdates: boolean;
}
export interface ClientRuntime {
apiBaseUrl(): string;
metadata: typeof getAppMetadata;
capabilities(): RuntimeCapabilities;
}
export const clientRuntime: ClientRuntime = {
apiBaseUrl: resolveApiBaseUrl,
metadata: getAppMetadata,
capabilities: () => ({
serverConfiguration: isTauriRuntime(),
nativeFiles: false,
systemNotifications: false,
secureSessionStorage: false,
automaticUpdates: false,
}),
};
@@ -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,68 @@
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]"]);
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 = window.localStorage.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 previous = getDesktopServerUrl();
window.localStorage.setItem(DESKTOP_SERVER_URL_KEY, result.url);
window.dispatchEvent(
new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, {
detail: { previous, current: result.url },
}),
);
return result;
};
export const clearDesktopServerUrl = (): void => {
const previous = getDesktopServerUrl();
window.localStorage.removeItem(DESKTOP_SERVER_URL_KEY);
window.dispatchEvent(
new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, {
detail: { previous, current: null },
}),
);
};
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
+12
View File
@@ -0,0 +1,12 @@
export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./clientRuntime";
export {
clearDesktopServerUrl,
DESKTOP_SERVER_URL_CHANGED_EVENT,
getDesktopServerUrl,
hasDesktopServerUrl,
normalizeDesktopServerUrl,
setDesktopServerUrl,
shouldRequireDesktopServerUrl,
} from "./desktopServerConfig";
export { getAppMetadata, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
+21
View File
@@ -0,0 +1,21 @@
export type RuntimePlatform = "web" | "macos" | "windows" | "linux";
declare global {
interface Window {
__TAURI__?: unknown;
__TAURI_INTERNALS__?: unknown;
}
}
export const isTauriRuntime = (): boolean =>
typeof window !== "undefined" && Boolean(window.__TAURI__ || window.__TAURI_INTERNALS__);
export const getRuntimePlatform = (): RuntimePlatform => {
if (!isTauriRuntime() || typeof navigator === "undefined") return "web";
const platform = navigator.platform.toLowerCase();
const userAgent = navigator.userAgent.toLowerCase();
if (platform.includes("mac") || userAgent.includes("mac os x")) return "macos";
if (platform.includes("win") || userAgent.includes("windows")) return "windows";
return "linux";
};
@@ -0,0 +1,161 @@
<template>
<div class="desktop-settings-page">
<section class="settings-panel">
<div class="panel-header">
<p class="eyebrow">CTMS Desktop</p>
<h1>服务器设置</h1>
<p class="description">配置桌面客户端要连接的 CTMS 服务端入口。业务数据仍由服务端统一保存和裁决。</p>
</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="服务器地址" :error="urlError">
<el-input
v-model.trim="serverUrl"
size="large"
placeholder="https://ctms.example.com"
autocomplete="url"
@keyup.enter="save"
/>
</el-form-item>
<div class="hint">
允许 HTTPS 服务地址;本地开发可使用 http://localhost 或 http://127.0.0.1。
</div>
<div class="actions">
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
<el-button type="primary" size="large" :loading="saving" @click="save">保存并检查连接</el-button>
</div>
</el-form>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
const router = useRouter();
const auth = useAuthStore();
const studyStore = useStudyStore();
const currentServerUrl = getDesktopServerUrl();
const serverUrl = ref(currentServerUrl || "");
const urlError = ref("");
const saving = ref(false);
const canCancel = computed(() => Boolean(currentServerUrl));
const checkHealth = async (baseUrl: string) => {
const healthUrl = new URL("health", baseUrl).toString();
const response = await fetch(healthUrl, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
};
const clearSessionForServerChange = () => {
auth.logout();
studyStore.clearCurrentStudy();
};
const save = async () => {
urlError.value = "";
const normalized = normalizeDesktopServerUrl(serverUrl.value);
if (!normalized.ok) {
urlError.value = normalized.reason;
return;
}
saving.value = true;
try {
await checkHealth(normalized.url);
const previous = getDesktopServerUrl();
const result = setDesktopServerUrl(normalized.url);
if (!result.ok) {
urlError.value = result.reason;
return;
}
if (previous !== result.url) {
clearSessionForServerChange();
}
ElMessage.success("服务器连接已确认");
router.replace("/login");
} catch {
urlError.value = "无法连接服务器的 /health,请确认地址和网络后重试";
} finally {
saving.value = false;
}
};
const goBack = () => {
router.back();
};
</script>
<style scoped>
.desktop-settings-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: #f6f8fb;
}
.settings-panel {
width: min(100%, 520px);
padding: 32px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.panel-header {
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: #2563eb;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h1 {
margin: 0;
color: #0f172a;
font-size: 28px;
line-height: 1.3;
}
.description {
margin: 12px 0 0;
color: #475569;
font-size: 14px;
line-height: 1.7;
}
.hint {
margin-top: -8px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 28px;
}
</style>
+15 -6
View File
@@ -198,6 +198,9 @@
<!-- 注册与忘记密码(左右分立) --> <!-- 注册与忘记密码(左右分立) -->
<div class="forgot-register-row"> <div class="forgot-register-row">
<RouterLink to="/forgot-password" class="forgot-link">忘记密码?</RouterLink> <RouterLink to="/forgot-password" class="forgot-link">忘记密码?</RouterLink>
<RouterLink v-if="showDesktopServerSettings" to="/desktop/server-settings" class="server-settings-link">
服务器设置
</RouterLink>
<RouterLink to="/register" class="register-link">新用户注册</RouterLink> <RouterLink to="/register" class="register-link">新用户注册</RouterLink>
</div> </div>
</el-form> </el-form>
@@ -257,6 +260,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth"; import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales"; import { TEXT, requiredMessage } from "../locales";
import { isTauriRuntime } from "../runtime";
import { import {
consumeLogoutReason, consumeLogoutReason,
LOGOUT_REASON_AUTH_EXPIRED, LOGOUT_REASON_AUTH_EXPIRED,
@@ -287,6 +291,7 @@ const protocolDialogVisible = ref(false);
const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: string } | null>(null); const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: string } | null>(null);
const loginError = ref<{ title: string; message?: string } | null>(null); const loginError = ref<{ title: string; message?: string } | null>(null);
const protocolSections = authProtocolSections; const protocolSections = authProtocolSections;
const showDesktopServerSettings = isTauriRuntime();
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, ""); const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
const availableEmailDomains = computed(() => Array.from(new Set([ const availableEmailDomains = computed(() => Array.from(new Set([
@@ -353,7 +358,7 @@ onMounted(async () => {
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true"; form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
}); });
watch(() => form.agreeProtocol, (v) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(v))); watch(() => form.agreeProtocol, (checked) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked)));
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts); watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
const openProtocolDialog = () => { protocolDialogVisible.value = true; }; const openProtocolDialog = () => { protocolDialogVisible.value = true; };
@@ -371,7 +376,11 @@ const onSubmit = async () => {
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const userKey = auth.user?.email || form.email; const userKey = auth.user?.email || form.email;
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin }); await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
router.push(studyStore.currentStudy ? "/project/overview" : auth.user?.is_admin ? "/admin/users" : "/admin/projects"); if (studyStore.currentStudy) {
router.push("/project/overview");
} else {
router.push(auth.user?.is_admin ? "/admin/users" : "/admin/projects");
}
} catch (error: any) { } catch (error: any) {
const status = error?.response?.status; const status = error?.response?.status;
const detail: string = error?.response?.data?.detail || error?.response?.data?.message || ""; const detail: string = error?.response?.data?.detail || error?.response?.data?.message || "";
@@ -704,8 +713,8 @@ const onSubmit = async () => {
/* 卡片容器 */ /* 卡片容器 */
.login-card-container { .login-card-container {
width: 100%; width: clamp(480px, 34vw, 560px);
max-width: 380px; max-width: calc(100vw - 40px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -1040,7 +1049,7 @@ const onSubmit = async () => {
margin-top: 20px; margin-top: 20px;
} }
.forgot-link, .register-link { .forgot-link, .register-link, .server-settings-link {
font-size: 13px; font-size: 13px;
color: #2563eb; color: #2563eb;
text-decoration: none; text-decoration: none;
@@ -1048,7 +1057,7 @@ const onSubmit = async () => {
transition: color 0.15s; transition: color 0.15s;
} }
.forgot-link:hover, .register-link:hover { .forgot-link:hover, .register-link:hover, .server-settings-link:hover {
color: #1d4ed8; color: #1d4ed8;
text-decoration: underline; text-decoration: underline;
} }
+19 -4
View File
@@ -2,7 +2,15 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import { fileURLToPath } from "node:url"; 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 tauriDevHost = process.env.TAURI_DEV_HOST;
const tauriPlatform = process.env.TAURI_ENV_PLATFORM;
const tauriDebug = process.env.TAURI_ENV_DEBUG === "true";
export default defineConfig({ export default defineConfig({
clearScreen: false,
envPrefix: ["VITE_", "TAURI_ENV_"],
plugins: [ plugins: [
vue(), vue(),
{ {
@@ -27,16 +35,23 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: true, host: tauriDevHost || true,
port: 5173, port: 5173,
hmr: { strictPort: true,
clientPort: 8888, hmr: hmrClientPort ? { clientPort: hmrClientPort } : undefined,
watch: {
ignored: ["**/src-tauri/**"],
}, },
proxy: { proxy: {
"/api": { "/api": {
target: "http://backend:8000", target: devApiProxyTarget,
changeOrigin: true, changeOrigin: true,
}, },
}, },
}, },
build: {
target: tauriPlatform === "windows" ? "chrome105" : "safari13",
minify: tauriDebug ? false : "esbuild",
sourcemap: tauriDebug,
},
}); });