Initial commit

master
cysamurai 2 months ago
commit 63232e89c1

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -0,0 +1,23 @@
#项目规范
## 技术栈
- 前端:Tauri + Vue 3 + TypeScript
- 后端:Java + Spring Boot
- 数据库:MS SQL Server 或 OceanBase
## 代码规范
- 函数名用 camelcase组件名用 Pascalcase
- 所有函数必须有 JSDoc 注释
- 错误处理必须用 try/catch不能用.catch()
## 禁止行为
- 不使用 any 类型
- 不写裸 console.log用统一的 logger
- css不用内联样式
## 提交规范
feat:新功能 |fix:修复| refactor:重构

@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

@ -0,0 +1,280 @@
# `call-client` 项目说明
## 1. 项目概览
当前项目是一个基于 `Tauri 2 + Vue 3 + TypeScript + Vite` 的桌面客户端,已从原 `Electron + Vue` 项目迁移出主要业务骨架。
当前已完成的核心能力包括:
- 登录窗口、主窗口、票号列表窗口三窗口流程
- 前端业务页面迁移
- Tauri 宿主适配层
- 配置文件读写
- 本地日志写入
- Session 内存状态管理
- 主窗口与票号列表窗口之间的事件通信
- 基础 Linux 打包目标配置
- `src-tauri/icons/` 图标资源已补齐
## 2. 根目录结构
以下是当前项目根目录中最重要的目录和文件:
```text
call-client/
├─ electron-sourcecode/ # 原 Electron/Vue 源码备份目录,迁移参考用
├─ src/ # 当前 Vue 前端源码
├─ src-tauri/ # 当前 Tauri Rust 宿主源码
├─ dist/ # 前端构建输出目录
├─ node_modules/ # 前端依赖
├─ package.json # 前端依赖与 npm 脚本
├─ package-lock.json # npm 锁文件
├─ vite.config.ts # Vite 构建配置
├─ tsconfig.json # 前端 TS 配置
├─ tsconfig.node.json # Node/Vite 侧 TS 配置
├─ TAURI-MIGRATION.md # 迁移设计文档
├─ TAURI-MIGRATION-TASKS.md # 当前迁移任务清单
└─ PROJECT_GUIDE.md # 当前这份项目说明文档
```
## 3. 前端目录说明
`src/` 下的关键结构如下:
```text
src/
├─ api/
│ └─ index.ts # 后端接口封装
├─ assets/
│ ├─ base.css # 基础样式
│ ├─ main.css # 全局样式入口
│ └─ wavy-lines.svg # 背景图资源
├─ host/
│ ├─ config.ts # Tauri 配置读写封装
│ ├─ dialog.ts # 原生确认框封装
│ ├─ events.ts # 窗口事件通信封装
│ ├─ logger.ts # 宿主日志封装
│ ├─ session.ts # Session 读写封装
│ ├─ types.ts # 宿主层公共类型
│ └─ window.ts # 窗口控制封装
├─ router/
│ └─ index.ts # 路由和路由守卫
├─ types/
│ ├─ action.ts # 主叫号业务类型
│ ├─ rank.ts # 评价/排队统计类型
│ ├─ ticket.ts # 票池类型
│ ├─ user.ts # 登录用户类型
│ ├─ window.ts # 服务窗口类型
│ ├─ http.ts # HTTP 响应类型
│ └─ element-plus-locale.d.ts # Element Plus locale 声明
├─ utils/
│ └─ service.ts # axios 实例和 baseURL/token 处理
├─ views/
│ ├─ LoginView.vue # 登录页
│ ├─ MainView.vue # 主叫号页
│ ├─ ServerSetupView.vue # 服务地址配置页
│ └─ TicketListView.vue # 票号列表页
├─ App.vue # 路由出口
└─ main.ts # Vue 应用入口
```
## 4. `src-tauri` 目录说明
`src-tauri/` 下的关键结构如下:
```text
src-tauri/
├─ src/
│ ├─ commands/
│ │ ├─ config.rs # 配置文件命令
│ │ ├─ events.rs # 事件发送命令
│ │ ├─ logger.rs # 文件日志命令
│ │ ├─ mod.rs # commands 模块导出
│ │ ├─ session.rs # Session 命令
│ │ └─ window.rs # 窗口创建/切换/退出命令
│ ├─ lib.rs # Tauri Builder 和命令注册入口
│ ├─ main.rs # Rust 程序入口
│ └─ state.rs # 全局内存状态
├─ capabilities/
│ └─ default.json # Tauri capability 配置
├─ Cargo.toml # Rust 依赖配置
├─ build.rs # Tauri 构建脚本
└─ tauri.conf.json # Tauri 应用与打包配置
```
补充说明:
- `src-tauri/target/` 是 Rust 编译产物目录
- 该目录可能会很大,通常不需要手动修改
## 5. 当前窗口流程
当前实现的窗口生命周期如下:
1. 应用启动后默认打开 `login` 窗口
2. Rust 端会预创建隐藏的 `main` 窗口
3. 登录成功并选择窗口后,关闭 `login`,显示 `main`
4. 在 `main` 中可以打开 `ticketList` 子窗口
5. `ticketList` 可以通过事件驱动 `main` 执行呼叫或评价
6. 在 `main` 中退出登录时,会重新显示 `login`
7. 在 `main` 菜单中选择“退出程序”时,会真正退出整个应用
## 6. 如何运行开发环境
### 6.1 仅启动前端开发服务
在项目根目录执行:
```bash
npm run dev
```
说明:
- 该命令只启动 Vite 前端开发服务器
- 默认地址是 `http://localhost:1420`
### 6.2 构建前端
在项目根目录执行:
```bash
npm run build
```
说明:
- 会先执行 `vue-tsc --noEmit`
- 然后执行 `vite build`
- 构建结果输出到根目录 `dist/`
### 6.3 启动 Tauri 开发模式
在项目根目录执行:
```bash
npm run tauri dev
```
说明:
- 该命令会先执行 `npm run dev`
- 然后由 Tauri 启动桌面应用
- 需要本机已经安装 Rust / Cargo
如果本机没有安装 Rust/Cargo`tauri dev` 无法运行。
## 7. 如何打包
### 7.1 前提条件
打包前需要满足以下条件:
1. 本机已安装 Rust / Cargo
2. 本机已安装 Tauri 所需系统依赖
3. 已补齐 `src-tauri/icons/*` 图标资源
当前项目的 `tauri.conf.json` 已配置 Linux 打包目标:
- `deb`
- `appimage`
当前状态:
- `bundle.active` 已开启
- 已配置 Linux 打包目标 `deb``appimage`
- `src-tauri/icons/` 中已存在当前打包所需图标文件
### 7.2 构建前端产物
```bash
npm run build
```
### 7.3 执行 Tauri 打包
```bash
npm run tauri build
```
说明:
- 实际打包使用的是 `src-tauri/tauri.conf.json`
- 前端产物目录由 `frontendDist: "../dist"` 指向根目录 `dist`
## 8. 当前 npm 脚本
`package.json` 中当前可用脚本如下:
```json
{
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
}
```
常用命令示例:
```bash
npm run dev
npm run build
npm run preview
npm run tauri dev
npm run tauri build
```
## 9. 关键配置文件
### `package.json`
作用:
- 管理前端依赖
- 定义 npm 脚本
### `vite.config.ts`
作用:
- 配置 Vite 开发端口
- 配置 Tauri 开发环境 HMR
- 配置前端拆包策略
- 配置 Element Plus 自动按需引入
### `src-tauri/tauri.conf.json`
作用:
- 配置应用名、标识符、初始窗口
- 配置 `tauri dev` 的前端地址
- 配置 `tauri build` 的前端产物目录
- 配置 Linux 打包目标
### `TAURI-MIGRATION.md`
作用:
- 说明从 Electron 迁移到 Tauri 的目标、范围和策略
### `TAURI-MIGRATION-TASKS.md`
作用:
- 记录当前已完成项与待完成项
## 10. 当前已知注意事项
- 当前机器如果没有 Rust/Cargo无法实际运行 `tauri dev``tauri build`
- `electron-sourcecode/` 只是原项目参考目录,不参与当前 Tauri 运行
- `dist/``src-tauri/target/` 都是构建产物目录,不建议手改
## 11. 推荐使用顺序
日常开发建议按下面顺序进行:
1. 修改前端或 Rust 代码
2. 执行 `npm run build` 检查前端是否通过
3. 在已安装 Rust/Cargo 的环境执行 `npm run tauri dev`
4. 联调完成后执行 `npm run tauri build`

@ -0,0 +1,39 @@
# `call-client`
`call-client` 是一个基于 `Tauri 2 + Vue 3 + TypeScript + Vite` 的桌面客户端项目,已从原 `Electron + Vue` 项目迁移出主要业务骨架。
## 当前状态
- 已完成 `login -> main -> ticketList` 三窗口流程
- 已接入配置、日志、Session、窗口事件通信
- 已接入主业务页面迁移骨架
- 已补齐 `src-tauri/icons/` 图标资源
- 已开启 Tauri Linux 打包目标:`deb`、`appimage`
## 常用命令
```bash
npm run dev
npm run build
npm run tauri dev
npm run tauri build
```
说明:
- `npm run dev`:启动前端开发服务
- `npm run build`:执行前端类型检查和构建
- `npm run tauri dev`:启动 Tauri 开发模式
- `npm run tauri build`:执行 Tauri 打包
## 文档入口
- 项目结构、关键文件、运行方式、打包说明:`PROJECT_GUIDE.md`
- 迁移设计文档:`TAURI-MIGRATION.md`
- 当前迁移任务清单:`TAURI-MIGRATION-TASKS.md`
## 运行前提
- Node.js / npm 已安装
- 运行 `tauri dev``tauri build` 前,需要本机已安装 Rust / Cargo
- Linux 打包是否成功还取决于本机 Tauri 打包依赖是否完整

@ -0,0 +1,46 @@
# `call-client` Tauri 迁移任务清单
## 已完成
- 建立 `vue-router` 路由骨架,使用 `hash` 路由承载登录页、主叫号页、票号列表页
- 新增前端适配层:
- `src/host/session.ts`
- `src/host/config.ts`
- `src/host/logger.ts`
- `src/host/window.ts`
- `src/host/dialog.ts`
- `src/host/events.ts`
- 新增 Rust 宿主命令:
- `session_get`
- `session_set`
- `session_clear`
- `config_get_all`
- `config_merge`
- `app_log`
- `emit_to_window`
- `list_windows`
- 新增窗口生命周期命令:
- `open_main_window`
- `open_login_window`
- `open_ticket_window`
- `focus_window`
- `quit_app`
- 配置文件持久化已按文档要求保留为 `config.json`
- 日志已迁移到 Rust 侧,包含基础轮转和过期清理
- 已恢复 `login -> main -> ticketList` 三窗口切换
- 主界面“更多”菜单与暂停原因菜单已迁移为前端可用交互
- `tauri.conf.json` 已切换为 `npm` 构建命令并补充 Linux `deb` / `AppImage` 目标
- `src-tauri/icons/*` 图标资源已补齐,`bundle.active` 已开启
- 新增 `dialog` 插件接入
## 待接入原业务源码
- Tauri 真机联调三窗口行为
- Rust 端实际编译与运行验证
- 按最终需求决定是否进一步恢复原生系统菜单
- Linux 真机打包验证
## 验收结果
- `npm run build` 已通过
- Rust 代码未能本机编译验证,因为当前环境缺少 `cargo`

@ -0,0 +1,645 @@
# `call-client` 迁移到 Tauri 技术文档
## 1. 目标
将当前基于 `Electron + Vue 3 + TypeScript + electron-vite` 的桌面应用迁移为 `Tauri + Vue 3 + TypeScript`在尽量保留现有前端业务代码的前提下替换主进程、窗口管理、IPC、配置与日志等宿主能力。
迁移完成后应满足:
- 保持现有登录、主叫号、票号列表、服务地址配置等业务功能不变
- 保持 Linux麒麟打包能力优先支持 `x64` / `arm64`
- 保持本地配置与日志的 XDG 规范行为
- 保持多窗口、原生确认框、菜单、前后端通信能力
- 为后续减小安装包体积、降低内存占用、提高跨平台一致性做准备
---
## 2. 当前项目现状
当前项目目录与能力大致如下:
- 前端:`Vue 3 + vue-router + element-plus + axios`
- 宿主:`Electron`
- 构建:`electron-vite + electron-builder`
- 主进程入口:`src/main/index.ts`
- 窗口创建:`src/main/window.ts`
- 预加载桥接:`src/preload/index.ts`
- 本地配置:`src/main/app-config.ts`
- 文件日志:`src/main/file-logger.ts`
- 渲染层类型声明:`src/renderer/src/env.d.ts`
当前渲染层大量依赖 `window.xxx` 能力:
- `window.winControl`
- `window.contextMenu`
- `window.pauseMenu`
- `window.session`
- `window.appLogger`
- `window.appConfig`
- `window.nativeDialog`
- `window.ticketToMain`
- `window.mainTicketEvents`
这意味着迁移的核心不是 Vue 页面重写,而是把这些 Electron 注入能力改造成 Tauri 的命令、事件和窗口 API。
---
## 3. 为什么适合迁移到 Tauri
当前项目业务逻辑主要集中在前端,宿主层职责相对清晰:
- 管理窗口
- 提供原生菜单和对话框
- 管理会话状态
- 提供配置读写
- 提供文件日志
- 在多个窗口之间转发事件
这些能力都可以在 Tauri 中找到对应实现,因此该项目具备较好的迁移可行性。
适合迁移的原因:
- 前端已是标准 Vite/Vue 结构Tauri 可直接复用
- 本地能力边界清晰,便于逐项替换
- Linux 发行打包对 Tauri 更友好
- 当前没有深度依赖 Electron 的 `BrowserView`、`desktopCapturer`、`webContents` 高级能力
---
## 4. Electron 到 Tauri 能力映射
### 4.1 窗口
当前 Electron
- `createLoginWindow()`
- `createMainWindow()`
- `createTicketWindow()`
- 运行时调用 `show()`、`focus()`、`restore()`、`minimize()`
Tauri 对应方案:
- 主窗口由 Tauri 启动时创建
- 其它窗口通过 `WebviewWindow` 或 Rust 端创建
- 窗口最小化、关闭、显示、聚焦使用 `@tauri-apps/api/window`
建议:
- `login`、`main`、`ticketList` 改为具名 Tauri 窗口
- 窗口路由仍保留 `hash` 路由,降低前端改造成本
### 4.2 IPC / 桥接
当前 Electron
- `ipcMain.handle`
- `ipcRenderer.invoke`
- `ipcRenderer.send/on`
- `contextBridge.exposeInMainWorld`
Tauri 对应方案:
- `invoke()` 调用 Rust `#[tauri::command]`
- `emit/listen` 做窗口间事件通信
- 直接在前端封装 `src/renderer/src/tauri-api/*` 替代 `window.xxx`
建议:
- 不再保留 `window.xxx` 直挂模式
- 改为前端统一封装模块,例如:
- `src/renderer/src/host/session.ts`
- `src/renderer/src/host/config.ts`
- `src/renderer/src/host/logger.ts`
- `src/renderer/src/host/window.ts`
- `src/renderer/src/host/menu.ts`
### 4.3 原生菜单
当前 Electron
- 主进程动态构建菜单
- 用于“办税员窗口 / 票号列表 / 退出程序”
- 暂停菜单用于选择暂停原因
Tauri 对应方案:
- Rust 菜单 API
- 或前端自绘菜单 + Rust 命令
- 简单场景也可直接使用前端弹层替代原生菜单
建议:
- “系统/上下文菜单”优先保留原生
- “暂停原因菜单”可以迁移成前端 `Element Plus` 下拉/弹窗,降低 Rust 复杂度
### 4.4 原生确认框
当前 Electron
- `dialog.showMessageBox`
Tauri 对应方案:
- `@tauri-apps/plugin-dialog`
建议:
- `window.nativeDialog.confirm()` 改为前端封装 `confirmNative()`
- 接口签名保持一致,减少页面改动
### 4.5 配置文件
当前 Electron
- `src/main/app-config.ts`
- Linux 下遵循 `XDG_CONFIG_HOME`
- 使用 JSON 文件持久化
Tauri 对应方案:
- Rust 命令自己读写 JSON 文件
- 目录使用 `tauri::api::path` 或 Tauri 2 对应 path API
建议:
- 继续保持当前配置文件结构不变
- 继续使用 `config.json`
- 路径规则继续保持:
- Linux`$XDG_CONFIG_HOME/<app>` 或 `~/.config/<app>`
- 其他平台:应用数据目录
### 4.6 文件日志
当前 Electron
- `src/main/file-logger.ts`
- Linux 遵循 `XDG_STATE_HOME`
- 纯文本
- 100MB 轮转
- 7 天清理
Tauri 对应方案:
- Rust 端实现同样的文件日志模块
建议:
- 日志逻辑直接迁移到 Rust
- 保持现有文件命名、轮转、保留策略不变
- 渲染层仍使用统一 `log(level, message)` 接口
### 4.7 Session 状态
当前 Electron
- 主进程内存维护 `sessionState`
- 渲染层通过 IPC 获取/设置
Tauri 对应方案:
- Rust `State<T>` 保存内存会话
- 通过 `command` 获取/设置
建议:
- 维持现有结构:
- `empUid`
- `winUid`
- `queueToken`
### 4.8 多窗口事件转发
当前 Electron
- `ticket:main-action`
- `main:ticket-action`
Tauri 对应方案:
- 指定窗口 `emit_to`
- 目标窗口 `listen`
建议:
- 保持当前事件模型:
- `ticket -> main` 呼叫
- `ticket -> main` 评价
- 仅把传输媒介从 Electron IPC 改为 Tauri Event
---
## 5. 迁移范围拆解
### 5.1 前端可直接复用部分
这些部分原则上可以原样保留:
- `src/renderer/src/views/*.vue`
- `src/renderer/src/router/index.ts`
- `src/renderer/src/api/index.ts`
- `src/renderer/src/utils/service.ts`
- 大部分 TypeScript 类型定义
- Axios 与后端接口封装
需要改动的地方主要是所有 `window.xxx` 调用。
### 5.2 必须重写的部分
这些 Electron 专属模块需要全部替换:
- `src/main/index.ts`
- `src/main/window.ts`
- `src/preload/index.ts`
- `src/renderer/src/env.d.ts` 中的 Electron 全局声明
- `electron.vite.config.ts`
- `electron-builder.yml`
- `package.json` 中 Electron 构建脚本和依赖
### 5.3 可迁移但建议重构的部分
- `pauseMenu` 建议从原生菜单改成前端弹窗
- `contextMenu` 可按实际需要决定是否保留原生
- `window.winControl.loginSuccess()` 可重构为前端路由 + 新窗口显示逻辑
---
## 6. 推荐的 Tauri 目标结构
建议最终目录结构:
```text
call-client/
├─ src/
│ ├─ renderer/
│ │ └─ src/
│ │ ├─ api/
│ │ ├─ host/
│ │ │ ├─ session.ts
│ │ │ ├─ config.ts
│ │ │ ├─ logger.ts
│ │ │ ├─ window.ts
│ │ │ ├─ dialog.ts
│ │ │ └─ events.ts
│ │ ├─ router/
│ │ ├─ views/
│ │ └─ ...
│ └─ shared/
├─ src-tauri/
│ ├─ src/
│ │ ├─ main.rs
│ │ ├─ commands/
│ │ │ ├─ session.rs
│ │ │ ├─ config.rs
│ │ │ ├─ logger.rs
│ │ │ ├─ window.rs
│ │ │ ├─ dialog.rs
│ │ │ └─ events.rs
│ │ └─ state.rs
│ ├─ tauri.conf.json
│ └─ Cargo.toml
└─ package.json
```
---
## 7. 前端接口替换清单
当前前端依赖的 Electron 注入接口,需要替换为 Tauri 封装:
### 7.1 `window.winControl`
当前能力:
- `windowMinimize()`
- `windowClose()`
- `loginSuccess()`
Tauri 替代:
- `getCurrentWindow().minimize()`
- `getCurrentWindow().close()`
- 登录成功后触发:
- 显示主窗口
- 关闭登录窗口
- 或仅路由切换,视最终窗口方案而定
### 7.2 `window.contextMenu`
当前能力:
- 打开上下文菜单
Tauri 替代:
- Rust 菜单
- 或前端菜单组件
### 7.3 `window.pauseMenu`
当前能力:
- 弹出暂停原因菜单
- 回传用户选中的原因
建议替代:
- 使用前端对话框/选择器,避免专门做 Rust 菜单事件回传
### 7.4 `window.session`
当前能力:
- `get()`
- `set()`
- `clear()`
Tauri 替代:
- `invoke('session_get')`
- `invoke('session_set', { ... })`
- `invoke('session_clear')`
### 7.5 `window.appLogger`
当前能力:
- `log(level, message)`
Tauri 替代:
- `invoke('app_log', { level, message })`
### 7.6 `window.appConfig`
当前能力:
- `getAll()`
- `set(partial)`
Tauri 替代:
- `invoke('config_get_all')`
- `invoke('config_merge', { partial })`
### 7.7 `window.nativeDialog`
当前能力:
- `confirm({ title, message, okLabel, cancelLabel })`
Tauri 替代:
- 封装 `plugin-dialog`
### 7.8 `window.ticketToMain` / `window.mainTicketEvents`
当前能力:
- 票号列表窗口通知主窗口执行呼叫/评价
Tauri 替代:
- `emitTo('main', 'ticket-action', payload)`
- 主窗口 `listen('ticket-action', ...)`
---
## 8. 后端 API 层迁移影响
后端 API 请求层基本不需要因为迁移到 Tauri 而重写。
现有:
- `axios`
- `src/renderer/src/api/index.ts`
- `src/renderer/src/utils/service.ts`
保留原则:
- 所有 `/auth/login`、`/call-terminal/*`、`/isRank`、`/getQueueCount` 保持不变
- 仅调整“token 获取来源”和“应用配置读取来源”
需要注意:
- 目前 `service.ts``window.session.get()` 中拿 `queueToken`
- 迁移后应改成 `host/session.ts` 封装
---
## 9. 打包与发布迁移
当前:
- 使用 `electron-builder`
- 面向 `deb` / `AppImage`
迁移到 Tauri 后:
- 使用 `tauri build`
- Linux 侧通常可生成 `deb` / `AppImage`
建议:
- 保留现有 `resources/call_icon.png`
- 重新配置 Tauri 的:
- 应用名
- 图标
- 窗口尺寸
- 标题栏/装饰策略
---
## 10. 重点风险
### 10.1 多窗口迁移复杂度
当前项目不是单窗口,而是至少有:
- 登录窗口
- 主窗口
- 票号列表窗口
这在 Tauri 可以实现,但实现方式与 Electron 不同,需要提前设计窗口生命周期和事件路由。
### 10.2 原生菜单差异
Electron 菜单 API 较成熟Tauri 菜单方案和事件模型不同,暂停菜单与上下文菜单可能需要重构。
### 10.3 文件系统逻辑要从 Node 改到 Rust
以下逻辑不能直接复用:
- `fs`
- `path`
- `os`
- `app.getPath()`
需要改写为 Rust。
### 10.4 前端大量 `window.xxx` 依赖
虽然页面 UI 可复用,但所有页面中使用的宿主能力都需要替换接口。
建议先做适配层,不要在页面里直接写 Tauri API。
### 10.5 开发流程变化
从:
- `electron-vite dev`
改为:
- `tauri dev`
工程脚本、CI、打包环境都要同步调整。
---
## 11. 推荐迁移策略
建议使用“分阶段替换”,不要一次性推倒重来。
### 阶段 1建立 Tauri 空壳
目标:
- 保留现有 Vue 前端
- 新建 `src-tauri`
- 跑通 `tauri dev`
- 页面能正常打开
产出:
- `src-tauri/`
- `tauri.conf.json`
- 基础窗口配置
### 阶段 2建立宿主适配层
目标:
- 新建前端 `host/*` 封装
- 暂时不改页面业务,只改调用入口
例如将:
- `window.appConfig.getAll()`
替换为:
- `hostConfig.getAll()`
这样后续无论是 Electron 还是 Tauri都能通过适配层承接。
### 阶段 3迁移配置、日志、session
优先迁移最基础的宿主能力:
- 配置文件
- 日志
- 会话状态
因为这些能力会被多个页面依赖。
### 阶段 4迁移窗口与事件
迁移:
- 登录成功切主窗口
- 票号列表子窗口
- 主窗口与子窗口之间动作事件
### 阶段 5迁移原生菜单/对话框
迁移:
- 确认框
- 上下文菜单
- 暂停菜单
此阶段可顺便评估哪些能力改成前端组件更合适。
### 阶段 6移除 Electron 依赖
删除:
- `electron`
- `electron-builder`
- `electron-vite`
- `src/main/*`
- `src/preload/*`
并重写 `package.json` 脚本。
---
## 12. 预计工作量
按当前项目规模估算:
- 前端页面复用:高
- 宿主层重写:中到高
- 多窗口与事件:中
- 打包与环境:中
大致可按以下量级评估:
- 基础可运行 Tauri 版本1 到 2 天
- 功能完整迁移3 到 7 天
- 打包与麒麟环境联调1 到 3 天
实际取决于:
- 是否保留原生菜单
- 是否保留多窗口
- 是否要求与当前行为完全一致
---
## 13. 最小可行迁移方案
如果目标是“尽快落地”,建议先做最小版本:
- 保留单主窗口
- `ticketList` 改为路由页/弹层,而不是独立窗口
- 暂停菜单改为前端弹窗
- 原生确认框使用 Tauri dialog 插件
- 配置、日志、session 用 Rust 命令重写
这样能明显降低迁移复杂度。
---
## 14. 结论
该项目适合迁移到 Tauri且前端业务代码可大量复用。真正的迁移重点在于
- 宿主能力替换
- 多窗口与事件通信重构
- 本地配置/日志/session 的 Rust 实现
- 打包链路从 Electron Builder 迁移到 Tauri Build
推荐实施顺序:
1. 先建立 Tauri 壳与前端适配层
2. 再迁移配置、日志、session
3. 再迁移窗口、事件、对话框、菜单
4. 最后移除 Electron
---
## 15. 下一步建议
如果你确认要开始迁移,下一份文档建议继续输出:
- `TAURI-MIGRATION-TASKS.md`
内容包括:
- 逐文件改造清单
- Electron API 到 Tauri API 映射表
- `src-tauri` 初始代码结构
- `package.json` 新脚本方案
- 每一步验收标准

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Vue + Typescript App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,33 @@
{
"name": "call-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "^2.4.2",
"axios": "^1.14.0",
"element-plus": "^2.13.6",
"sass": "^1.98.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.0.0",
"vue": "^3.5.13",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

File diff suppressed because it is too large Load Diff

@ -0,0 +1,27 @@
[package]
name = "call-client"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "call_client_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-store = "2.4.2"

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "login", "ticketList"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"dialog:default",
"opener:default",
"store:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@ -0,0 +1,113 @@
use std::{
collections::BTreeMap,
env,
fs,
path::{Path, PathBuf},
};
use serde_json::{Map, Value};
const APP_NAME: &str = "call-client";
const CONFIG_FILE_NAME: &str = "config.json";
fn get_home_dir() -> PathBuf {
if let Ok(value) = env::var("HOME") {
return PathBuf::from(value);
}
if let Ok(value) = env::var("USERPROFILE") {
return PathBuf::from(value);
}
PathBuf::from(".")
}
fn get_config_dir() -> PathBuf {
if cfg!(target_os = "linux") {
if let Ok(value) = env::var("XDG_CONFIG_HOME") {
return PathBuf::from(value).join(APP_NAME);
}
return get_home_dir().join(".config").join(APP_NAME);
}
if let Ok(value) = env::var("APPDATA") {
return PathBuf::from(value).join(APP_NAME);
}
get_home_dir().join(".").join(APP_NAME)
}
fn get_config_file_path() -> PathBuf {
get_config_dir().join(CONFIG_FILE_NAME)
}
fn ensure_parent_dir(path: &Path) -> Result<(), String> {
let Some(parent) = path.parent() else {
return Ok(());
};
fs::create_dir_all(parent).map_err(|error| format!("创建配置目录失败: {error}"))
}
fn read_config() -> Result<Value, String> {
let path = get_config_file_path();
if !path.exists() {
return Ok(Value::Object(Map::new()));
}
let content = fs::read_to_string(&path).map_err(|error| format!("读取配置文件失败: {error}"))?;
if content.trim().is_empty() {
return Ok(Value::Object(Map::new()));
}
serde_json::from_str::<Value>(&content).map_err(|error| format!("解析配置文件失败: {error}"))
}
fn write_config(value: &Value) -> Result<(), String> {
let path = get_config_file_path();
ensure_parent_dir(&path)?;
let content = serde_json::to_string_pretty(value).map_err(|error| format!("序列化配置失败: {error}"))?;
fs::write(path, content).map_err(|error| format!("写入配置文件失败: {error}"))
}
fn merge_value(target: &mut Value, patch: Value) {
match (target, patch) {
(Value::Object(target_map), Value::Object(patch_map)) => {
for (key, value) in patch_map {
if let Some(existing) = target_map.get_mut(&key) {
merge_value(existing, value);
} else {
target_map.insert(key, value);
}
}
}
(target_value, patch_value) => {
*target_value = patch_value;
}
}
}
#[tauri::command]
pub fn config_get_all() -> Result<BTreeMap<String, Value>, String> {
let value = read_config()?;
let Value::Object(map) = value else {
return Ok(BTreeMap::new());
};
Ok(map.into_iter().collect())
}
#[tauri::command]
pub fn config_merge(partial: Value) -> Result<BTreeMap<String, Value>, String> {
let mut current = read_config()?;
merge_value(&mut current, partial);
write_config(&current)?;
let Value::Object(map) = current else {
return Ok(BTreeMap::new());
};
Ok(map.into_iter().collect())
}

@ -0,0 +1,18 @@
use serde_json::Value;
use tauri::{AppHandle, Emitter, Manager};
#[tauri::command]
pub fn emit_to_window(
app: AppHandle,
label: String,
event: String,
payload: Value,
) -> Result<(), String> {
app.emit_to(label, event.as_str(), payload)
.map_err(|error| format!("发送窗口事件失败: {error}"))
}
#[tauri::command]
pub fn list_windows(app: AppHandle) -> Vec<String> {
app.webview_windows().keys().cloned().collect()
}

@ -0,0 +1,122 @@
use std::{
env,
fs::{self, OpenOptions},
io::Write,
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
const APP_NAME: &str = "call-client";
const LOG_FILE_NAME: &str = "app.log";
const MAX_LOG_SIZE_BYTES: u64 = 100 * 1024 * 1024;
const LOG_RETENTION_DAYS: u64 = 7;
fn get_home_dir() -> PathBuf {
if let Ok(value) = env::var("HOME") {
return PathBuf::from(value);
}
if let Ok(value) = env::var("USERPROFILE") {
return PathBuf::from(value);
}
PathBuf::from(".")
}
fn get_state_dir() -> PathBuf {
if cfg!(target_os = "linux") {
if let Ok(value) = env::var("XDG_STATE_HOME") {
return PathBuf::from(value).join(APP_NAME);
}
return get_home_dir().join(".local").join("state").join(APP_NAME);
}
if let Ok(value) = env::var("LOCALAPPDATA") {
return PathBuf::from(value).join(APP_NAME).join("state");
}
get_home_dir().join(".").join(APP_NAME).join("state")
}
fn ensure_parent_dir(path: &Path) -> Result<(), String> {
let Some(parent) = path.parent() else {
return Ok(());
};
fs::create_dir_all(parent).map_err(|error| format!("创建日志目录失败: {error}"))
}
fn cleanup_old_logs(dir: &Path) -> Result<(), String> {
if !dir.exists() {
return Ok(());
}
let retention = Duration::from_secs(LOG_RETENTION_DAYS * 24 * 60 * 60);
let now = SystemTime::now();
let entries = fs::read_dir(dir).map_err(|error| format!("读取日志目录失败: {error}"))?;
for entry in entries {
let entry = entry.map_err(|error| format!("遍历日志目录失败: {error}"))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let metadata = entry
.metadata()
.map_err(|error| format!("读取日志元数据失败: {error}"))?;
let Ok(modified_at) = metadata.modified() else {
continue;
};
if now.duration_since(modified_at).unwrap_or_default() > retention {
fs::remove_file(&path).map_err(|error| format!("清理过期日志失败: {error}"))?;
}
}
Ok(())
}
fn rotate_log_if_needed(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let metadata = fs::metadata(path).map_err(|error| format!("读取日志文件失败: {error}"))?;
if metadata.len() < MAX_LOG_SIZE_BYTES {
return Ok(());
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("生成日志时间戳失败: {error}"))?
.as_secs();
let rotated_path = path.with_file_name(format!("app-{timestamp}.log"));
fs::rename(path, rotated_path).map_err(|error| format!("轮转日志失败: {error}"))
}
#[tauri::command]
pub fn app_log(level: String, message: String) -> Result<(), String> {
let dir = get_state_dir();
let path = dir.join(LOG_FILE_NAME);
ensure_parent_dir(&path)?;
cleanup_old_logs(&dir)?;
rotate_log_if_needed(&path)?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("生成日志时间失败: {error}"))?
.as_secs();
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| format!("打开日志文件失败: {error}"))?;
writeln!(file, "[{timestamp}] [{}] {message}", level.to_uppercase())
.map_err(|error| format!("写入日志失败: {error}"))
}

@ -0,0 +1,5 @@
pub mod config;
pub mod events;
pub mod logger;
pub mod session;
pub mod window;

@ -0,0 +1,38 @@
use tauri::State;
use crate::state::{AppState, SessionState};
#[tauri::command]
pub fn session_get(state: State<'_, AppState>) -> Result<SessionState, String> {
let session = state
.session
.lock()
.map_err(|error| format!("读取会话锁失败: {error}"))?;
Ok(session.clone())
}
#[tauri::command]
pub fn session_set(
payload: SessionState,
state: State<'_, AppState>,
) -> Result<SessionState, String> {
let mut session = state
.session
.lock()
.map_err(|error| format!("写入会话锁失败: {error}"))?;
*session = payload.clone();
Ok(payload)
}
#[tauri::command]
pub fn session_clear(state: State<'_, AppState>) -> Result<SessionState, String> {
let mut session = state
.session
.lock()
.map_err(|error| format!("清空会话锁失败: {error}"))?;
*session = SessionState::default();
Ok(session.clone())
}

@ -0,0 +1,140 @@
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
pub fn ensure_main_window(app: &AppHandle) -> Result<(), String> {
if app.get_webview_window("main").is_some() {
return Ok(());
}
WebviewWindowBuilder::new(app, "main", WebviewUrl::App("/#/main".into()))
.title("Call Client")
.inner_size(500.0, 100.0)
.resizable(false)
.decorations(false)
.always_on_top(true)
.visible(false)
.build()
.map_err(|error| format!("创建主窗口失败: {error}"))?;
Ok(())
}
pub fn ensure_login_window(app: &AppHandle) -> Result<(), String> {
if app.get_webview_window("login").is_some() {
return Ok(());
}
WebviewWindowBuilder::new(app, "login", WebviewUrl::App("/#/login".into()))
.title("登录")
.inner_size(480.0, 600.0)
.resizable(false)
.decorations(false)
.always_on_top(true)
.build()
.map_err(|error| format!("创建登录窗口失败: {error}"))?;
Ok(())
}
#[tauri::command]
pub fn open_ticket_window(app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("ticketList") {
// 优先复用已有窗口,避免频繁 close/recreate 引起的白屏状态。
let _ = window.eval(
"if (window.location.hash !== '#/ticketList') { window.location.hash = '/ticketList'; }",
);
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
return Ok(());
}
let builder = WebviewWindowBuilder::new(
&app,
"ticketList",
WebviewUrl::App("/#/ticketList".into()),
)
.title("票号列表")
.inner_size(1024.0, 720.0)
.resizable(false)
.visible(true)
.decorations(false)
.always_on_top(true);
let window = builder
.build()
.map_err(|error| format!("创建票号列表窗口失败: {error}"))?;
let _ = window
.eval("if (window.location.hash !== '#/ticketList') { window.location.hash = '/ticketList'; }");
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
Ok(())
}
#[tauri::command]
pub fn close_ticket_window(app: AppHandle) -> Result<(), String> {
let Some(window) = app.get_webview_window("ticketList") else {
return Ok(());
};
// 票号窗口关闭按钮采用 hide避免销毁后再次创建出现白屏。
let _ = window.hide();
Ok(())
}
#[tauri::command]
pub fn focus_window(app: AppHandle, label: String) -> Result<(), String> {
let Some(window) = app.get_webview_window(label.as_str()) else {
return Err(format!("窗口不存在: {label}"));
};
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
Ok(())
}
#[tauri::command]
pub fn open_main_window(app: AppHandle) -> Result<(), String> {
ensure_main_window(&app)?;
let Some(main_window) = app.get_webview_window("main") else {
return Err("主窗口不存在".to_string());
};
let _ = main_window.show();
let _ = main_window.unminimize();
let _ = main_window.set_focus();
if let Some(login_window) = app.get_webview_window("login") {
let _ = login_window.close();
}
Ok(())
}
#[tauri::command]
pub fn open_login_window(app: AppHandle) -> Result<(), String> {
ensure_login_window(&app)?;
let Some(login_window) = app.get_webview_window("login") else {
return Err("登录窗口不存在".to_string());
};
let _ = login_window.show();
let _ = login_window.unminimize();
let _ = login_window.set_focus();
if let Some(main_window) = app.get_webview_window("main") {
let _ = main_window.hide();
}
Ok(())
}
#[tauri::command]
pub fn quit_app(app: AppHandle) {
app.exit(0);
}

@ -0,0 +1,45 @@
mod commands;
mod state;
use commands::{
config::{config_get_all, config_merge},
events::{emit_to_window, list_windows},
logger::app_log,
session::{session_clear, session_get, session_set},
window::{
close_ticket_window, ensure_main_window, focus_window, open_login_window, open_main_window,
open_ticket_window, quit_app,
},
};
use state::AppState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::default().build())
.manage(AppState::default())
.setup(|app| {
ensure_main_window(app.handle())?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
session_get,
session_set,
session_clear,
config_get_all,
config_merge,
app_log,
emit_to_window,
list_windows,
open_ticket_window,
close_ticket_window,
focus_window,
open_main_window,
open_login_window,
quit_app
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
call_client_lib::run()
}

@ -0,0 +1,23 @@
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionState {
pub emp_uid: Option<i64>,
pub win_uid: Option<i64>,
pub queue_token: Option<String>,
}
pub struct AppState {
pub session: Mutex<SessionState>,
}
impl Default for AppState {
fn default() -> Self {
Self {
session: Mutex::new(SessionState::default()),
}
}
}

@ -0,0 +1,61 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "call-client",
"version": "0.1.0",
"identifier": "com.ziyun.callclient",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "login",
"title": "登录",
"url": "/#/login",
"width": 480,
"height": 600,
"resizable": false,
"maximizable": false,
"decorations": false,
"alwaysOnTop": true
},
{
"label": "ticketList",
"title": "票号列表",
"url": "/#/ticketList",
"width": 1024,
"height": 720,
"visible": false,
"resizable": false,
"maximizable": false,
"decorations": false,
"alwaysOnTop": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"linux": {
"deb": {
"depends": []
},
"appimage": {
"bundleMediaFramework": false
}
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

@ -0,0 +1,37 @@
import type { CallRequest, CallResponse, PauseRequest, ReCallRequest } from "../types/action";
import type {
IsRankData,
IsRankRequest,
QueueCountData,
QueueCountRequest,
} from "../types/rank";
import type { TicketPoolRequest, TicketPoolResponse } from "../types/ticket";
import type { UserRequest, UserResponse } from "../types/user";
import type { WindowResponse } from "../types/window";
import { http } from "../utils/service";
export const api = {
user: {
login: (data: UserRequest) => http.post<UserResponse>("/auth/login", data),
},
window: {
list: () => http.get<WindowResponse>("/windows/list"),
},
action: {
call: (data: CallRequest) => http.post<CallResponse>("/call-terminal/call", data),
init: (data: CallRequest) => http.post<CallResponse>("/call-terminal/init", data),
recall: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/recall", data),
abandon: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/abandon", data),
pause: (data: PauseRequest) => http.post<CallResponse>("/call-terminal/pause", data),
resume: (data: CallRequest) => http.post<CallResponse>("/call-terminal/resume", data),
start: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/start", data),
complete: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/complete", data),
evaluate: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/evaluate", data),
pool: (params: TicketPoolRequest) => http.get<TicketPoolResponse>("/call-terminal/pool", params),
isRank: (params: IsRankRequest) => http.get<IsRankData>("/call-terminal/is-rank", params),
getQueueCount: (params: QueueCountRequest) =>
http.get<QueueCountData>("/call-terminal/queue-count", params),
},
};

@ -0,0 +1,55 @@
:root {
--ev-c-white: #ffffff;
--ev-c-white-soft: #f8f8f8;
--ev-c-white-mute: #f2f2f2;
--ev-c-black: #1b1b1f;
--ev-c-black-soft: #222222;
--ev-c-black-mute: #282828;
--ev-c-gray-1: #515c67;
--ev-c-gray-2: #414853;
--ev-c-gray-3: #32363f;
--ev-c-text-1: rgba(255, 255, 245, 0.86);
--ev-c-text-2: rgba(235, 235, 245, 0.6);
--ev-c-text-3: rgba(235, 235, 245, 0.38);
--ev-button-alt-border: transparent;
--ev-button-alt-text: var(--ev-c-text-1);
--ev-button-alt-bg: var(--ev-c-gray-3);
--ev-button-alt-hover-border: transparent;
--ev-button-alt-hover-text: var(--ev-c-text-1);
--ev-button-alt-hover-bg: var(--ev-c-gray-2);
--color-background: var(--ev-c-black);
--color-background-soft: var(--ev-c-black-soft);
--color-background-mute: var(--ev-c-black-mute);
--color-text: var(--ev-c-text-1);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

@ -0,0 +1,27 @@
@import "./base.css";
html,
body,
#app {
width: 100%;
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-image: url("./wavy-lines.svg");
background-size: cover;
user-select: none;
padding: 0;
margin: 0;
}
#app {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@ -0,0 +1,6 @@
<svg width="1600" height="1000" viewBox="0 0 1600 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1600" height="1000" fill="#0F172A"/>
<path d="M0 145C140 205 300 216 442 176C600 132 742 36 901 24C1086 10 1261 115 1444 145C1498 154 1549 157 1600 155V1000H0V145Z" fill="#1E3A8A" fill-opacity="0.35"/>
<path d="M0 295C138 334 298 320 432 269C597 205 754 76 929 84C1102 92 1240 240 1406 281C1471 297 1535 305 1600 305V1000H0V295Z" fill="#2563EB" fill-opacity="0.28"/>
<path d="M0 455C132 497 267 492 400 448C587 386 767 250 960 272C1137 292 1274 455 1454 487C1503 495 1552 497 1600 494V1000H0V455Z" fill="#38BDF8" fill-opacity="0.18"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

@ -0,0 +1,24 @@
import { invoke } from "@tauri-apps/api/core";
import type { AppConfig } from "./types";
/**
*
*/
export async function getAllConfig(): Promise<AppConfig> {
try {
return await invoke<AppConfig>("config_get_all");
} catch (error) {
throw new Error(`读取配置失败: ${String(error)}`);
}
}
/**
*
*/
export async function mergeConfig(partial: AppConfig): Promise<AppConfig> {
try {
return await invoke<AppConfig>("config_merge", { partial });
} catch (error) {
throw new Error(`写入配置失败: ${String(error)}`);
}
}

@ -0,0 +1,17 @@
import { confirm } from "@tauri-apps/plugin-dialog";
import type { NativeConfirmOptions } from "./types";
/**
*
*/
export async function confirmNative(options: NativeConfirmOptions): Promise<boolean> {
try {
return await confirm(options.message, {
title: options.title,
okLabel: options.okLabel,
cancelLabel: options.cancelLabel,
});
} catch (error) {
throw new Error(`打开确认框失败: ${String(error)}`);
}
}

@ -0,0 +1,71 @@
import { emit, listen, type Event } from "@tauri-apps/api/event";
import type { TicketActionPayload } from "./types";
const MAIN_TICKET_EVENT = "main:ticket-action";
const TICKET_MAIN_EVENT = "ticket:main-action";
/**
*
*/
export async function emitTicketAction(payload: TicketActionPayload): Promise<void> {
try {
await emit(MAIN_TICKET_EVENT, payload);
} catch (error) {
throw new Error(`发送票号事件失败: ${String(error)}`);
}
}
/**
*
*/
export async function listenTicketAction(
handler: (event: Event<TicketActionPayload>) => void,
): Promise<() => void> {
try {
return await listen<TicketActionPayload>(MAIN_TICKET_EVENT, handler);
} catch (error) {
throw new Error(`订阅票号事件失败: ${String(error)}`);
}
}
/**
*
*/
export async function emitCallAction(payload: Omit<TicketActionPayload, "action">): Promise<void> {
try {
await emit(TICKET_MAIN_EVENT, { action: "call", ...payload });
} catch (error) {
throw new Error(`发送主窗口事件失败: ${String(error)}`);
}
}
/**
*
*/
export async function emitEvaluateAction(
payload: Omit<TicketActionPayload, "action">,
): Promise<void> {
try {
await emit(TICKET_MAIN_EVENT, { action: "evaluate", ...payload });
} catch (error) {
throw new Error(`发送主窗口事件失败: ${String(error)}`);
}
}
/**
*
*/
export async function listenMainAction(
handler: (action: "call" | "evaluate", payload?: Omit<TicketActionPayload, "action">) => void,
): Promise<() => void> {
try {
return await listen<TicketActionPayload>(TICKET_MAIN_EVENT, (event) => {
handler(event.payload.action, {
ticketUid: event.payload.ticketUid,
tktNum: event.payload.tktNum,
});
});
} catch (error) {
throw new Error(`订阅主窗口事件失败: ${String(error)}`);
}
}

@ -0,0 +1,13 @@
import { invoke } from "@tauri-apps/api/core";
import type { LogLevel } from "./types";
/**
* Rust
*/
export async function log(level: LogLevel, message: string): Promise<void> {
try {
await invoke("app_log", { level, message });
} catch (error) {
throw new Error(`写入日志失败: ${String(error)}`);
}
}

@ -0,0 +1,76 @@
import { load, type Store } from "@tauri-apps/plugin-store";
import type { SessionState } from "./types";
const STORE_PATH = "global-session.json";
const SESSION_KEY = "runtime_session";
const DEFAULT_SESSION: SessionState = {
empUid: null,
winUid: null,
queueToken: null,
};
let storePromise: Promise<Store> | null = null;
async function getStore(): Promise<Store> {
if (storePromise === null) {
storePromise = load(STORE_PATH, { defaults: {}, autoSave: false });
}
return storePromise;
}
function normalizeSession(raw: unknown): SessionState {
const source = (raw ?? {}) as Partial<SessionState>;
const empUid =
typeof source.empUid === "number" && Number.isFinite(source.empUid) ? source.empUid : null;
const winUid =
typeof source.winUid === "number" && Number.isFinite(source.winUid) ? source.winUid : null;
const queueToken = typeof source.queueToken === "string" ? source.queueToken : null;
return { empUid, winUid, queueToken };
}
/**
* Store
*/
export async function getSession(): Promise<SessionState> {
try {
const store = await getStore();
const value = await store.get<SessionState>(SESSION_KEY);
if (value === undefined) {
return { ...DEFAULT_SESSION };
}
return normalizeSession(value);
} catch (error) {
throw new Error(`读取会话失败: ${String(error)}`);
}
}
/**
* Store
*/
export async function setSession(payload: SessionState): Promise<SessionState> {
try {
const normalized = normalizeSession(payload);
const store = await getStore();
await store.set(SESSION_KEY, normalized);
await store.save();
return normalized;
} catch (error) {
throw new Error(`写入会话失败: ${String(error)}`);
}
}
/**
* Store
*/
export async function clearSession(): Promise<SessionState> {
try {
const store = await getStore();
await store.set(SESSION_KEY, DEFAULT_SESSION);
await store.save();
return { ...DEFAULT_SESSION };
} catch (error) {
throw new Error(`清空会话失败: ${String(error)}`);
}
}

@ -0,0 +1,26 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export interface SessionState {
empUid: number | null;
winUid: number | null;
queueToken: string | null;
}
export interface NativeConfirmOptions {
title: string;
message: string;
okLabel?: string;
cancelLabel?: string;
}
export interface TicketActionPayload {
action: "call" | "evaluate";
ticketUid?: number;
tktNum?: string;
}
export type AppConfig = Record<string, JsonValue>;
export type LogLevel = "debug" | "info" | "warn" | "error";

@ -0,0 +1,101 @@
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
/**
*
*/
export async function minimizeWindow(): Promise<void> {
try {
await getCurrentWindow().minimize();
} catch (error) {
throw new Error(`最小化窗口失败: ${String(error)}`);
}
}
/**
*
*/
export async function closeWindow(): Promise<void> {
try {
await getCurrentWindow().close();
} catch (error) {
throw new Error(`关闭窗口失败: ${String(error)}`);
}
}
/**
*
*/
export async function startWindowDragging(): Promise<void> {
try {
await getCurrentWindow().startDragging();
} catch (error) {
throw new Error(`拖拽窗口失败: ${String(error)}`);
}
}
/**
*
*/
export async function openTicketListWindow(): Promise<void> {
try {
await invoke("open_ticket_window");
} catch (error) {
throw new Error(`打开票号列表窗口失败: ${String(error)}`);
}
}
/**
*
*/
export async function closeTicketListWindow(): Promise<void> {
try {
await invoke("close_ticket_window");
} catch (error) {
throw new Error(`关闭票号列表窗口失败: ${String(error)}`);
}
}
/**
*
*/
export async function focusNamedWindow(label: string): Promise<void> {
try {
await invoke("focus_window", { label });
} catch (error) {
throw new Error(`聚焦窗口失败: ${String(error)}`);
}
}
/**
*
*/
export async function openMainWindow(): Promise<void> {
try {
await invoke("open_main_window");
} catch (error) {
throw new Error(`打开主窗口失败: ${String(error)}`);
}
}
/**
* 退
*/
export async function openLoginWindow(): Promise<void> {
try {
await invoke("open_login_window");
} catch (error) {
throw new Error(`打开登录窗口失败: ${String(error)}`);
}
}
/**
* 退
*/
export async function quitApplication(): Promise<void> {
try {
await invoke("quit_app");
} catch (error) {
throw new Error(`退出应用失败: ${String(error)}`);
}
}

@ -0,0 +1,44 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./assets/main.css";
import { getAllConfig } from "./host/config";
import { applyServerIpToHttp } from "./utils/service";
import { router } from "./router";
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`timeout after ${timeoutMs}ms`));
}, timeoutMs);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
}
/**
*
*/
async function bootstrap(): Promise<void> {
const app = createApp(App);
app.use(router).mount("#app");
try {
const config = await withTimeout(getAllConfig(), 1500);
const serverIp = typeof config.server_ip === "string" ? config.server_ip.trim() : "";
if (serverIp) {
applyServerIpToHttp(serverIp);
}
} catch {
// 配置缺失时保持默认空地址,由路由守卫引导到设置页。
}
}
void bootstrap();

@ -0,0 +1,62 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
import { getAllConfig } from "../host/config";
import type { AppConfig } from "../host/types";
import TicketListView from "../views/TicketListView.vue";
const LoginView = () => import("../views/LoginView.vue");
const MainView = () => import("../views/MainView.vue");
const ServerSetupView = () => import("../views/ServerSetupView.vue");
/**
*
*/
function getServerIpFromConfig(config: AppConfig): string {
const value = config.server_ip;
return typeof value === "string" ? value.trim() : "";
}
const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/login" },
{ path: "/setup", name: "setup", component: ServerSetupView },
{ path: "/login", name: "login", component: LoginView },
{ path: "/main", name: "main", component: MainView },
{ path: "/ticketList", name: "ticketList", component: TicketListView },
// 避免在窗口初始 URL/hash 不匹配时出现空白页面
{ path: "/:pathMatch(.*)*", redirect: "/ticketList" },
];
export const router = createRouter({
history: createWebHashHistory(),
routes,
});
router.beforeEach(async (to, _from, next) => {
// 票号列表窗口由主窗口显式打开,优先保证路由可渲染,避免守卫异步阻塞导致白屏。
if (to.path === "/ticketList") {
next();
return;
}
let ip = "";
try {
const config = await getAllConfig();
ip = getServerIpFromConfig(config);
} catch (error) {
// 如果当前窗口没有权限/IPC 失败,避免导航被中断导致页面空白
console.error("[router] getAllConfig failed, skip guard:", error);
next();
return;
}
if (!ip && to.path !== "/setup") {
next({ path: "/setup", replace: true });
return;
}
if (ip && to.path === "/setup") {
next({ path: "/login", replace: true });
return;
}
next();
});

@ -0,0 +1,38 @@
export type CallStatus = "idle" | "calling" | "paused" | "working" | "evaluating" | "transferring";
export interface ActionButton {
icon: unknown;
label: string;
action: string;
enabled: boolean;
}
export interface CallRequest {
windowUid: number;
empUid: number;
ticketUid?: number | null;
}
export interface CallResponse {
action: string;
success: boolean;
message: string;
windowUid: number;
windowName: string;
ticketNo: string;
ticketStatus: number;
ticketStatusText: string;
ticketUid: number;
}
export interface ReCallRequest {
windowUid: number;
empUid: number;
ticketUid: number;
}
export interface PauseRequest {
windowUid: number;
empUid: number;
pauseReason: string;
}

@ -0,0 +1,5 @@
declare module "element-plus/dist/locale/zh-cn.mjs" {
import type { Language } from "element-plus/es/locale";
const locale: Language;
export default locale;
}

@ -0,0 +1,5 @@
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T;
}

@ -0,0 +1,19 @@
export interface IsRankData {
hasRank: boolean;
ticketUid?: number;
isEvaluated?: boolean;
}
export interface IsRankRequest {
ticketUid: number;
}
export interface QueueCountData {
queueCount: number;
windowUid?: number;
count?: number;
}
export interface QueueCountRequest {
windowUid: number;
}

@ -0,0 +1,30 @@
export interface TicketPoolRequest {
winUid: number;
keyword: string;
page: number;
size: number;
status?: number;
}
export interface TicketPoolItem {
id?: number;
ticketUid?: number;
tktNum?: string;
ticketNo?: string;
startTime?: string;
endTime?: string;
status?: string | number;
ticketStatusText?: string;
[key: string]: unknown;
}
export interface TicketPoolResponse {
list?: TicketPoolItem[];
records?: TicketPoolItem[];
items?: TicketPoolItem[];
total?: number;
count?: number;
page?: number;
size?: number;
[key: string]: unknown;
}

@ -0,0 +1,25 @@
export interface UserRequest {
loginMode: string;
clientType: string;
username: string;
password: string;
hallRegNum: string;
}
export interface UserInfo {
empUid: number;
name: string;
username: string;
avatar: string;
tenantId: string;
hallCode: string;
hallName: string;
}
export interface UserResponse {
queueToken: string;
refreshToken: string;
tokenType: string;
expiresIn: string;
operatorProfile: UserInfo;
}

@ -0,0 +1,9 @@
export interface ServiceWindow {
windowUid: number;
windowCode: string;
windowName: string;
}
export interface WindowResponse {
windows: ServiceWindow[];
}

@ -0,0 +1,171 @@
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios";
import { ElMessage } from "element-plus";
import { getSession } from "../host/session";
import type { SessionState } from "../host/types";
export const API_QUEUE_CALLER_PATH = "/api/queue/caller";
const DEFAULT_API_PORT = 8845;
/**
* baseURL
*/
export function buildBaseUrlFromServerIp(serverIp: string): string {
const raw = serverIp.trim();
if (!raw) {
return "";
}
if (raw.startsWith("http://") || raw.startsWith("https://")) {
return `${raw.replace(/\/$/, "")}${API_QUEUE_CALLER_PATH}`;
}
const hostPort = raw.replace(/^\/+/, "");
const hasPort = /:\d+$/.test(hostPort) || /^\[.+\]:\d+$/.test(hostPort);
if (hasPort) {
return `http://${hostPort}${API_QUEUE_CALLER_PATH}`;
}
return `http://${hostPort}:${DEFAULT_API_PORT}${API_QUEUE_CALLER_PATH}`;
}
const instance = axios.create({
baseURL: "",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
/**
* axios
*/
export function applyServerIpToHttp(serverIp: string): void {
if (import.meta.env.DEV) {
instance.defaults.baseURL = API_QUEUE_CALLER_PATH;
return;
}
instance.defaults.baseURL = buildBaseUrlFromServerIp(serverIp);
}
instance.interceptors.request.use(
async (config) => {
if (!instance.defaults.baseURL) {
throw new Error("未配置服务器地址,请先完成服务地址设置");
}
let token: string | null = null;
try {
const sessionState: SessionState = await getSession();
token = sessionState.queueToken;
} catch {
token = null;
}
if (token && !config.url?.includes("/login")) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
async (error) => {
throw error;
},
);
instance.interceptors.response.use(
(response: AxiosResponse) => {
const { data } = response;
if (data.code !== 200) {
ElMessage.error(data.message || "请求失败");
if (data.code === 401) {
ElMessage.error("请求 token 过期,请重新登录");
}
throw new Error(data.message || "请求失败");
}
return data.data;
},
async (error) => {
if (error.response) {
switch (error.response.status) {
case 400:
ElMessage.error("请求错误");
break;
case 401:
ElMessage.error("未授权,请重新登录");
break;
case 403:
ElMessage.error("拒绝访问");
break;
case 404:
ElMessage.error("请求地址不存在");
break;
case 500:
ElMessage.error("服务器内部错误");
break;
case 502:
ElMessage.error("网关错误");
break;
case 503:
ElMessage.error("服务不可用");
break;
default:
ElMessage.error("请求失败");
}
} else if (error.request) {
ElMessage.error("网络连接异常");
} else {
ElMessage.error(error.message);
}
throw error;
},
);
export const http = {
/**
* axios
*/
request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
return instance.request(config);
},
/**
* GET
*/
get<T = unknown>(url: string, params?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.get(url, { params, ...config });
},
/**
* POST
*/
post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.post(url, data, config);
},
/**
* PUT
*/
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.put(url, data, config);
},
/**
* DELETE
*/
delete<T = unknown>(url: string, params?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.delete(url, { params, ...config });
},
/**
* PATCH
*/
patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.patch(url, data, config);
},
};

@ -0,0 +1,576 @@
<script setup lang="ts">
import { Close, Lock, Minus, User } from "@element-plus/icons-vue";
import { ElForm, ElMessage } from "element-plus";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { api } from "../api";
import { getAllConfig, mergeConfig } from "../host/config";
import { log } from "../host/logger";
import { setSession } from "../host/session";
import type { SessionState } from "../host/types";
import {
closeWindow,
minimizeWindow,
openMainWindow,
} from "../host/window";
const username = ref("admin");
const password = ref("admin");
const loginType = ref("NSFW");
const isLoading = ref(false);
const formRef = ref<InstanceType<typeof ElForm>>();
const isLoginSuccessed = ref(false);
const selectedWin = ref("");
const cachedWinKey = ref("");
const options = ref<Array<{ label: string; value: string }>>([]);
let sessionState: SessionState = {
empUid: null,
winUid: null,
queueToken: null,
};
const rules = {
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
{ min: 5, message: "密码长度不能少于5位", trigger: "blur" },
],
};
const isFormValid = computed(
() => username.value.trim() !== "" && password.value !== "",
);
const isWindowSelected = computed(() => selectedWin.value !== "");
/**
* 在窄窗口中使用居中消息避免提示框被裁剪
*/
function showMessage(
type: "success" | "warning" | "error",
message: string,
): void {
ElMessage({
type,
message,
offset: 52,
grouping: true,
customClass: "narrow-window-message",
});
}
/**
* 处理账号登录
*/
async function handleLogin(): Promise<void> {
if (!isFormValid.value) {
showMessage("warning", "请输入用户名和密码");
return;
}
await formRef.value?.validate(async (valid) => {
if (!valid) {
return;
}
isLoading.value = true;
try {
const loginRes = await api.user.login({
loginMode: loginType.value,
clientType: "CALLER",
username: username.value,
password: password.value,
hallRegNum: "",
});
if (!loginRes?.queueToken) {
await log(
"warn",
`登录失败: 接口未返回有效 queueToken, user=${username.value}`,
);
return;
}
sessionState = {
empUid: loginRes.operatorProfile.empUid,
winUid: 0,
queueToken: loginRes.queueToken,
};
await setSession(sessionState);
const winList = await api.window.list();
options.value = winList.windows.map((win) => ({
label: win.windowName,
value: win.windowUid.toString(),
}));
if (
cachedWinKey.value &&
options.value.some((item) => item.value === cachedWinKey.value)
) {
selectedWin.value = cachedWinKey.value;
}
const initWindowUid =
selectedWin.value.trim() !== ""
? Number.parseInt(selectedWin.value, 10)
: Number(sessionState.winUid ?? 0);
const initRes = await api.action.init({
empUid: Number(sessionState.empUid ?? -1),
windowUid: Number.isFinite(initWindowUid) ? initWindowUid : 0,
});
const initSuccess =
((initRes as { data?: { success?: boolean } }).data?.success ??
(initRes as { success?: boolean }).success) === true;
if (!initSuccess) {
await log("warn", "初始化接口调用完成,但返回 success=false");
} else {
sessionState.empUid = Number(sessionState.empUid ?? loginRes.operatorProfile.empUid);
sessionState.winUid = Number.isFinite(initWindowUid) ? initWindowUid : Number(sessionState.winUid ?? 0);
await setSession(sessionState);
}
isLoginSuccessed.value = true;
await log(
"info",
`登录成功: user=${username.value}, empUid=${sessionState.empUid}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await log("error", `登录失败: user=${username.value}, ${message}`);
} finally {
isLoading.value = false;
}
});
}
/**
* 处理窗口登录确认
*/
async function handleWindowLogin(): Promise<void> {
try {
const winUid = Number.parseInt(selectedWin.value, 10);
const selected = options.value.find(
(item) => item.value === selectedWin.value,
);
await mergeConfig({
last_username: username.value.trim(),
selected_win_key: selectedWin.value,
selected_win_value: selected?.label ?? "",
selected_win_uid: winUid,
});
sessionState.winUid = winUid;
await setSession(sessionState);
await openMainWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
showMessage("error", message || "打开主窗口失败");
await log("error", `打开主窗口失败: ${message}`);
}
}
async function handleMinimizeClick(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
try {
await minimizeWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
showMessage("error", message || "最小化窗口失败");
await log("error", `最小化窗口失败: ${message}`);
}
}
async function handleCloseClick(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
try {
await closeWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
showMessage("error", message || "关闭窗口失败");
await log("error", `关闭窗口失败: ${message}`);
}
}
/**
* 处理回车键触发登录
*/
function handleKeyPress(event: KeyboardEvent): void {
if (event.key === "Enter" && isFormValid.value && !isLoginSuccessed.value) {
void handleLogin();
}
}
/**
* 聚焦时选中输入内容
*/
function handleInputFocus(event: Event): void {
const target = event.target as HTMLInputElement;
target.select();
}
onMounted(async () => {
const config = await getAllConfig();
if (typeof config.last_username === "string" && config.last_username.trim()) {
username.value = config.last_username;
}
if (
typeof config.selected_win_key === "string" &&
config.selected_win_key.trim()
) {
cachedWinKey.value = config.selected_win_key;
selectedWin.value = config.selected_win_key;
}
window.addEventListener("keydown", handleKeyPress);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeyPress);
});
</script>
<template>
<div class="login-container">
<div class="background-elements">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
</div>
<div class="login-header">
<div class="login-header-drag" data-tauri-drag-region @dblclick.prevent.stop></div>
<div class="login-header-actions">
<button
class="control-button"
type="button"
@mousedown.stop
@click="handleMinimizeClick"
>
<el-icon class="control-icon">
<component :is="Minus" />
</el-icon>
</button>
<button
class="control-button"
type="button"
@mousedown.stop
@dblclick.prevent.stop
@click="handleCloseClick"
>
<el-icon class="control-icon">
<component :is="Close" />
</el-icon>
</button>
</div>
</div>
<div class="login-main">
<div class="header-section" data-tauri-drag-region @dblclick.prevent.stop>
<div class="app-info" data-tauri-drag-region>
<h1 class="app-title" data-tauri-drag-region>紫云智慧大厅</h1>
<h2 class="app-subtitle" data-tauri-drag-region>呼叫客户端系统</h2>
</div>
</div>
<div class="form-section">
<div class="form-wrapper">
<div v-show="!isLoginSuccessed" class="user-form">
<div class="form-header">
<p class="form-subtitle">请选择登录方式并输入账号信息</p>
</div>
<div class="type-selector">
<el-radio-group v-model="loginType" size="large">
<el-radio-button value="NSFW">综合管理平台账号</el-radio-button>
<el-radio-button value="QUEUE">自建账号</el-radio-button>
</el-radio-group>
</div>
<el-form
ref="formRef"
:model="{ username, password }"
:rules="rules"
class="login-form"
label-position="top"
@submit.prevent
>
<el-form-item label="账号" prop="username">
<el-input
v-model="username"
:prefix-icon="User"
placeholder="请输入用户名/手机号"
size="large"
@focus="handleInputFocus"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="password"
:prefix-icon="Lock"
type="password"
placeholder="请输入登录密码"
size="large"
show-password
@focus="handleInputFocus"
/>
</el-form-item>
<div class="form-actions">
<el-button
type="primary"
size="large"
:loading="isLoading"
:disabled="!isFormValid"
class="login-button"
@click="handleLogin"
>
{{ isLoading ? "登录中..." : "立即登录" }}
</el-button>
</div>
</el-form>
</div>
<div v-show="isLoginSuccessed" class="window-form">
<div class="form-header">
<p class="form-subtitle">请选择登录窗口</p>
</div>
<el-form>
<el-form-item class="window-select-item">
<el-select
v-model="selectedWin"
placeholder="请选择登录窗口"
size="large"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="form-actions">
<el-button
type="primary"
size="large"
:loading="isLoading"
:disabled="!isWindowSelected"
class="login-button"
@click="handleWindowLogin"
>
{{ isLoading ? "登录中..." : "立即登录" }}
</el-button>
</div>
<div class="form-actions">
<el-button
type="primary"
size="large"
class="login-button"
@click="isLoginSuccessed = false"
>
返回
</el-button>
</div>
</el-form>
</div>
<div class="version-info">
<div class="version">版本号V0.1.0</div>
<div class="copyright">© 2023 紫云科技 版权所有</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
background: linear-gradient(135deg, #004d99 0%, #003b7a 100%);
overflow: hidden;
position: relative;
border-radius: 5px;
padding: 0 16px;
}
.background-elements {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&.circle-1 {
width: 300px;
height: 300px;
top: -100px;
left: -100px;
}
&.circle-2 {
width: 200px;
height: 200px;
right: -80px;
bottom: -80px;
}
}
}
.login-header {
width: 100%;
height: 32px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.login-header-drag {
flex: 1;
height: 100%;
}
.login-header-actions {
display: flex;
align-items: center;
}
.control-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: white;
border-radius: 4px;
-webkit-app-region: no-drag;
border: none;
background: transparent;
cursor: pointer;
}
.control-button,
.control-button * {
cursor: pointer;
}
.control-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.control-icon {
font-size: 20px;
}
.login-main {
width: 100%;
max-width: 420px;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 16px;
}
.header-section {
text-align: center;
margin-bottom: 10px;
position: relative;
z-index: 2;
}
.app-title {
font-size: 28px;
font-weight: 700;
color: white;
margin-bottom: 8px;
}
.app-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.form-wrapper {
background: rgba(255, 255, 255, 0.95);
border-radius: 5px;
padding: 28px 30px 22px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.form-header {
text-align: center;
margin-bottom: 25px;
}
.form-subtitle {
color: #666;
font-size: 13px;
}
.type-selector {
margin-bottom: 25px;
:deep(.el-radio-group) {
width: 100%;
}
:deep(.el-radio-button) {
flex: 1;
}
:deep(.el-radio-button__inner) {
width: 100%;
}
}
.form-actions {
.login-button {
width: 100%;
margin-top: 10px;
}
}
.window-select-item {
margin: 80px 0;
}
.version-info {
margin-top: 18px;
text-align: center;
color: #999;
font-size: 12px;
padding-top: 10px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.version {
margin-bottom: 5px;
}
:global(.narrow-window-message) {
min-width: 0 !important;
width: calc(100vw - 32px) !important;
left: 16px !important;
right: 16px !important;
}
</style>

@ -0,0 +1,850 @@
<script setup lang="ts">
import {
CircleCheck,
Delete,
Menu as MenuIcon,
Phone,
Star,
Switch,
VideoPause,
VideoPlay,
} from "@element-plus/icons-vue";
import { LogicalPosition } from "@tauri-apps/api/dpi";
import { Menu } from "@tauri-apps/api/menu";
import { ElMessage } from "element-plus";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { api } from "../api";
import { confirmNative } from "../host/dialog";
import { listenMainAction } from "../host/events";
import { log } from "../host/logger";
import { getSession } from "../host/session";
import type { SessionState } from "../host/types";
import { minimizeWindow, openTicketListWindow, quitApplication } from "../host/window";
import type { ActionButton } from "../types/action";
type ActionResultLike = {
success?: boolean;
message?: string;
ticketNo?: string;
ticketUid?: number;
data?: {
success?: boolean;
message?: string;
ticketNo?: string;
ticketUid?: number;
};
};
const sessionState = ref<SessionState>({
empUid: -1,
winUid: -1,
queueToken: "",
});
const textColor = ref("#99ccff");
const iconColor = ref("#dcdfe6");
const message = ref("欢迎使用紫云呼叫终端");
const callStatus = ref("idle");
const callBtnText = ref("呼叫");
const pauseBtnText = ref("暂停");
const callingTkt = ref(-1);
const evaluatingPrefixText = ref("评价中");
let evaluatingCountdownTimer: ReturnType<typeof setInterval> | null = null;
let isRankPollingTimer: ReturnType<typeof setInterval> | null = null;
let isRankPollingBusy = false;
let queueCountPollingTimer: ReturnType<typeof setInterval> | null = null;
let unlistenMainAction: (() => void) | null = null;
let moreNativeMenuPromise: Promise<Menu> | null = null;
let pauseNativeMenuPromise: Promise<Menu> | null = null;
const EVALUATING_COUNTDOWN_SEC = 15;
const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"];
function getMoreNativeMenu(): Promise<Menu> {
if (moreNativeMenuPromise === null) {
moreNativeMenuPromise = Menu.new({
items: [
{
id: "main",
text: "办税员窗口",
action: () => {
void handleMoreCommand("main");
},
},
{
id: "ticketList",
text: "票号列表",
action: () => {
void handleMoreCommand("ticketList");
},
},
{ item: "Separator" },
{
id: "logout",
text: "退出程序",
action: () => {
void handleMoreCommand("logout");
},
},
],
});
}
return moreNativeMenuPromise;
}
function getPauseNativeMenu(): Promise<Menu> {
if (pauseNativeMenuPromise === null) {
pauseNativeMenuPromise = Menu.new({
items: pauseReasonOptions.map((reason) => ({
id: reason,
text: reason,
action: () => {
void confirmPauseReason(reason);
},
})),
});
}
return pauseNativeMenuPromise;
}
/**
* 记录错误日志
*/
async function logErr(context: string, error: unknown): Promise<void> {
const message = error instanceof Error ? error.message : String(error);
await log("error", `${context}: ${message}`);
}
/**
* 提取后端动作返回对象
*/
function getActionData(res: unknown): NonNullable<ActionResultLike["data"]> | ActionResultLike {
const result = (res ?? {}) as ActionResultLike;
return result.data && typeof result.data === "object" ? result.data : result;
}
/**
* 判断动作是否成功
*/
function isActionSuccess(res: unknown): boolean {
return getActionData(res).success === true;
}
/**
* 提取动作消息
*/
function getActionMessage(res: unknown): string {
return String(getActionData(res).message ?? "");
}
/**
* 提取票号
*/
function getActionTicketNo(res: unknown): string {
return String(getActionData(res).ticketNo ?? "");
}
/**
* 提取票据 UID
*/
function getActionTicketUid(res: unknown): number {
const value = getActionData(res).ticketUid;
return typeof value === "number" ? value : -1;
}
function parseOptionalNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
/**
* 清理评价轮询
*/
function clearIsRankPolling(): void {
if (isRankPollingTimer !== null) {
clearInterval(isRankPollingTimer);
isRankPollingTimer = null;
}
}
/**
* 清理等待人数轮询
*/
function clearQueueCountPolling(): void {
if (queueCountPollingTimer !== null) {
clearInterval(queueCountPollingTimer);
queueCountPollingTimer = null;
}
}
/**
* 清理评价倒计时
*/
function clearEvaluatingCountdown(): void {
if (evaluatingCountdownTimer !== null) {
clearInterval(evaluatingCountdownTimer);
evaluatingCountdownTimer = null;
}
}
/**
* 更新日志显示
*/
function updateLog(nextMessage: string): void {
message.value = nextMessage;
}
/**
* 开启评价倒计时
*/
function startEvaluatingCountdown(prefixText: string): void {
clearEvaluatingCountdown();
evaluatingPrefixText.value = prefixText;
let left = EVALUATING_COUNTDOWN_SEC;
const applyMessage = (): void => {
message.value = `${prefixText}(剩余 ${left} 秒)`;
};
applyMessage();
evaluatingCountdownTimer = setInterval(() => {
left -= 1;
if (left <= 0) {
clearEvaluatingCountdown();
message.value = evaluatingPrefixText.value;
return;
}
applyMessage();
}, 1000);
}
/**
* 轮询当前票据是否已评价
*/
async function pollIsRankOnce(): Promise<void> {
if (isRankPollingBusy) {
return;
}
isRankPollingBusy = true;
try {
const ticketUid = callingTkt.value;
if (ticketUid <= 0) {
return;
}
const res = await api.action.isRank({ ticketUid });
const ranked = res.hasRank === true || res.isEvaluated === true;
if (ranked) {
clearIsRankPolling();
clearEvaluatingCountdown();
callStatus.value = "idle";
callBtnText.value = "呼叫";
callingTkt.value = -1;
message.value = "欢迎使用紫云呼叫终端";
await log("info", "isRank: 评价完成,进入待机");
return;
}
if (evaluatingCountdownTimer === null) {
message.value = `${evaluatingPrefixText.value}(等待评价完成...`;
}
} catch (error) {
await logErr("查询 isRank 失败", error);
} finally {
isRankPollingBusy = false;
}
}
/**
* 开启评价轮询
*/
function startIsRankPolling(): void {
clearIsRankPolling();
void pollIsRankOnce();
isRankPollingTimer = setInterval(() => {
void pollIsRankOnce();
}, 1000);
}
/**
* 轮询当前窗口等候人数
*/
async function pollQueueCountOnce(): Promise<void> {
try {
const winUidFromSession = Number(sessionState.value.winUid ?? -1);
const windowUid = winUidFromSession;
if (windowUid <= 0) {
return;
}
const res = await api.action.getQueueCount({ windowUid });
const count = typeof res.queueCount === "number" ? res.queueCount : Number(res.count ?? 0);
message.value = `欢迎使用紫云呼叫终端,当前窗口等候人数:${count}`;
} catch (error) {
await logErr("查询 getQueueCount 失败", error);
}
}
/**
* 开启等待人数轮询
*/
function startQueueCountPolling(): void {
clearQueueCountPolling();
void pollQueueCountOnce();
queueCountPollingTimer = setInterval(() => {
void pollQueueCountOnce();
}, 15000);
}
watch(
callStatus,
(status) => {
if (status === "evaluating") {
startIsRankPolling();
} else {
clearIsRankPolling();
}
if (status === "idle") {
startQueueCountPolling();
} else {
clearQueueCountPolling();
}
},
{ immediate: true },
);
const buttons = computed<ActionButton[]>(() => {
const startOrComplete =
callStatus.value === "working"
? {
icon: CircleCheck,
label: "完成",
action: "complete",
enabled: true,
}
: {
icon: VideoPlay,
label: "开始",
action: "start",
enabled: !["idle", "paused", "working", "evaluating", "transferring"].includes(callStatus.value),
};
return [
{
icon: Phone,
label: callBtnText.value,
action: "call",
enabled: !["paused", "working", "evaluating", "transferring"].includes(callStatus.value),
},
startOrComplete,
{
icon: Delete,
label: "弃号",
action: "abandon",
enabled: callStatus.value === "calling",
},
{
icon: Switch,
label: "转移",
action: "transfer",
enabled: !["idle", "calling", "paused", "evaluating", "transferring"].includes(callStatus.value),
},
{
icon: VideoPause,
label: pauseBtnText.value,
action: "pause",
enabled: !["calling", "working", "evaluating", "transferring"].includes(callStatus.value),
},
{
icon: Star,
label: "评价",
action: "evaluate",
enabled: !["idle", "calling", "paused", "working", "transferring"].includes(callStatus.value),
},
];
});
/**
* 打开票池页面
*/
async function openTicketList(): Promise<void> {
await openTicketListWindow();
}
/**
* 发起呼叫或重呼
*/
async function callAction(): Promise<void> {
try {
const windowUid = Number(sessionState.value.winUid ?? -1);
const empUid = Number(sessionState.value.empUid ?? -1);
const ticketUid = callingTkt.value > 0 ? callingTkt.value : null;
if (callStatus.value === "calling" && callingTkt.value > 0) {
const recallRes = await api.action.recall({ windowUid, empUid, ticketUid: callingTkt.value });
if (isActionSuccess(recallRes)) {
updateLog(`已重呼:${getActionTicketNo(recallRes)},请勿重复点击!`);
await log("info", `重呼成功: ticketNo=${getActionTicketNo(recallRes)}`);
} else {
updateLog(getActionMessage(recallRes));
}
return;
}
const res = await api.action.call({ windowUid, empUid, ticketUid });
if (isActionSuccess(res)) {
callStatus.value = "calling";
callBtnText.value = "重呼";
callingTkt.value = getActionTicketUid(res);
updateLog(`正在呼叫:${getActionTicketNo(res)}`);
await log("info", `呼叫成功: ticketNo=${getActionTicketNo(res)}, ticketUid=${getActionTicketUid(res)}`);
return;
}
updateLog(getActionMessage(res));
} catch (error) {
await logErr("呼叫失败", error);
}
}
/**
* 发起弃号
*/
async function abandonAction(): Promise<void> {
try {
const res = await api.action.abandon({
windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1),
ticketUid: callingTkt.value,
});
if (isActionSuccess(res)) {
callStatus.value = "idle";
callBtnText.value = "呼叫";
callingTkt.value = -1;
updateLog(`弃号成功: ${getActionTicketNo(res)}`);
return;
}
updateLog(`弃号未成功: ${getActionMessage(res) || "unknown"}`);
} catch (error) {
await logErr("弃号失败", error);
}
}
/**
* 开始办理
*/
async function startAction(): Promise<void> {
try {
const res = await api.action.start({
windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1),
ticketUid: callingTkt.value,
});
if (isActionSuccess(res)) {
callStatus.value = "working";
updateLog(`正在办理:${getActionTicketNo(res)}`);
}
} catch (error) {
await logErr("开始办理失败", error);
}
}
/**
* 完成办理并进入评价状态
*/
async function completeAction(): Promise<void> {
try {
const res = await api.action.complete({
windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1),
ticketUid: callingTkt.value,
});
if (!isActionSuccess(res)) {
return;
}
callStatus.value = "evaluating";
callBtnText.value = "呼叫";
startEvaluatingCountdown(`办理完成:${getActionTicketNo(res)},评价中`);
} catch (error) {
await logErr("完成办理失败", error);
}
}
/**
* 发起评价接口调用
*/
async function invokeEvaluateApi(): Promise<void> {
const res = await api.action.evaluate({
windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1),
ticketUid: callingTkt.value,
});
if (res.success) {
callStatus.value = "evaluating";
callBtnText.value = "呼叫";
startEvaluatingCountdown("评价中");
}
}
/**
* 发起评价或二次评价
*/
async function evaluateAction(): Promise<void> {
if (callStatus.value === "evaluating") {
const confirmed = await confirmNative({
title: "提示",
message: "当前业务正在评价中,是否重新发起评价?",
okLabel: "是",
cancelLabel: "否",
});
if (!confirmed) {
return;
}
}
try {
await invokeEvaluateApi();
} catch (error) {
await logErr("评价失败", error);
}
}
/**
* 暂停或恢复
*/
async function pauseAction(): Promise<void> {
try {
if (callStatus.value !== "paused") {
await openPauseContextMenu();
return;
}
const res = await api.action.resume({
windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1),
});
if (isActionSuccess(res)) {
callStatus.value = "idle";
pauseBtnText.value = "暂停";
updateLog("已恢复待机");
}
} catch (error) {
await logErr("暂停/恢复流程异常", error);
}
}
/**
* 确认暂停原因并发起暂停
*/
async function confirmPauseReason(reason: string): Promise<void> {
try {
const pauseReason = reason.trim();
if (!pauseReason) {
ElMessage.warning("请选择暂停原因");
return;
}
const res = await api.action.pause({
windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1),
pauseReason,
});
if (isActionSuccess(res)) {
callStatus.value = "paused";
pauseBtnText.value = "恢复";
updateLog(`暂停中,原因:${pauseReason}`);
return;
}
updateLog(`暂停未成功: ${getActionMessage(res) || "unknown"}`);
} catch (error) {
await logErr("暂停失败", error);
}
}
/**
* 处理动作按钮点击
*/
function handleButtonClick(button: ActionButton): void {
if (!button.enabled) {
return;
}
switch (button.action) {
case "call":
void callAction();
break;
case "abandon":
void abandonAction();
break;
case "pause":
void pauseAction();
break;
case "transfer":
updateLog("转移功能暂未对接");
break;
case "start":
void startAction();
break;
case "complete":
void completeAction();
break;
case "evaluate":
void evaluateAction();
break;
default:
break;
}
}
async function openMoreContextMenu(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
const position = new LogicalPosition(event.clientX, event.clientY + 8);
try {
const menu = await getMoreNativeMenu();
await menu.popup(position);
} catch (error) {
await logErr("更多菜单 popup 失败", error);
ElMessage.error("更多菜单弹出失败,请查看日志");
}
}
async function openPauseContextMenu(): Promise<void> {
try {
const menu = await getPauseNativeMenu();
const pauseButton = document.querySelector<HTMLButtonElement>('[data-action="pause"]');
if (!pauseButton) {
await menu.popup(new LogicalPosition(16, 56));
return;
}
const rect = pauseButton.getBoundingClientRect();
await menu.popup(new LogicalPosition(rect.left, rect.bottom + 8));
} catch (error) {
await logErr("暂停菜单 popup 失败", error);
ElMessage.error("暂停菜单弹出失败,请查看日志");
}
}
/**
* 处理顶部更多菜单动作
*/
async function handleMoreCommand(command: "main" | "ticketList" | "logout"): Promise<void> {
try {
if (command === "main") {
updateLog("当前已在办税员窗口");
return;
}
if (command === "ticketList") {
updateLog("正在打开票号列表...");
//
await minimizeWindow();
await openTicketList();
updateLog("票号列表已打开");
return;
}
const confirmed = await confirmNative({
title: "提示",
message: "确认退出程序吗?",
okLabel: "确认",
cancelLabel: "取消",
});
if (!confirmed) {
return;
}
await quitApplication();
} catch (error) {
await logErr(`更多菜单处理失败: ${command}`, error);
ElMessage.error("操作失败,请查看日志");
updateLog(`操作失败: ${command}`);
}
}
onMounted(async () => {
try {
const session = await getSession();
sessionState.value = {
empUid: parseOptionalNumber(session.empUid),
winUid: parseOptionalNumber(session.winUid),
queueToken:
typeof session.queueToken === "string" && session.queueToken.trim() !== ""
? session.queueToken
: null,
};
} catch (error) {
await logErr("读取 session 失败", error);
}
unlistenMainAction = await listenMainAction((action, payload) => {
if (typeof payload?.ticketUid === "number" && payload.ticketUid > 0) {
callingTkt.value = payload.ticketUid;
}
if (action === "call") {
void callAction();
}
if (action === "evaluate") {
void evaluateAction();
}
});
});
onUnmounted(() => {
clearEvaluatingCountdown();
clearIsRankPolling();
clearQueueCountPolling();
if (unlistenMainAction) {
unlistenMainAction();
}
});
</script>
<template>
<div class="main-bg" @dblclick.prevent.stop>
<div class="btn-div">
<button class="action-button action-button-menu" type="button" @click="openMoreContextMenu">
<el-icon class="button-icon">
<component :is="MenuIcon" />
</el-icon>
</button>
<div class="divider-vertical"></div>
<button
v-for="(btn, index) in buttons"
:key="index"
type="button"
class="action-button"
:data-action="btn.action"
:class="{ disabled: !btn.enabled }"
:style="{ color: !btn.enabled ? '#ccc' : textColor }"
@click="handleButtonClick(btn)"
>
<el-icon class="button-icon" :style="{ color: !btn.enabled ? '#ccc' : iconColor }">
<component :is="btn.icon" />
</el-icon>
<span class="button-label">{{ btn.label }}</span>
</button>
</div>
<div class="divider-horizontal"></div>
<div class="log-div" data-tauri-drag-region @dblclick.prevent.stop>
<span class="log-span" data-tauri-drag-region>{{ message }}</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.main-bg {
width: 100vw;
height: 100vh;
padding: 5px 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background:
linear-gradient(180deg, rgba(53, 64, 94, 0.96) 0%, rgba(25, 29, 40, 0.98) 100%);
}
.btn-div {
width: 100%;
height: 48%;
padding: 0 4px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
-webkit-app-region: no-drag;
}
.action-button {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 2px;
padding: 5px 4px;
border: none;
background: transparent;
cursor: pointer;
-webkit-app-region: no-drag;
}
.action-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.action-button.disabled {
pointer-events: none;
}
.button-icon {
margin: 0 2px;
font-size: 18px;
}
.button-label {
margin: 0 1px;
font-size: 16px;
}
.log-div {
width: 100%;
height: 48%;
padding: 0 6px;
display: flex;
align-items: center;
}
.log-span {
font-size: 18px;
color: #99ccff;
}
.divider-horizontal {
height: 0.5px;
width: 100%;
margin: 5px 0;
background-color: #c1c1c1;
}
.divider-vertical {
width: 0.5px;
height: 70%;
margin: 0 5px;
background-color: #c1c1c1;
}
.action-button-menu {
color: #99ccff;
}
.action-button-close {
margin-left: auto;
color: #dcdfe6;
}
</style>

@ -0,0 +1,294 @@
<script setup lang="ts">
import { Close, Minus } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { mergeConfig } from "../host/config";
import { log } from "../host/logger";
import { closeWindow, minimizeWindow } from "../host/window";
import { applyServerIpToHttp } from "../utils/service";
const router = useRouter();
const serverIp = ref("");
const saving = ref(false);
const isValid = computed(() => serverIp.value.trim().length > 0);
/**
* 在窄窗口中使用居中消息避免提示框被裁剪
*/
function showMessage(
type: "success" | "warning" | "error",
message: string,
): void {
ElMessage({
type,
message,
offset: 52,
grouping: true,
customClass: "narrow-window-message",
});
}
/**
* 校验服务地址输入是否合法
*/
function validateHostInput(raw: string): boolean {
const value = raw.trim();
if (!value) {
return false;
}
if (value.startsWith("http://") || value.startsWith("https://")) {
try {
const url = new URL(value);
return Boolean(url);
} catch {
return false;
}
}
return /^[\w.\-]+(?::\d+)?$/.test(value) || /^\d{1,3}(\.\d{1,3}){3}(?::\d+)?$/.test(value);
}
/**
* 保存服务地址并跳转登录页
*/
async function handleSave(): Promise<void> {
const value = serverIp.value.trim();
if (!value) {
showMessage("warning", "请输入服务器 IP 或地址");
return;
}
if (!validateHostInput(value)) {
showMessage("warning", "地址格式不正确例如192.168.1.10 或 192.168.1.10:8845");
return;
}
saving.value = true;
try {
await mergeConfig({ server_ip: value });
applyServerIpToHttp(value);
await log("info", `已保存服务器地址: ${value}`);
showMessage("success", "保存成功");
await router.replace("/login");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
showMessage("error", message || "保存失败");
await log("error", `保存服务器地址失败: ${message}`);
} finally {
saving.value = false;
}
}
function handleMinimizeClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
void minimizeWindow();
}
function handleCloseClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
void closeWindow();
}
</script>
<template>
<div class="login-container">
<div class="background-elements">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
</div>
<div class="login-header drag-region">
<button class="control-button" type="button" @click="handleMinimizeClick">
<el-icon class="control-icon">
<component :is="Minus" />
</el-icon>
</button>
<button class="control-button" type="button" @click="handleCloseClick">
<el-icon class="control-icon">
<component :is="Close" />
</el-icon>
</button>
</div>
<div class="login-main">
<div class="header-section drag-region">
<div class="app-info">
<h1 class="app-title">服务地址</h1>
<h2 class="app-subtitle">请先配置服务器 IP 或地址</h2>
</div>
</div>
<div class="form-section">
<div class="form-wrapper">
<div class="form-header">
<p class="form-subtitle">默认端口 8845支持 host:port 或完整 http(s) 地址</p>
</div>
<el-input
v-model="serverIp"
size="large"
clearable
placeholder="例如 192.168.1.10"
@keyup.enter="handleSave"
/>
<div class="form-actions">
<el-button
type="primary"
size="large"
class="login-button"
:loading="saving"
:disabled="!isValid"
@click="handleSave"
>
保存并进入登录
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
position: relative;
padding: 0 16px 20px;
}
.drag-region {
-webkit-app-region: drag;
}
.background-elements {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&.circle-1 {
width: 300px;
height: 300px;
top: -100px;
left: -100px;
}
&.circle-2 {
width: 200px;
height: 200px;
right: -80px;
bottom: -80px;
}
}
}
.login-header {
width: 100%;
height: 32px;
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
}
.control-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: white;
border-radius: 4px;
-webkit-app-region: no-drag;
border: none;
background: transparent;
}
.control-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.control-icon {
font-size: 20px;
}
.login-main {
width: 100%;
max-width: 420px;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 16px;
}
.header-section {
text-align: center;
margin-bottom: 30px;
}
.app-title {
font-size: 28px;
font-weight: 700;
color: white;
margin-bottom: 8px;
}
.app-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.form-wrapper {
background: rgba(255, 255, 255, 0.95);
border-radius: 5px;
padding: 28px 30px 26px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.form-header {
text-align: center;
margin-bottom: 25px;
}
.form-subtitle {
color: #666;
font-size: 13px;
}
.form-actions {
margin-top: 24px;
}
.login-button {
width: 100%;
}
:global(.narrow-window-message) {
min-width: 0 !important;
width: calc(100vw - 32px) !important;
left: 16px !important;
right: 16px !important;
}
</style>

@ -0,0 +1,547 @@
<template>
<div class="table-container">
<div class="ticket-header">
<div class="ticket-header-title" data-tauri-drag-region @dblclick.prevent.stop>票号列表</div>
<div class="ticket-header-actions">
<button class="control-button" type="button" @mousedown.stop @click="handleMinimizeClick">
<el-icon class="control-icon">
<component :is="Minus" />
</el-icon>
</button>
<button
class="control-button"
type="button"
@mousedown.stop
@dblclick.prevent.stop
@click="handleCloseClick"
>
<el-icon class="control-icon">
<component :is="Close" />
</el-icon>
</button>
</div>
</div>
<div class="search-wrapper">
<el-input
v-model="keyword"
placeholder="请输入关键字"
clearable
@keyup.enter="handleSearch"
/>
<el-select
v-model="statusFilter"
placeholder="请选择票号状态"
clearable
@change="handleStatusChange"
>
<el-option label="全部" :value="null" />
<el-option
v-for="opt in statusSelectOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button type="primary" @click="handleSearch"></el-button>
</div>
<el-table
v-loading="loading"
:data="paginatedData"
:height="tableHeight"
stripe
border
class="ticket-table"
:default-sort="{ prop: sortField, order: sortOrder }"
@sort-change="handleSortChange"
>
<el-table-column prop="ticketUid" label="UID" sortable="custom" />
<el-table-column prop="tktNum" label="票号" sortable="custom" show-overflow-tooltip />
<el-table-column prop="dateText" label="日期" sortable="custom" show-overflow-tooltip />
<el-table-column prop="startTimeOnly" label="开始时间" sortable="custom" show-overflow-tooltip />
<el-table-column prop="endTimeOnly" label="结束时间" sortable="custom" show-overflow-tooltip />
<el-table-column prop="bizName" label="业务值" sortable="custom" show-overflow-tooltip />
<el-table-column prop="status" label="票号状态" align="center">
<template #default="{ row }">
<el-tag :type="statusTagTypeMap[row.status] ?? 'info'">
{{ row.statusText }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template #default="{ row }">
<el-button v-if="row.canCall" type="primary" link @click="handleCall(row)"></el-button>
<el-button v-if="row.canReEvaluate" type="warning" link @click="handleReEvaluate(row)">
评价
</el-button>
<span v-if="!row.canCall && !row.canReEvaluate"> -- </span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-config-provider :locale="zhCn">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalItems"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-config-provider>
</div>
<div v-if="!loading" class="stats-info">
显示第 {{ startIndex }} 到第 {{ endIndex }} 条记录 {{ totalItems }}
</div>
</div>
</template>
<script setup lang="ts">
import { Close, Minus } from "@element-plus/icons-vue";
import { ElConfigProvider, ElMessage } from "element-plus";
import zhCn from "element-plus/dist/locale/zh-cn.mjs";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { api } from "../api";
import { emitCallAction, emitEvaluateAction } from "../host/events";
import { log } from "../host/logger";
import type { LogLevel } from "../host/types";
import { getSession } from "../host/session";
import { closeTicketListWindow, focusNamedWindow, minimizeWindow } from "../host/window";
console.info("[TicketListView] script setup executed");
interface Tkt {
ticketUid: number;
tktNum: string;
bizName: string;
startTime: string;
endTime: string;
dateText: string;
startTimeOnly: string;
endTimeOnly: string;
status: number;
statusText: string;
canCall: boolean;
canReEvaluate: boolean;
}
const loading = ref(false);
const allData = ref<Tkt[]>([]);
const currentPage = ref(1);
const pageSize = ref(20);
const totalCount = ref(0);
const keyword = ref("");
const statusFilter = ref<number | null>(null);
const sortField = ref("ticketUid");
const sortOrder = ref<"ascending" | "descending">("ascending");
const tableHeight = ref(500);
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let focusRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const statusTextMap: Record<number, string> = {
0: "正在等候",
1: "未换领",
2: "已换领",
3: "正在呼叫",
4: "正在办理",
5: "已完成",
6: "弃号",
7: "换领超时",
8: "换领作废",
9: "等待评价",
10: "评价中",
};
async function safeLog(level: LogLevel, messageText: string): Promise<void> {
try {
await log(level, messageText);
} catch (error) {
// /IPC
console.warn(`[TicketListView] safeLog failed: ${level}: ${messageText}`, error);
}
}
const statusTagTypeMap: Record<number, "success" | "warning" | "danger" | "info"> = {
0: "info",
1: "warning",
2: "info",
3: "warning",
4: "warning",
5: "success",
6: "danger",
7: "warning",
8: "danger",
9: "warning",
10: "success",
};
const statusSelectOptions = computed(() =>
Object.keys(statusTextMap).map((key) => ({
value: Number(key),
label: statusTextMap[Number(key)],
})),
);
const paginatedData = computed(() => {
const filtered = [...allData.value];
filtered.sort((a, b) => {
const field = sortField.value as keyof Tkt;
const aVal = a[field];
const bVal = b[field];
if (sortOrder.value === "ascending") {
return aVal > bVal ? 1 : -1;
}
return aVal < bVal ? 1 : -1;
});
return filtered;
});
const totalItems = computed(() => totalCount.value);
const startIndex = computed(() => (totalItems.value === 0 ? 0 : (currentPage.value - 1) * pageSize.value + 1));
const endIndex = computed(() => Math.min(currentPage.value * pageSize.value, totalItems.value));
/**
* 处理表格排序
*/
function handleSortChange(sort: {
prop: string;
order: "ascending" | "descending" | null;
}): void {
if (sort.prop && sort.order) {
sortField.value = sort.prop;
sortOrder.value = sort.order;
return;
}
sortField.value = "ticketUid";
sortOrder.value = "ascending";
}
/**
* 处理分页大小变化
*/
function handleSizeChange(size: number): void {
pageSize.value = size;
currentPage.value = 1;
void refreshData();
}
/**
* 处理页码切换
*/
function handleCurrentChange(page: number): void {
currentPage.value = page;
void refreshData();
}
/**
* 触发检索
*/
function handleSearch(): void {
currentPage.value = 1;
void refreshData();
}
/**
* 处理状态筛选变更
*/
function handleStatusChange(): void {
currentPage.value = 1;
void refreshData();
}
async function handleMinimizeClick(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
try {
await minimizeWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ElMessage.error(message || "最小化窗口失败");
await safeLog("error", `票池窗口最小化失败: ${message}`);
}
}
async function handleCloseClick(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
try {
await closeTicketListWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ElMessage.error(message || "关闭窗口失败");
await safeLog("error", `票池窗口关闭失败: ${message}`);
}
}
/**
* 归一化后端票池数据
*/
function normalizeRows(raw: unknown[]): Tkt[] {
return raw.map((item) => {
const row = item as Record<string, unknown>;
const startTimeRaw = String(row.startTime ?? "");
const endTimeRaw = String(row.endTime ?? "");
const status = Number(row.status ?? -1);
const startDate = startTimeRaw.replace("T", " ").trim().split(" ")[0] ?? "";
const endDate = endTimeRaw.replace("T", " ").trim().split(" ")[0] ?? "";
return {
ticketUid: Number(row.ticketUid ?? 0),
tktNum: String(row.tktId ?? row.tktNum ?? row.ticketNo ?? ""),
bizName: String(row.bizName ?? ""),
startTime: startTimeRaw,
endTime: endTimeRaw,
dateText: startDate && endDate ? (startDate === endDate ? startDate : `${startDate}/${endDate}`) : startDate || endDate || "",
startTimeOnly: startTimeRaw.replace("T", " ").trim().split(" ")[1] ?? "",
endTimeOnly: endTimeRaw.replace("T", " ").trim().split(" ")[1] ?? "",
status,
statusText: String(row.statusText ?? statusTextMap[status] ?? "未知状态"),
canCall: Boolean(row.canCall),
canReEvaluate: Boolean(row.canReEvaluate),
};
});
}
/**
* 从票池发起呼叫动作
*/
async function handleCall(row: Tkt): Promise<void> {
await focusNamedWindow("main");
await minimizeWindow();
await emitCallAction({
ticketUid: row.ticketUid,
tktNum: row.tktNum,
});
await log("info", `票池呼叫按钮点击: ticketUid=${row.ticketUid}, tktNum=${row.tktNum}`);
}
/**
* 从票池发起评价动作
*/
async function handleReEvaluate(row: Tkt): Promise<void> {
await focusNamedWindow("main");
await minimizeWindow();
await emitEvaluateAction({
ticketUid: row.ticketUid,
tktNum: row.tktNum,
});
await log("info", `票池评价按钮点击: ticketUid=${row.ticketUid}, tktNum=${row.tktNum}`);
}
/**
* 刷新票池数据
*/
async function refreshData(): Promise<void> {
if (loading.value) {
return;
}
loading.value = true;
try {
await safeLog("info", "票池刷新: start");
const session = await getSession();
const winUid = Number(session.winUid ?? -1);
if (winUid <= 0) {
await safeLog("warn", `票池刷新: winUid 无效 (${winUid}),清空数据`);
allData.value = [];
totalCount.value = 0;
return;
}
const res = await api.action.pool({
winUid,
keyword: keyword.value.trim(),
page: currentPage.value,
size: pageSize.value,
status: statusFilter.value === null ? undefined : statusFilter.value,
});
const rawRows = Array.isArray(res.list)
? res.list
: Array.isArray(res.records)
? res.records
: Array.isArray(res.items)
? res.items
: [];
allData.value = normalizeRows(rawRows as unknown[]);
totalCount.value = Number(res.total ?? res.count ?? allData.value.length);
await safeLog("info", `票池刷新: success total=${totalCount.value}`);
} catch (error) {
await safeLog(
"error",
`票池刷新失败: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
loading.value = false;
}
}
function scheduleImmediateRefresh(): void {
if (focusRefreshTimer !== null) {
clearTimeout(focusRefreshTimer);
}
// show/focus
focusRefreshTimer = setTimeout(() => {
void refreshData();
}, 80);
}
function handleWindowFocus(): void {
scheduleImmediateRefresh();
}
function handleVisibilityChange(): void {
if (!document.hidden) {
scheduleImmediateRefresh();
}
}
onMounted(async () => {
console.info("[TicketListView] mounted");
await safeLog("info", "TicketListView mounted");
try {
const session = await getSession();
tableHeight.value = 720 - 220;
if (!session.winUid) {
await safeLog("warn", "票池页面启动时未获取到窗口 UID");
}
await refreshData();
refreshTimer = setInterval(() => {
void refreshData();
}, 20000);
window.addEventListener("focus", handleWindowFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);
} catch (error) {
await safeLog(
"error",
`TicketListView onMounted 失败: ${error instanceof Error ? error.message : String(error)}`,
);
ElMessage.error("票号列表加载失败,请查看日志");
loading.value = false;
}
});
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
if (focusRefreshTimer) {
clearTimeout(focusRefreshTimer);
focusRefreshTimer = null;
}
window.removeEventListener("focus", handleWindowFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
</script>
<style scoped>
.table-container {
height: 720px;
width: 1024px;
display: flex;
background-color: #fff;
flex-direction: column;
overflow: hidden;
position: relative;
}
.ticket-header {
width: 100%;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #004d99 0%, #003b7a 100%);
color: #fff;
padding: 0 6px 0 12px;
}
.ticket-header-title {
flex: 1;
height: 100%;
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
}
.ticket-header-actions {
display: flex;
align-items: center;
}
.control-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
border-radius: 4px;
-webkit-app-region: no-drag;
border: none;
background: transparent;
cursor: pointer;
}
.control-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.control-icon {
font-size: 18px;
}
.search-wrapper {
display: flex;
gap: 8px;
padding: 8px 16px 0;
margin: 16px;
}
.ticket-table {
width: 100%;
}
.pagination-wrapper {
margin-top: 16px;
padding: 0 16px;
display: flex;
justify-content: flex-end;
}
.stats-info {
margin: 8px 0;
padding: 0 16px;
font-size: 12px;
color: #909399;
text-align: right;
}
:deep(.el-table) {
table-layout: auto;
}
:deep(.el-table__header-wrapper),
:deep(.el-table__body-wrapper) {
overflow-x: hidden !important;
}
:deep(.el-table .cell) {
white-space: nowrap;
}
</style>

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

@ -0,0 +1,161 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
const APP_NAME = "call-client";
const CONFIG_FILE_NAME = "config.json";
const API_QUEUE_CALLER_PATH = "/api/queue/caller";
const DEFAULT_API_PORT = 8845;
/**
*
*/
function getHomeDirectory(): string {
return process.env.HOME || process.env.USERPROFILE || os.homedir() || ".";
}
/**
*
*/
function getConfigFilePath(): string {
if (process.platform === "linux") {
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
const baseDirectory =
xdgConfigHome && path.isAbsolute(xdgConfigHome)
? xdgConfigHome
: path.join(getHomeDirectory(), ".config");
return path.join(baseDirectory, APP_NAME, CONFIG_FILE_NAME);
}
if (process.env.APPDATA) {
return path.join(process.env.APPDATA, APP_NAME, CONFIG_FILE_NAME);
}
return path.join(getHomeDirectory(), `.${APP_NAME}`, CONFIG_FILE_NAME);
}
/**
* server_ip Vite target
*/
function buildProxyTarget(serverIp: string): string {
const raw = serverIp.trim();
if (!raw) {
return "";
}
if (raw.startsWith("http://") || raw.startsWith("https://")) {
return raw.replace(/\/$/, "");
}
const hostPort = raw.replace(/^\/+/, "");
const hasPort = /:\d+$/.test(hostPort) || /^\[.+\]:\d+$/.test(hostPort);
if (hasPort) {
return `http://${hostPort}`;
}
return `http://${hostPort}:${DEFAULT_API_PORT}`;
}
/**
* config.json
*/
function resolveProxyTarget(): string {
const configFilePath = getConfigFilePath();
if (!fs.existsSync(configFilePath)) {
return "";
}
try {
const content = fs.readFileSync(configFilePath, "utf8");
const parsed = JSON.parse(content) as Record<string, unknown>;
const serverIp = typeof parsed.server_ip === "string" ? parsed.server_ip : "";
return buildProxyTarget(serverIp);
} catch {
return "";
}
}
const proxyTarget = resolveProxyTarget();
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [
vue(),
AutoImport({
imports: ["vue", "vue-router"],
dts: false,
resolvers: [ElementPlusResolver()],
}),
Components({
dts: false,
resolvers: [ElementPlusResolver()],
}),
],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) {
return undefined;
}
if (id.includes("element-plus") || id.includes("@element-plus")) {
return "vendor-element-plus";
}
if (id.includes("vue-router")) {
return "vendor-router";
}
if (id.includes("axios")) {
return "vendor-axios";
}
if (id.includes("node_modules/vue/") || id.includes("node_modules/@vue/")) {
return "vendor-vue";
}
return "vendor-misc";
},
},
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
proxy: proxyTarget
? {
[API_QUEUE_CALLER_PATH]: {
target: proxyTarget,
changeOrigin: true,
},
}
: undefined,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));
Loading…
Cancel
Save