feat(desktop): implement phase 1 tauri client

This commit is contained in:
Cheng Zhou
2026-06-30 17:25:03 +08:00
parent 4654a812a0
commit d1a6c957f7
45 changed files with 5473 additions and 27 deletions
+4
View File
@@ -53,6 +53,10 @@ pyrightconfig.json
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/src-tauri/target/
frontend/src-tauri/gen/schemas/
frontend/src-tauri/icons/android/
frontend/src-tauri/icons/ios/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+7
View File
@@ -40,6 +40,13 @@
- 后端 API:同域 `/api/v1/*`
- `nginx` 负责托管前端静态资源,并将 `/api``/health` 转发到 `backend`
## macOS 桌面端开发
- 桌面端遵循 `docs/desktop-project-plan.md` 第一阶段边界:Tauri 在线客户端,不内嵌后端、不保存本地业务数据、不做离线同步。
- 开发启动:进入 `frontend/` 后执行 `npm run desktop:dev`
- 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`DMG 构建执行 `npm run desktop:bundle:dmg`
- 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health`
- 生产或非本地服务地址必须使用 HTTPS;本地开发允许 `http://localhost``http://127.0.0.1`
## 仓库治理文档
- 分支治理规范:`docs/branch-governance.md`
- 分支环境安装配置:`docs/guides/branch-environment-installation.md`
+1
View File
@@ -35,6 +35,7 @@ services:
NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com}
VITE_RUNTIME_ENV: ${ENV:-development}
VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-true}
VITE_HMR_CLIENT_PORT: ${VITE_HMR_CLIENT_PORT:-8888}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5173/"]
interval: 2s
+1
View File
@@ -5,6 +5,7 @@ CTMS 文档入口只展示当前仍会影响开发、发布和运维决策的内
## 当前约束
- [`desktop-project-plan.md`](desktop-project-plan.md): 桌面端 Tauri 项目边界、阶段计划与必读约束
- [`desktop-phase-1-design.md`](desktop-phase-1-design.md): 桌面端第一阶段 macOS 在线客户端详细方案
- [`branch-governance.md`](branch-governance.md): 长期分支治理规则
- [`guides/release-checklist.md`](guides/release-checklist.md): 发布前检查项与回归门禁
- [`audits/storage-persistence-governance.md`](audits/storage-persistence-governance.md): 重要数据落库治理基线
+331
View File
@@ -0,0 +1,331 @@
# CTMS 桌面端第一阶段详细方案
日期:2026-06-30
## 目标
第一阶段只交付 macOS 在线桌面客户端。桌面端使用 Tauri 承载现有 Vue/Vite 前端,连接已有 CTMS 服务端,不内嵌后端、不内嵌数据库、不做离线能力。
本阶段结束后,应能在 macOS 上启动 CTMS 桌面 App,配置 CTMS 服务端地址,完成登录,并执行现有 Web 端的常规在线业务流程。Web 端构建和现有 Docker/nginx 部署必须继续可用。
## 范围边界
本阶段必须做:
-`frontend/` 内加入 Tauri 工程结构。
- 复用现有 Vue 3、Vite、Element Plus、Pinia、Vue Router、Axios 前端代码。
- 让桌面端连接已有 CTMS 后端公开入口,推荐连接 nginx 入口而不是直接连接后端容器。
- 提供桌面端服务端地址配置能力。
- 抽出最小运行时适配层,避免业务页面直接依赖 Tauri API。
- 保持认证、权限、审计和业务数据持久化由 FastAPI 后端裁决。
- 产出 macOS 开发构建流程和后续签名、公证、DMG 发布路径。
本阶段明确不做:
- 离线登录、离线浏览、离线草稿、离线队列、离线同步。
- 本地 PostgreSQL、SQLite、IndexedDB 业务数据缓存或本地 API 镜像。
- 内嵌 Python/FastAPI 后端服务。
- 重写 CTMS 业务页面。
- Windows 安装包交付。
- 自动更新实现。
- token/session 安全存储迁移;该事项进入第二阶段。
## 当前代码影响点
现状:
- `frontend/src/api/axios.ts``frontend/src/api/authClient.ts``baseURL` 都是 `/`
- API 调用路径均为 `/api/v1/...`
- Web 端依赖 nginx 同域反代 `/api`
- Tauri 打包后页面运行在桌面 WebView 中,不能继续假设 `/api` 一定代表 CTMS 服务端。
- `frontend/src/utils/auth.ts` 当前使用 `localStorage` 保存 token。
- `frontend/src/session/sessionManager.ts` 依赖 `localStorage``sessionStorage``BroadcastChannel`
第一阶段的核心改动是先解决“桌面端如何知道并访问服务端地址”,同时尽量不触碰业务页面。
## 目标架构
```mermaid
flowchart LR
A["Tauri macOS App"] --> B["Bundled Vue/Vite Frontend"]
B --> C["Runtime Adapters"]
C --> D["Configured CTMS Server URL"]
D --> E["nginx / HTTPS entry"]
E --> F["FastAPI backend"]
F --> G["PostgreSQL"]
```
桌面端只负责本地窗口、运行时配置和少量平台能力。业务数据仍走服务端 API,权限和审计仍由后端完成。
## Tauri 工程方案
Tauri 目录放在现有前端工程内:
```text
frontend/
src-tauri/
tauri.conf.json
Cargo.toml
src/
main.rs
capabilities/
default.json
```
建议新增脚本:
```json
{
"scripts": {
"tauri": "tauri",
"desktop:dev": "tauri dev",
"desktop:build": "tauri build",
"desktop:bundle:dmg": "tauri build -- --bundles dmg"
}
}
```
Tauri 配置方向:
- `build.beforeDevCommand`: `npm run dev`
- `build.beforeBuildCommand`: `npm run build`
- `build.devUrl`: `http://localhost:5173`
- `build.frontendDist`: `../dist`
- 主窗口标题:`CTMS`
- 主窗口初始尺寸建议:`1440x900`
- 最小窗口尺寸建议:`1180x760`
- bundle identifier 待最终确认,临时建议:`cn.huapont.ctms.desktop`
第一阶段不加载远程 Web UI。Tauri 只加载本地打包出的前端资源,远程访问仅限 Axios 访问配置好的 CTMS 服务端。
## Vite 调整方案
当前 `vite.config.ts` 为 Docker 开发做了固定代理:
- dev server 端口:`5173`
- HMR client port`8888`
- `/api` 代理目标:`http://backend:8000`
为兼容 Web、Docker 和 Tauri,建议改成环境变量驱动:
- `VITE_DEV_API_PROXY_TARGET`:默认 `http://backend:8000`
- `VITE_HMR_CLIENT_PORT`Docker/nginx 开发时设为 `8888`,桌面端开发不设置。
- `TAURI_DEV_HOST`:按 Tauri/Vite 推荐方式兼容 Tauri dev。
Vite 配置需要补充:
- `server.strictPort: true`,避免 Tauri devUrl 与 Vite 实际端口不一致。
- `server.watch.ignored: ["**/src-tauri/**"]`,避免 Rust 目录变动触发不必要的前端监听。
- `clearScreen: false`,避免 Rust 编译错误被 Vite 清屏隐藏。
- `build.target` 按 Tauri 平台设置:Windows 使用 Chromium 目标,macOS 使用 Safari/WebKit 目标。
- `envPrefix` 保留 `VITE_`,并允许 `TAURI_ENV_*`
## 服务端地址配置
新增运行时适配层:
```text
frontend/src/runtime/
platform.ts
apiBaseUrl.ts
desktopServerConfig.ts
```
职责:
- `platform.ts`
- 判断当前是否运行在 Tauri。
- 判断目标平台是 Web、macOS 桌面端还是 Windows 桌面端。
- 业务页面不得直接读取 Tauri 全局对象。
- `desktopServerConfig.ts`
- 保存和读取桌面端服务端地址。
- 第一阶段可继续使用 `localStorage` 保存服务器地址;这不是业务数据,也不是离线能力。
- key 建议:`ctms_desktop_server_url`
- 仅接受 `http://localhost``http://127.0.0.1``https://...`。非本地生产服务不接受明文 HTTP。
- `apiBaseUrl.ts`
- Web 端返回 `/`,保持现有同域 `/api` 行为。
- 桌面端返回配置的服务端 origin,例如 `https://ctms.example.com/`
- 统一规范尾斜杠,避免拼接出错。
Axios 调整:
- `frontend/src/api/axios.ts` 使用 `resolveApiBaseUrl()` 初始化 `baseURL`
- `frontend/src/api/authClient.ts` 同步使用同一 baseURL。
- 当桌面端服务端地址变化时,需要更新两个 Axios 实例的 `defaults.baseURL`
- 现有 API path 继续保持 `/api/v1/...`,不修改业务 API 文件。
## 服务端配置入口
第一阶段需要一个轻量的桌面端服务端设置入口。
建议实现方式:
- 桌面端启动时,如果没有服务端地址,则在登录页前展示服务端设置界面。
- 登录页提供“服务器设置”入口,允许修改当前服务端地址。
- 保存前调用 `${serverUrl}/health` 做连通性检查。
- 连通性检查失败时允许用户重新输入,不自动降级到离线模式。
- Web 端不显示桌面端服务器设置入口。
建议新增文件:
```text
frontend/src/views/DesktopServerSettings.vue
frontend/src/router/desktopGuard.ts
frontend/src/runtime/desktopServerConfig.ts
```
路由策略:
- 保持现有 Web 路由结构。
- 桌面端未配置服务端时,将用户导向 `/desktop/server-settings`
- `/login` 页面中允许打开服务器设置。
- Web 端访问 `/desktop/server-settings` 时重定向到 `/login` 或显示不可用状态。
## 认证和会话策略
第一阶段保持现有认证机制:
- 登录仍使用后端登录公钥和加密登录流程。
- token 仍通过 `frontend/src/utils/auth.ts` 存储在 `localStorage`
- 会话超时、token keep-alive、401 refresh 仍沿用现有逻辑。
本阶段只允许做为 Tauri 接入所必需的最小改动:
- API baseURL 可切换。
- 服务端地址变化时清理当前 token 和项目上下文,要求重新登录。
- 不引入桌面安全存储;该事项归入第二阶段。
## Tauri 权限策略
第一阶段不需要文件系统、Shell、系统通知、自动更新或单实例插件。
权限原则:
- 只启用主窗口运行所需的最小 core capability。
- 不开放宽泛文件系统权限。
- 不开放 shell 执行能力。
- 不开放远程页面访问 Tauri command 的能力。
- 如确实需要读取 App 版本或平台信息,优先通过窄适配层处理,并明确 capability。
第一阶段建议不新增自定义 Tauri command。服务端地址配置可以先由前端 `localStorage` 完成。
## macOS 打包路径
开发构建:
```bash
cd /Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop/frontend
npm run desktop:dev
```
生产构建:
```bash
cd /Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop/frontend
npm run desktop:build
```
DMG 构建:
```bash
cd /Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop/frontend
npm run desktop:bundle:dmg
```
正式分发前需要:
- Apple Developer 账号。
- macOS 代码签名证书。
- notarization 所需 App Store Connect API 或 Apple ID 凭据。
- 明确是否分发 DMG,第一阶段推荐 DMG。
第一阶段可以先产出未签名或 ad-hoc 签名的内部开发构建,但不能把它描述为正式可分发版本。
## 实施步骤
1. 初始化 Tauri
- 安装 `@tauri-apps/cli`
-`frontend/` 下生成 `src-tauri/`
- 固定基础配置:app name、window title、bundle identifier、devUrl、frontendDist。
2. 调整 Vite
- 引入 Tauri 兼容配置。
- 保持 Docker/nginx 开发代理不破坏。
- 使用环境变量控制代理和 HMR。
3. 新增运行时适配层
- 添加 `platform.ts`
- 添加 `apiBaseUrl.ts`
- 添加 `desktopServerConfig.ts`
- 新增单元测试覆盖 URL 规范化、Web/Tauri 分支和非法 URL 拒绝。
4. 改造 API client
- `axios.ts` 使用统一 baseURL。
- `authClient.ts` 使用统一 baseURL。
- 服务端地址变化后刷新 Axios baseURL。
- 保持 API path 和业务模块不变。
5. 添加桌面服务端设置界面
- 桌面端未配置服务端时拦截到设置页。
- 保存时校验 URL 和 `/health`
- 修改服务端地址时清理当前登录态和项目上下文。
6. 最小 Tauri 权限
- 检查 `capabilities/default.json`
- 移除第一阶段不需要的插件和权限。
- 不新增自定义 command,除非实现过程证明必要。
7. macOS 验证和文档
- 运行 Web 构建、类型检查、单元测试。
- 运行 Tauri dev。
- 验证 macOS 打包。
- 补充桌面端运行说明。
## 验收清单
功能验收:
- macOS 桌面 App 可以启动。
- 首次启动未配置服务端时进入服务器设置。
- 服务端地址保存前会校验 `/health`
- 配置有效服务端后可以登录。
- 登录后能进入项目列表和项目工作区。
- 现有权限控制、401 refresh、会话超时行为与 Web 端一致。
- 切换服务端地址会清理当前登录态。
工程验收:
- `npm run build` 通过。
- `npm run type-check` 通过。
- `npm run test:unit` 通过,或明确记录失败原因。
- `npm run desktop:dev` 可启动 macOS 窗口。
- `npm run desktop:build` 可完成构建。
- Web 端 Docker/nginx 访问不回归。
边界验收:
- 没有新增本地业务数据缓存。
- 没有内嵌后端服务。
- 没有本地数据库。
- 没有离线队列或同步机制。
- Tauri API 没有散落在业务页面中。
- Tauri capability 没有开放第一阶段不需要的文件系统或 shell 权限。
## 风险和决策点
- bundle identifier 需要正式确认,建议在第一阶段实现前由产品或组织负责人定稿。
- 桌面端连接公开 CTMS 服务时,生产环境应使用 HTTPS;本地开发可允许 localhost HTTP。
- 当前后端 CORS 是宽松配置。第一阶段可先不改后端,但正式分发前应评估是否收紧允许来源。
- token 第一阶段仍在 `localStorage`,这是有意识的阶段性选择;安全存储迁移必须排入第二阶段。
- macOS 正式分发需要 Apple Developer、签名和 notarization,不应等到最后一天处理。
## 参考资料
- Tauri 创建项目与向现有前端加入 Taurihttps://tauri.app/start/create-project/
- Tauri + Vite 配置:https://tauri.app/start/frontend/vite/
- Tauri capabilitieshttps://tauri.app/security/capabilities/
- Tauri DMG 分发:https://tauri.app/distribute/dmg/
- Tauri macOS 签名与公证:https://tauri.app/distribute/sign/macos/
- Tauri updater 资料,供第二阶段使用:https://tauri.app/plugin/updater/
+2
View File
@@ -34,6 +34,8 @@
第一阶段交付一个面向现有 CTMS 服务的 macOS 桌面壳。
详细方案见 [`desktop-phase-1-design.md`](desktop-phase-1-design.md)。
范围:
- 在现有前端工程中加入 Tauri 项目结构。
+3
View File
@@ -1,6 +1,9 @@
# Frontend feature flags and timeline overrides
VITE_RUNTIME_ENV=production
VITE_ALLOW_INSECURE_DEV_LOGIN=false
VITE_DEV_API_PROXY_TARGET=http://backend:8000
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
VITE_HMR_CLIENT_PORT=
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3
VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6
+233
View File
@@ -18,6 +18,7 @@
"vue-router": "^4.2.5"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
@@ -1354,6 +1355,238 @@
"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": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+5
View File
@@ -7,6 +7,10 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri",
"desktop:dev": "tauri dev",
"desktop:build": "tauri build",
"desktop:bundle:dmg": "tauri build -- --bundles dmg",
"test:unit": "vitest run --environment jsdom",
"type-check": "vue-tsc --noEmit",
"ui:contract": "node scripts/verify-ui-contract.mjs"
@@ -22,6 +26,7 @@
"vue-router": "^4.2.5"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
+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"]
}
}
+11 -1
View File
@@ -1,11 +1,21 @@
import axios from "axios";
import type { AxiosResponse } from "axios";
import { resolveApiBaseUrl } from "../runtime/apiBaseUrl";
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig";
const authClient = axios.create({
baseURL: "/",
baseURL: resolveApiBaseUrl(),
timeout: 15000,
});
export const refreshAuthClientBaseUrl = (): void => {
authClient.defaults.baseURL = resolveApiBaseUrl();
};
if (typeof window !== "undefined") {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshAuthClientBaseUrl);
}
export type ExtendResponse = {
accessToken: string;
expiresAt: string;
+11 -1
View File
@@ -3,12 +3,22 @@ import { ElMessage } from "element-plus";
import { getToken } from "../utils/auth";
import type { ApiError } from "../types/api";
import { TEXT } from "../locales";
import { resolveApiBaseUrl } from "../runtime/apiBaseUrl";
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig";
const instance: AxiosInstance = axios.create({
baseURL: "/",
baseURL: resolveApiBaseUrl(),
timeout: 15000,
});
export const refreshApiBaseUrl = (): void => {
instance.defaults.baseURL = resolveApiBaseUrl();
};
if (typeof window !== "undefined") {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshApiBaseUrl);
}
const NETWORK_RETRY_LIMIT = 10;
const NETWORK_RETRY_DELAY_MS = 30000;
+20 -15
View File
@@ -12,22 +12,27 @@ import App from "./App.vue";
import router from "./router";
import { getToken } from "./utils/auth";
import { useStudyStore } from "./store/study";
import { shouldRequireDesktopServerUrl } from "./runtime/desktopServerConfig";
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
dayjs.locale("zh-cn");
app.use(ElementPlus, { locale: zhCn });
const bootstrap = async () => {
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
dayjs.locale("zh-cn");
app.use(ElementPlus, { locale: zhCn });
// 初始化项目上下文
const studyStore = useStudyStore();
studyStore.loadCurrentStudy();
if (getToken()) {
await studyStore.rehydrateStudyForLastUser();
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
// 初始化项目上下文
const studyStore = useStudyStore();
studyStore.loadCurrentStudy();
if (getToken() && !shouldRequireDesktopServerUrl()) {
await studyStore.rehydrateStudyForLastUser();
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
app.use(router);
await router.isReady();
app.use(router);
await router.isReady();
app.mount("#app");
app.mount("#app");
};
bootstrap();
+20
View File
@@ -0,0 +1,20 @@
import type { RouteLocationNormalized } from "vue-router";
import { hasDesktopServerUrl, shouldRequireDesktopServerUrl } from "../runtime/desktopServerConfig";
import { isTauriRuntime } from "../runtime/platform";
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 Register from "../views/Register.vue";
import ForgotPassword from "../views/ForgotPassword.vue";
import DesktopServerSettings from "../views/DesktopServerSettings.vue";
import StudyHome from "../views/StudyHome.vue";
import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.vue";
@@ -55,6 +56,7 @@ import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
import SubjectForm from "../views/subjects/SubjectForm.vue";
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
import { TEXT } from "../locales";
import { resolveDesktopRouteRedirect } from "./desktopGuard";
const SYSTEM_PERMISSION_READ = "system:permissions:read";
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
@@ -78,6 +80,12 @@ const routes: RouteRecordRaw[] = [
component: ForgotPassword,
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
},
{
path: "/desktop/server-settings",
name: "DesktopServerSettings",
component: DesktopServerSettings,
meta: { public: true, title: "服务器设置" },
},
{
path: "/",
component: Layout,
@@ -504,6 +512,12 @@ const ensureProjectPermissionAccess = async (
};
router.beforeEach(async (to, _from, next) => {
const desktopRedirect = resolveDesktopRouteRedirect(to);
if (desktopRedirect) {
next({ path: desktopRedirect });
return;
}
const auth = useAuthStore();
const studyStore = useStudyStore();
const getToken = () => auth.token;
+7
View File
@@ -0,0 +1,7 @@
import { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform";
export const resolveApiBaseUrl = (): string => {
if (!isTauriRuntime()) return "/";
return getDesktopServerUrl() || "/";
};
@@ -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();
+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/desktopServerConfig";
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">
<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>
</div>
</el-form>
@@ -257,6 +260,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales";
import { isTauriRuntime } from "../runtime/platform";
import {
consumeLogoutReason,
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 loginError = ref<{ title: string; message?: string } | null>(null);
const protocolSections = authProtocolSections;
const showDesktopServerSettings = isTauriRuntime();
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
const availableEmailDomains = computed(() => Array.from(new Set([
@@ -353,7 +358,7 @@ onMounted(async () => {
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);
const openProtocolDialog = () => { protocolDialogVisible.value = true; };
@@ -371,7 +376,11 @@ const onSubmit = async () => {
const studyStore = useStudyStore();
const userKey = auth.user?.email || form.email;
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) {
const status = error?.response?.status;
const detail: string = error?.response?.data?.detail || error?.response?.data?.message || "";
@@ -704,8 +713,8 @@ const onSubmit = async () => {
/* 卡片容器 */
.login-card-container {
width: 100%;
max-width: 380px;
width: clamp(480px, 34vw, 560px);
max-width: calc(100vw - 40px);
display: flex;
flex-direction: column;
}
@@ -1040,7 +1049,7 @@ const onSubmit = async () => {
margin-top: 20px;
}
.forgot-link, .register-link {
.forgot-link, .register-link, .server-settings-link {
font-size: 13px;
color: #2563eb;
text-decoration: none;
@@ -1048,7 +1057,7 @@ const onSubmit = async () => {
transition: color 0.15s;
}
.forgot-link:hover, .register-link:hover {
.forgot-link:hover, .register-link:hover, .server-settings-link:hover {
color: #1d4ed8;
text-decoration: underline;
}
+19 -4
View File
@@ -2,7 +2,15 @@ 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 tauriDevHost = process.env.TAURI_DEV_HOST;
const tauriPlatform = process.env.TAURI_ENV_PLATFORM;
const tauriDebug = process.env.TAURI_ENV_DEBUG === "true";
export default defineConfig({
clearScreen: false,
envPrefix: ["VITE_", "TAURI_ENV_"],
plugins: [
vue(),
{
@@ -27,16 +35,23 @@ export default defineConfig({
},
},
server: {
host: true,
host: tauriDevHost || true,
port: 5173,
hmr: {
clientPort: 8888,
strictPort: true,
hmr: hmrClientPort ? { clientPort: hmrClientPort } : undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
proxy: {
"/api": {
target: "http://backend:8000",
target: devApiProxyTarget,
changeOrigin: true,
},
},
},
build: {
target: tauriPlatform === "windows" ? "chrome105" : "safari13",
minify: tauriDebug ? false : "esbuild",
sourcemap: tauriDebug,
},
});