修正倒计时bug 更新导税员窗口

master
cysamurai 1 month ago
parent 0eb502ae43
commit 0533c2ccb7

@ -9,14 +9,44 @@
## 代码规范 ## 代码规范
- 函数名用 camelcase组件名用 Pascalcase - 函数名用 camelcase组件名用 Pascalcase
- 常量UPPER_SNAKE_CASE
- 文件名:一般 kebab-case 或与默认导出组件同名 PascalCase
- 布尔值变量以 is / has / should 开头,如 isLoading
- 所有函数必须有 JSDoc 注释 - 所有函数必须有 JSDoc 注释
- 错误处理必须用 try/catch不能用.catch() - 错误处理必须用 try/catch不能用.catch()
- 行尾无空白,文件末尾保留一个空行
- 一行代码不超过 100 / 120 个字符(常见 100超长时合理换行
- 不加分号
- 普通字符串用 单引号 'hello',需要插值时用反引号模板字符串
- JSX 属性用双引号
- 严格的导入顺序第三方库React、axios 等)> 内部路径别名模块(如 @/components> 相对路径模块(./utils> 样式文件
- 禁止通配符导入import \* as … 只在极少数必要情况)
- 分组之间保留空行,同类按字母排序
- 任何有意义的数字和字符串必须提取为 命名常量
- 始终使用 === 和 !==,避免隐式类型转换带来问题
- 单一职责:一个函数只做一件事
- 长度:单个函数不超过 50 行(逻辑复杂时拆小)
- 参数数量:不超过 3 个,超出时用对象参数
- 提前返回Return Early避免深层 if 嵌套,提高可读性
- 纯函数优先:不修改入参,避免副作用
- 所有 async/await 必须 try/catch 或附加 .catch()
- 严禁空 catch 块,至少要记录日志或显式忽略并注释原因
- Promise 的拒绝必须处理unhandled rejection 零容忍)
- 所有 导出的函数、类、类型、接口、组件 必须写 JSDoc / TSDoc 注释,描述用途、参数、返回值
- 复杂逻辑用行注释解释“为什么”,而非“做什么”
- 使用 // TODO: 和 // FIXME: 标记,并要求 AI 不在生产代码留大段注释掉的旧代码
- 不保留未使用的变量、导入、函数(可由 ESLint 直接检测)
- 不留下 console.log 调试语句(除非严格限定的日志工具)
- 一个文件一个主要导出(默认导出或单一组件),相关工具/类型可并存
- 文件总行数建议不超过 300 行(组件可适当放宽,但组件太长也要拆分)
## 禁止行为 ## 禁止行为
- 禁用 var全部使用 const / let,优先 const只有确实需要重新赋值才用 let
- 不使用 any 类型 - 不使用 any 类型
- 不写裸 console.log用统一的 logger - 不写裸 console.log用统一的 logger
- css不用内联样式 - css不用内联样式
- 禁止混用 Tab使用 2 个空格(或 4 个,项目统一即可)
## 提交规范 ## 提交规范

@ -0,0 +1,537 @@
# TaxerInfo 模块改写为 Tauri v1单窗口 + Vue设计文档
## 1. 文档目标
`CallClient/WPF/TaxerInfo.xaml.cs` + `CallClient/WPF/TaxerInfo.xaml` 对应功能迁移到 **Tauri v1 + Vue单窗口** 架构,确保:
- 业务行为与现有 WPF 一致(取号、开始办理、办结、实名信息、企业列表、网页加载、复制、查询)。
- 桌面能力由 TauriRust提供界面由 Vue 负责。
- 后续可以逐步替换原 `WebBrowser` 内嵌业务页,最终统一为前端组件化实现。
---
## 2. 现有模块功能盘点WPF
## 2.1 核心职责
`TaxerInfo` 窗口当前承担:
1. 展示办税员实名信息(身份证图、头像、姓名、手机号、采集状态)。
2. 展示当前取号下的企业/票据列表,支持“开始”“办结”“选择企业”。
3. 根据当前票据拼接 URL 并加载纳税人页面(`WebBrowser`)。
4. 提供操作按钮:复制税号、复制号码、刷新、一户式查询。
5. 展示“代理人办理企业清单”(通过 HTTP 接口获取)。
## 2.2 主要数据模型
- `ticket`:票据/办理对象(状态、企业信息、号票信息等)。
- `Enterprise`:代理人办理企业清单项。
- `SerialNumber`
- `EnterpriseName`
- `TaxpayerId`
- `ApiResponse / DataItem``today-enterprises` 接口返回结构。
- 外部响应:
- `SmzJhxtGetBsyxxByqhhmResponse`
- `SmzJhxtGetSmzcjxxResponse`
- `SmzJhxtGetBdNsrxxResponse`
## 2.3 关键流程
1. `LoadInfo(comparecode)` 触发加载:
- 调实名接口、绑定纳税人接口、绑定企业接口;
- 查询当日关联票据列表;
- 填充 UI。
2. 票据切换(`SelectedSubTicket`
- 更新“当前办理文案”;
- 刷新纳税人页面 URL。
3. 开始办理(`StartCommand`
- 更新票据状态为办理中;
- 记录窗口/员工信息;
- 刷新右侧页面。
4. 办结(`EndCommand`
- 结束当前票据;
- 浏览器跳转 `about:blank`
---
## 3. 迁移目标架构Tauri v1
## 3.1 技术分层
- **前端Vue**
- 单窗口页面:`TaxerInfoView.vue`
- 状态管理Pinia推荐或 Vue reactive store
- UI 组件Element Plus / Ant Design Vue任选
- **Tauri 后端Rust**
- `#[tauri::command]` 提供桌面能力与系统访问
- 封装对旧服务接口调用(可直接 HTTP 调用)
- 封装票据数据库/SDK 调用(通过 Rust 侧 adapter
- **桥接**
- `invoke()`:前端调用命令
- `event`:推送异步状态(可选)
## 3.2 单窗口布局建议(与 WPF 对齐)
- 左侧(固定宽度)
- 办税员实名信息卡片(身份证图 + 头像 + 姓名 + 手机 + 采集状态)
- 企业/票据列表(开始、办结按钮)
- “选择企业”按钮
- 右侧(自适应)
- 顶部操作栏(当前办理文案 + 四个按钮)
- Tabs
- `纳税人信息``iframe/webview` 或前端重构页
- `代理人办理企业清单`:表格展示 `EnterpriseList`
---
## 4. 接口设计Tauri Command + 业务 HTTP
以下为建议接口(前端调用 Rust command
## 4.1 初始化与加载
### `taxer_load_info(compareCode: String, currentTicketId: String) -> TaxerLoadInfoDto`
聚合返回:
- 办税员实名信息(姓名、性别、身份证、地址、头像、身份证底图)
- 实名采集结果(手机号、备注、是否已采集)
- 绑定纳税人列表(用于税号映射)
- 当日票据列表(`TicketDto[]`
- 默认选中票据
- 代理人办理企业清单(`EnterpriseDto[]`
> 说明WPF 中由 `BackgroundWorker + 多次接口请求 + 本地票据查询` 完成,迁移后建议统一为一个“聚合命令”,减少前端编排复杂度。
## 4.2 票据动作
### `taxer_start_ticket(ticketId: String, windowUid: i64, employeeUid: i64) -> TicketDto`
- 更新开始时间、状态dealing、窗口/员工绑定。
- 返回更新后的票据数据。
### `taxer_end_ticket(ticketId: String) -> TicketDto`
- 标记办结(对应原 `t.Stop(DateTime.Now)`)。
- 返回更新后的票据数据。
### `taxer_batch_end_or_abandon(ticketIds: Vec<String>) -> ()`
- 对应原 `End()` 的兜底逻辑(办理中停止、待办废弃)。
## 4.3 URL 与外部查询
### `taxer_build_nsr_url(ticketId: String, phoneNo: String, compareCode: String) -> String`
根据模板参数替换生成 URL等价 `Analize_URL`
- `{djxh}` `ENTERPRISE_ID`
- `{sfzhm}` 身份证号
- `{sflb}` 企业/个人标识
- `{swjgDm}` 税务机关代码
- `{qhhm}` 取号号码
- `{sjhm}` 手机号
### `taxer_build_yhscx_url(ticketId: String) -> String`
生成“一户式查询”URL。
## 4.4 代理人企业清单
### `taxer_get_today_enterprises(ticketId: String) -> Vec<EnterpriseDto>`
调用:
- `GET {ZIYUN_SERVICE_URL}/agentInfo/today-enterprises/{ticketId}`
返回字段映射:
- `serialNumber -> SerialNumber`
- `serviceTarget -> EnterpriseName`
- `serviceTargetCode -> TaxpayerId`
## 4.5 系统能力
### `system_copy_text(text: String) -> ()`
- 复制号码、复制税号统一复用。
### `system_open_external(url: String) -> ()`
- 一户式查询可在系统默认浏览器打开(可选)。
---
## 5. 前端 Vue 数据结构建议
```ts
export interface TaxerState {
compareCode: string
currentTicketId: string
currentTicket?: TicketDto
selectedSubTicket?: TicketDto
ticketList: TicketDto[]
enterpriseList: EnterpriseDto[]
name: string
phoneNo: string
memo: string
idCardImageBase64?: string
headImageBase64?: string
caijiRegistered: boolean
isLoading: boolean
isStarted: boolean
currentCompanyText: string
nsrUrl: string
}
```
---
## 6. 功能映射表WPF -> Tauri + Vue
- `Caiji_Click`:打开实名采集弹窗 -> Vue Dialog + `taxer_submit_realname`(若后续迁移)。
- `Copy_Click`:复制 `TKT_ID` -> `system_copy_text(currentTicket.tktId)`
- `Copy_Tax_Click`:复制税号 -> 先映射税号,再 `system_copy_text`
- `Reflesh_Click`:浏览器刷新 -> `iframe` 重新赋值 `src` 或 key 强制重渲染。
- `YHSCX_Click`:一户式查询 -> `taxer_build_yhscx_url` + 新标签页/外部浏览器。
- `StartCommand`:开始办理 -> `taxer_start_ticket` 后更新列表和当前文案。
- `EndCommand`:办结 -> `taxer_end_ticket` 后更新状态并清空 `nsrUrl`
- `SelectedSubTicket` setter切换企业 -> 重算 `currentCompanyText``nsrUrl`
- `LoadInfo`:初始化 -> `taxer_load_info` 一次加载。
---
## 7. 页面交互细节Vue
1. 页面 `onMounted``taxer_load_info`
2. 左侧列表点击行 => 更新 `selectedSubTicket`,调用 `taxer_build_nsr_url`
3. 列表项按钮显示规则(与现有一致):
- 显示“开始”:`isStarted && (status == 0 || status == 2)`
- 显示“办结”:`isStarted && status == 4`
4. 采集状态图标:
- 已采集:绿色(对应 `Registerd.png`
- 未采集:灰/红(对应 `UnRegisterd.png`
---
## 8. 迁移实施步骤(建议)
## 第 1 阶段:壳迁移(低风险)
- 建立 Tauri v1 + Vue 单窗口工程。
- 先做 UI 结构 1:1 迁移。
- 右侧“纳税人信息”先保留 `iframe/webview` 加载原 URL不改业务页
## 第 2 阶段:命令层落地
- 实现 `taxer_load_info / start / end / build_url / get_today_enterprises`
- 前端改为只依赖 command不再直接拼接/请求。
## 第 3 阶段:体验与稳定性
- 增加错误提示、重试、超时处理。
- 增加日志链路(前端行为日志 + Rust 命令日志)。
- 补充 E2E 场景测试(见第 10 节)。
---
## 9. 关键风险与处理
- **旧 SDK 依赖迁移风险**`ticket/business` 等可能强依赖 .NET。
- 方案:先通过 HTTP/本地服务桥接,逐步替换为 Rust 实现。
- **WebBrowser 行为差异**WPF WebBrowser 与 Tauri webview 对 cookie/session 行为不同。
- 方案:先验证登录态与跨域;必要时改外部浏览器打开。
- **线程模型差异**WPF Dispatcher/BackgroundWorker 到 Vue 异步模型要重构。
- 方案:统一在 command 层串行/并行编排,前端只维护状态。
---
## 10. 测试清单(迁移验收)
- 初始化后实名信息完整展示(姓名、头像、身份证图、手机号)。
- 票据列表状态渲染正确(待办/办理中/已完成)。
- 开始办理后状态切换为办理中,当前办理文案正确。
- 办结后状态切换为已完成,页面清空/切换逻辑正确。
- 复制号码、复制税号可在系统剪贴板拿到正确值。
- 一户式查询 URL 参数替换正确(`djxh/sfzhm/sflb/swjgDm/qhhm/sjhm`)。
- 代理人办理企业清单可正确加载并展示。
- 网络异常时有可感知报错,不出现页面卡死。
---
## 11. 建议目录结构Tauri
```text
src-tauri/
src/
commands/
taxer.rs
system.rs
services/
baishui_service.rs
ziyun_service.rs
ticket_service.rs
models/
taxer.rs
ticket.rs
src/
views/
TaxerInfoView.vue
stores/
taxer.ts
services/
tauri-taxer.ts
components/
taxer/
TaxerProfileCard.vue
TicketList.vue
TaxerToolbar.vue
EnterpriseTable.vue
```
---
## 12. 结论
`TaxerInfo` 模块适合按“**UI 先迁移、命令聚合、再逐步替换内嵌网页**”的路径落地。
在 Tauri v1 中建议将现有 ViewModel 的业务入口收敛为 5~7 个 command由 Vue 负责渲染与交互,可在保持业务一致的前提下提升后续可维护性。
---
## 13. 深挖:`BaiShuiSDK` / `QueuingSystemSDK` 真实依赖行为
本节用于回答“改写后是否还能调用相同后端接口”的关键问题。
## 13.1 `TaxerInfo` 实际依赖清单(代码级)
- `BaiShuiSDK.Model.BaishuiModel`
- `SmzJhxtGetBsyxxByqhhm`(取号号 -> 办税员信息)
- `SmzGetSmzcjxx`(身份证号 -> 采集信息)
- `SmzJhxtGetBdNsrxx`(身份证号 -> 绑定纳税人)
- `QueuingSystemSDK.ticket`
- `Update()`、`Stop()`、`Abandon()`、`GetList(...)`
- `QueuingSystemSDK.business`
- `new business((int)t.BIZ_UID)` 用于补全业务名称
- `QueuingSystemSDK.SystemControl`
- `Get("REALNAME_CHECK_API")`、`Get("BS_NSR_URL")`、`Get("BS_YHSCX_URL")`、`Get("BS_TAX_AUTHORITY_NUM")`、`Get("ZIYUN_SERVICE_URL")`
- `QueuingSystemSDK.HttpHelper`
- `httpGet("{ZIYUN_SERVICE_URL}/agentInfo/today-enterprises/{tktId}")`
- `DbHelperSQL.GetServerTime()`(开始办理时落库时间)
## 13.2 百税接口协议(必须保持兼容)
`TaxerInfo` 间接通过 `BaishuiModel -> BsDefaultClient.execute` 调后端。真实请求格式不是纯 JSON而是
- `POST {REALNAME_CHECK_API}`
- `Content-Type: application/x-www-form-urlencoded`
- body 参数:
- `domain.ywId={bid}`
- `domain.parmJson={UrlEncode(Json(request))}`
其中 `UrlEncode` 使用 SDK 自定义逻辑:逐字符编码,并将 `%xx` 转为大写(`%2f -> %2F`)。
这在某些网关上会影响签名或解析结果,建议 Rust 侧保持一致实现。
## 13.3 `TaxerInfo` 使用的 3 个百税 `bid`
- `smz.jhxtGetBsyxxByqhhm`
- 请求字段:`qhhm`
- 来源:`compareCode`(取号号码)
- `smz.getSmzcjxx`
- 请求字段:`sfzhm`
- 来源:上一步返回 `result.sfzhm`
- `smz.jhxtGetBdNsrxx`
- 请求字段:`sfzhm`
- 来源:同上
返回字段依赖(前端展示必须保留):
- 办税员:`xm/xb/mz/sfzhm/zz/csrq/sfzzmPic/headPic`
- 采集信息:`sjhm/bz/code`
- 绑定纳税人:`dataList[].djxh/nsrsbh/nsrmc`
## 13.4 队列票据本地数据行为(非 HTTP
`ticket` 属于本地数据库实体SQL Server不是远端 REST
- `Update()`
- 更新 `WIN_ID/BIZ_UID/EMP_UID/START_TIME/END_TIME/RANK/STATUS`
- `Stop(DateTime)`
- 更新 `END_TIME=GETDATE()`、`STATUS=complete(5)`
- `Abandon()`
- 更新 `END_TIME=GETDATE()`、`STATUS=abandoned(6)`
- `GetList(where, ..., hideLinkTicket)`
- 直接拼 SQL 查 `[ticket]` 表并映射对象
这意味着 Tauri 改写时有两个实现路径:
1. **兼容优先**:继续复用现有 .NET 服务层本地中间服务操作数据库Tauri 仅调用中间层 API。
2. **重写优先**Rust 直接连库并重建 `ticket` SQL 逻辑(工作量和风险更高)。
## 13.5 配置来源(`SystemControl.Get`
`SystemControl` 在静态构造时会把 `system` 表配置加载进内存缓存,然后 `Get(key)` 直接读缓存。
`TaxerInfo` 迁移最关键的配置项如下:
- `REALNAME_CHECK_API`:百税 API 入口地址
- `BS_NSR_URL`:纳税人信息页模板(含 `{djxh}` 等占位符)
- `BS_YHSCX_URL`:一户式查询 URL 模板
- `BS_TAX_AUTHORITY_NUM``{swjgDm}` 参数
- `ZIYUN_SERVICE_URL`:紫云服务入口
默认值(由 `DBUpdateManager` 初始化)显示 `BS_NSR_URL/BS_YHSCX_URL` 为同一模板,迁移后可沿用同策略。
## 13.6 直接 HTTP 接口(紫云)
`GetDelegaterInfo()` 使用:
- `GET {ZIYUN_SERVICE_URL}/agentInfo/today-enterprises/{CurrentTicket.TKT_ID}`
解析规则:
- 响应 `code == 200 && data != null` 才展示
- 字段映射:
- `serialNumber -> SerialNumber`
- `serviceTarget -> EnterpriseName`
- `serviceTargetCode -> TaxpayerId`
- 否则清空列表
## 13.7 Tauri v1 落地时的“接口保持一致”建议
### A. 百税接口适配器Rust必须做到
- 保持相同 `bid` 与请求字段名
- 保持 form 参数名:`domain.ywId`、`domain.parmJson`
- 保持 UTF-8 + URL 编码行为(百分号大写)
- 保持错误语义:
- `SmzJhxtGetBsyxxByqhhm``code != "00"` 视为失败
- `SmzGetSmzcjxx`:允许 `"00"``"01"`
- `SmzJhxtGetBdNsrxx`:当前 SDK 对非 00 未强拦截(按原行为兼容)
### B. 队列票据能力建议
- 第一阶段不要在 Rust 侧直连数据库重写 `ticket` 全量逻辑;
- 建议先将 `Start/Stop/GetList` 封成可调用服务HTTP/IPCTauri 调该服务;
- 等功能回归稳定后再考虑 Rust 直连库替换。
### C. URL 模板能力
- `Analize_URL` 的 6 个占位符替换规则必须逐字兼容:
- `{djxh}`、`{sfzhm}`、`{sflb}`、`{swjgDm}`、`{qhhm}`、`{sjhm}`
## 13.8 额外注意点(代码库现状)
- `SmzJhxtGetBdNsrxxRequest` 类定义在 `SmzJhxtGetBsyGlqyxxRequest.cs` 文件中(文件名与类名不一致),迁移时建议统一命名避免维护歧义。
- `HttpHelper.httpGet` 未设置超时与异常细化Tauri 迁移时建议新增超时、重试与可观测日志。
---
## 14. 基于现有 Tauri 项目(`F:\workspace\zyclient_linux\TauriClient\call-client`)的落位分析
本节基于当前 Tauri v1 代码现状补充,目的是将 `TaxerInfo` 改写方案落到“现项目可实施”的路径,而非抽象设计。
## 14.1 当前 Tauri 主结构(已实现)
- 前端路由:
- `/main` -> `MainView.vue`
- `/ticketList` -> `TicketListView.vue`
- `/login` -> `LoginView.vue`
- `/setup` -> `ServerSetupView.vue`
- Rust 窗口命令(已实现):
- `open_ticket_window`
- `close_ticket_window`
- `focus_window`
- `open_main_window`
- `open_login_window`
- 当前窗口形态:
- `main` 窗口:`500x100`、`always_on_top`、无边框(条形呼叫台)
- `ticketList` 窗口:`1024x720`、无边框、独立路由页
## 14.2 “办税员窗口”当前行为(关键结论)
`MainView.vue` 的“更多菜单”里确有 `办税员窗口` 按钮,但目前逻辑是:
- `handleMoreCommand("main")` 仅提示“当前已在办税员窗口”
- 并不会打开新的 TaxerInfo 页面/窗口
这说明:**现项目尚未承载 C# `TaxerInfo` 的完整 UI 与业务**,仅有一个呼叫控制条主窗。
## 14.3 与 `TaxerInfo` 的差距(按能力分层)
### A. 窗口与页面承载差距
- 现 `main` 尺寸为 `500x100`,不具备 `TaxerInfo`(约 `1200x600`)承载条件。
- 当前仅有一个次窗口 `ticketList`,尚无 `taxerInfo` 路由/窗口定义。
### B. 接口协议差距
- 现有 `src/api/index.ts` 全部走 `API_QUEUE_CALLER_PATH=/api/queue/caller`JSON REST
- `TaxerInfo` 依赖的百税接口协议是 form-url-encoded + `domain.ywId/domain.parmJson`,目前项目中尚未实现该适配器。
### C. 数据源差距
- 现 `MainView/TicketListView` 主要消费 `call-terminal/*` 接口(呼叫、票池、评价)。
- `TaxerInfo` 需要额外数据源:
- 百税 9.2.2 / 9.2.12 / 9.2.3
- 紫云 `today-enterprises`
- 本地票据扩展信息(企业名、税号映射等)
## 14.4 在现项目中建议的落位方案(不改代码阶段的设计结论)
### 方案选型
推荐新增独立窗口 `taxerInfo`(与 `ticketList` 同级),而不是直接塞入当前 `500x100` 主窗:
1. 保留 `main` 作为呼叫条形控制台(最小侵入)。
2. 在“更多菜单 -> 办税员窗口”中打开/聚焦 `taxerInfo`
3. `taxerInfo` 使用新路由页面(建议 `/taxerInfo`)承载原 WPF 界面。
### 这样做的原因
- 与现有多窗口架构一致(已有 `ticketList` 模式可复用)。
- 不影响当前呼叫链路稳定性。
- 便于逐步迁移 `TaxerInfo` 而非一次性替换 `MainView`
## 14.5 与现项目文件的映射建议
- 前端:
- 新增 `src/views/TaxerInfoView.vue`
- `src/router/index.ts` 增加 `/taxerInfo`
- `src/host/window.ts` 增加 `openTaxerInfoWindow()`
- `src/views/MainView.vue``handleMoreCommand("main")` 改为打开/聚焦 `taxerInfo`
- Rust
- `src-tauri/src/commands/window.rs` 增加
- `open_taxer_info_window`
- `close_taxer_info_window`(可选)
- `src-tauri/src/lib.rs` 注册上述命令
## 14.6 `TaxerInfo` 接口在现项目中的接入位置建议
### 前端 API 层
`src/api` 下新增 `taxer.ts`,避免和 `call-terminal` 混杂:
- `loadTaxerInfo(compareCode, currentTicketId)`
- `startTaxerTicket(...)`
- `endTaxerTicket(...)`
- `getTodayEnterprises(ticketId)`
- `buildNsrUrl(...)` / `buildYhscxUrl(...)`(可前后端任选)
### HTTP 传输层
沿用 `src/utils/service.ts` 的 axios + Tauri HTTP adapter 机制,但需要区分两类 baseURL
1. 现有 `API_QUEUE_CALLER_PATH`
2. 百税 API 专用 baseURL`REALNAME_CHECK_API`+ form 编码适配
## 14.7 文档结论更新(针对你的目标)
结合当前 Tauri 项目,`TaxerInfo` 改写最可行路径是:
- **先新增 `taxerInfo` 独立窗口**(由 `MainView` 的“办税员窗口”按钮打开)
- **再迁移 C# `TaxerInfo` 的 UI 与接口编排**
- **最后再评估是否把条形主窗与办税员窗合并**
这样既满足“在 Tauri 主流程中打开办税员窗口”,又不会破坏现有呼叫台功能。

@ -0,0 +1,332 @@
{
"info": {
"_postman_id": "b9e271f1-8f38-47d5-9f3f-0cd2f1f5a904",
"name": "Taxer 9.4 Mock",
"description": "用于测试 TaxerInfo 当前已接入的 9.4 接口9.4.1/9.4.2/9.4.10",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "baseUrl",
"value": "https://YOUR_MOCK_ID.mock.pstmn.io"
},
{
"key": "qhhm",
"value": "A001"
},
{
"key": "sfzhm",
"value": "330381199806263611"
}
],
"item": [
{
"name": "9.4.1 smz.jhxtGetBsyxxByqhhm",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "domain.ywId",
"value": "smz.jhxtGetBsyxxByqhhm",
"type": "text"
},
{
"key": "domain.parmJson",
"value": "{\"qhhm\":\"{{qhhm}}\"}",
"type": "text"
}
]
},
"url": {
"raw": "{{baseUrl}}/taxCommon/doService",
"host": [
"{{baseUrl}}"
],
"path": [
"taxCommon",
"doService"
]
},
"description": "根据取号号码获取办税员信息9.4.1"
},
"response": [
{
"name": "success-00",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "domain.ywId",
"value": "smz.jhxtGetBsyxxByqhhm",
"type": "text"
},
{
"key": "domain.parmJson",
"value": "{\"qhhm\":\"A001\"}",
"type": "text"
}
]
},
"url": {
"raw": "{{baseUrl}}/taxCommon/doService",
"host": [
"{{baseUrl}}"
],
"path": [
"taxCommon",
"doService"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": "{\n \"code\": \"00\",\n \"mess\": \"成功\",\n \"result\": {\n \"xm\": \"张三\",\n \"sfzhm\": \"330381199806263611\",\n \"xb\": \"1\",\n \"mz\": \"汉\",\n \"csrq\": \"1998-06-26\",\n \"zz\": \"浙江省温州市\",\n \"sfzzmPic\": \"\",\n \"headPic\": \"\",\n \"sjhm\": \"13800000000\",\n \"bsyly\": \"请携带完整资料\",\n \"dataList\": [\n {\n \"djxh\": \"91330100X12345678A\"\n }\n ]\n }\n}"
}
]
},
{
"name": "9.4.2 smz.jhxtGetBdNsrxx",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "domain.ywId",
"value": "smz.jhxtGetBdNsrxx",
"type": "text"
},
{
"key": "domain.parmJson",
"value": "{\"sfzhm\":\"{{sfzhm}}\"}",
"type": "text"
}
]
},
"url": {
"raw": "{{baseUrl}}/taxCommon/doService",
"host": [
"{{baseUrl}}"
],
"path": [
"taxCommon",
"doService"
]
},
"description": "根据身份证获取绑定纳税人信息9.4.2"
},
"response": [
{
"name": "success-00",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "domain.ywId",
"value": "smz.jhxtGetBdNsrxx",
"type": "text"
},
{
"key": "domain.parmJson",
"value": "{\"sfzhm\":\"330381199806263611\"}",
"type": "text"
}
]
},
"url": {
"raw": "{{baseUrl}}/taxCommon/doService",
"host": [
"{{baseUrl}}"
],
"path": [
"taxCommon",
"doService"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": "{\n \"code\": \"00\",\n \"mess\": \"成功\",\n \"result\": {\n \"dataList\": [\n {\n \"djxh\": \"91330100X12345678A\",\n \"nsrsbh\": \"91330100X12345678A\",\n \"nsrmc\": \"杭州示例科技有限公司\",\n \"tsyjbz\": \"0\",\n \"tsyjnr\": \"\",\n \"zdbz\": \"0\"\n },\n {\n \"djxh\": \"92330100MA2ABCDE1X\",\n \"nsrsbh\": \"92330100MA2ABCDE1X\",\n \"nsrmc\": \"杭州示例个体工商户\",\n \"tsyjbz\": \"1\",\n \"tsyjnr\": \"请核验税务状态\",\n \"zdbz\": \"0\"\n }\n ]\n }\n}"
}
]
},
{
"name": "9.4.10 smz.getSmzcjxx",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "domain.ywId",
"value": "smz.getSmzcjxx",
"type": "text"
},
{
"key": "domain.parmJson",
"value": "{\"sfzhm\":\"{{sfzhm}}\"}",
"type": "text"
}
]
},
"url": {
"raw": "{{baseUrl}}/taxCommon/doService",
"host": [
"{{baseUrl}}"
],
"path": [
"taxCommon",
"doService"
]
},
"description": "获取实名采集信息9.4.10"
},
"response": [
{
"name": "success-00-collected",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "domain.ywId",
"value": "smz.getSmzcjxx",
"type": "text"
},
{
"key": "domain.parmJson",
"value": "{\"sfzhm\":\"330381199806263611\"}",
"type": "text"
}
]
},
"url": {
"raw": "{{baseUrl}}/taxCommon/doService",
"host": [
"{{baseUrl}}"
],
"path": [
"taxCommon",
"doService"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": "{\n \"code\": \"00\",\n \"mess\": \"成功\",\n \"result\": {\n \"code\": \"00\",\n \"sjhm\": \"13800000000\",\n \"bz\": \"已实名采集\",\n \"sfwxgz\": \"Y\"\n }\n}"
},
{
"name": "success-01-uncollected",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{
"key": "domain.ywId",
"value": "smz.getSmzcjxx",
"type": "text"
},
{
"key": "domain.parmJson",
"value": "{\"sfzhm\":\"000000000000000000\"}",
"type": "text"
}
]
},
"url": {
"raw": "{{baseUrl}}/taxCommon/doService",
"host": [
"{{baseUrl}}"
],
"path": [
"taxCommon",
"doService"
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": "{\n \"code\": \"00\",\n \"mess\": \"成功\",\n \"result\": {\n \"code\": \"01\",\n \"sjhm\": \"\",\n \"bz\": \"未实名采集\",\n \"sfwxgz\": \"N\"\n }\n}"
}
]
}
]
}

@ -0,0 +1,268 @@
{
"openapi": "3.0.3",
"info": {
"title": "Taxer 9.4 Mock API",
"version": "1.0.0",
"description": "用于 TaxerInfo 联调的 9.4 接口定义9.4.1 / 9.4.2 / 9.4.10"
},
"servers": [
{
"url": "{{baseUrl}}",
"description": "Mock 或真实服务地址"
}
],
"paths": {
"/taxCommon/doService": {
"post": {
"summary": "9.4.1 smz.jhxtGetBsyxxByqhhm",
"description": "根据取号号码获取办税员信息及办理纳税人",
"operationId": "smz_jhxtGetBsyxxByqhhm",
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/DoServiceRequest"
},
"examples": {
"request-9-4-1": {
"value": {
"domain.ywId": "smz.jhxtGetBsyxxByqhhm",
"domain.parmJson": "{\"qhhm\":\"A001\"}"
}
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BsyxxResponse"
},
"examples": {
"success": {
"value": {
"code": "00",
"mess": "成功",
"result": {
"xm": "张三",
"sfzhm": "330381199806263611",
"xb": "1",
"mz": "汉",
"csrq": "1998-06-26",
"zz": "浙江省温州市",
"sfzzmPic": "",
"headPic": "",
"sjhm": "13800000000",
"bsyly": "请携带完整资料",
"dataList": [
{
"djxh": "91330100X12345678A"
}
]
}
}
}
}
}
}
}
}
}
},
"/taxCommon/doService/bdNsrxx": {
"post": {
"summary": "9.4.2 smz.jhxtGetBdNsrxx",
"description": "根据身份证号码获取绑定纳税人信息",
"operationId": "smz_jhxtGetBdNsrxx",
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/DoServiceRequest"
},
"examples": {
"request-9-4-2": {
"value": {
"domain.ywId": "smz.jhxtGetBdNsrxx",
"domain.parmJson": "{\"sfzhm\":\"330381199806263611\"}"
}
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BdNsrxxResponse"
},
"examples": {
"success": {
"value": {
"code": "00",
"mess": "成功",
"result": {
"dataList": [
{
"djxh": "91330100X12345678A",
"nsrsbh": "91330100X12345678A",
"nsrmc": "杭州示例科技有限公司",
"tsyjbz": "0",
"tsyjnr": "",
"zdbz": "0"
},
{
"djxh": "92330100MA2ABCDE1X",
"nsrsbh": "92330100MA2ABCDE1X",
"nsrmc": "杭州示例个体工商户",
"tsyjbz": "1",
"tsyjnr": "请核验税务状态",
"zdbz": "0"
}
]
}
}
}
}
}
}
}
}
}
},
"/taxCommon/doService/smzcj": {
"post": {
"summary": "9.4.10 smz.getSmzcjxx",
"description": "获取办税员采集信息",
"operationId": "smz_getSmzcjxx",
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/DoServiceRequest"
},
"examples": {
"request-9-4-10": {
"value": {
"domain.ywId": "smz.getSmzcjxx",
"domain.parmJson": "{\"sfzhm\":\"330381199806263611\"}"
}
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SmzcjResponse"
},
"examples": {
"collected": {
"value": {
"code": "00",
"mess": "成功",
"result": {
"code": "00",
"sjhm": "13800000000",
"bz": "已实名采集",
"sfwxgz": "Y"
}
}
},
"uncollected": {
"value": {
"code": "00",
"mess": "成功",
"result": {
"code": "01",
"sjhm": "",
"bz": "未实名采集",
"sfwxgz": "N"
}
}
}
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"DoServiceRequest": {
"type": "object",
"required": [
"domain.ywId",
"domain.parmJson"
],
"properties": {
"domain.ywId": {
"type": "string"
},
"domain.parmJson": {
"type": "string",
"description": "JSON 字符串(未 URL 编码版本,便于调试)"
}
}
},
"BsyxxResponse": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"mess": {
"type": "string"
},
"result": {
"type": "object"
}
}
},
"BdNsrxxResponse": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"mess": {
"type": "string"
},
"result": {
"type": "object"
}
}
},
"SmzcjResponse": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"mess": {
"type": "string"
},
"result": {
"type": "object"
}
}
}
}
}
}

@ -394,7 +394,9 @@ dependencies = [
name = "call-client" name = "call-client"
version = "0.1.1" version = "0.1.1"
dependencies = [ dependencies = [
"chrono",
"fs2", "fs2",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@ -1103,6 +1105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@ -1326,8 +1329,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.1+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1338,7 +1357,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi 6.0.0",
"wasip2", "wasip2",
"wasip3", "wasip3",
] ]
@ -1514,7 +1533,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"http", "http 0.2.12",
"indexmap 2.13.0", "indexmap 2.13.0",
"slab", "slab",
"tokio", "tokio",
@ -1612,6 +1631,16 @@ dependencies = [
"itoa 1.0.18", "itoa 1.0.18",
] ]
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa 1.0.18",
]
[[package]] [[package]]
name = "http-body" name = "http-body"
version = "0.4.6" version = "0.4.6"
@ -1619,7 +1648,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [ dependencies = [
"bytes", "bytes",
"http", "http 0.2.12",
"pin-project-lite",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http 1.4.0",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http 1.4.0",
"http-body 1.0.1",
"pin-project-lite", "pin-project-lite",
] ]
@ -1652,8 +1704,8 @@ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2",
"http", "http 0.2.12",
"http-body", "http-body 0.4.6",
"httparse", "httparse",
"httpdate", "httpdate",
"itoa 1.0.18", "itoa 1.0.18",
@ -1665,6 +1717,42 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http 1.4.0",
"http-body 1.0.1",
"httparse",
"itoa 1.0.18",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http 1.4.0",
"hyper 1.9.0",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]] [[package]]
name = "hyper-tls" name = "hyper-tls"
version = "0.5.0" version = "0.5.0"
@ -1672,12 +1760,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [ dependencies = [
"bytes", "bytes",
"hyper", "hyper 0.14.32",
"native-tls", "native-tls",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
] ]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"hyper 1.9.0",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.65" version = "0.1.65"
@ -1915,6 +2026,16 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "0.4.8" version = "0.4.8"
@ -2112,6 +2233,12 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@ -3114,6 +3241,61 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@ -3123,6 +3305,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "6.0.0" version = "6.0.0"
@ -3154,6 +3342,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.2.2" version = "0.2.2"
@ -3174,6 +3372,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@ -3192,6 +3400,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_hc" name = "rand_hc"
version = "0.2.0" version = "0.2.0"
@ -3306,9 +3523,9 @@ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2",
"http", "http 0.2.12",
"http-body", "http-body 0.4.6",
"hyper", "hyper 0.14.32",
"hyper-tls", "hyper-tls",
"ipnet", "ipnet",
"js-sys", "js-sys",
@ -3322,7 +3539,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper 0.1.2",
"system-configuration", "system-configuration",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
@ -3336,6 +3553,46 @@ dependencies = [
"winreg 0.50.0", "winreg 0.50.0",
] ]
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper 1.9.0",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper 1.0.2",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "rfd" name = "rfd"
version = "0.10.0" version = "0.10.0"
@ -3360,6 +3617,26 @@ dependencies = [
"windows 0.37.0", "windows 0.37.0",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -3382,6 +3659,20 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "rustls-pemfile" name = "rustls-pemfile"
version = "1.0.4" version = "1.0.4"
@ -3391,6 +3682,27 @@ dependencies = [
"base64 0.21.7", "base64 0.21.7",
] ]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@ -3841,6 +4153,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@ -3869,6 +4187,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "synstructure" name = "synstructure"
version = "0.13.2" version = "0.13.2"
@ -4031,7 +4358,7 @@ dependencies = [
"glob", "glob",
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http 0.2.12",
"ignore", "ignore",
"indexmap 1.9.3", "indexmap 1.9.3",
"log", "log",
@ -4047,7 +4374,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"raw-window-handle", "raw-window-handle",
"regex", "regex",
"reqwest", "reqwest 0.11.27",
"rfd", "rfd",
"semver", "semver",
"serde", "serde",
@ -4138,7 +4465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8066855882f00172935e3fa7d945126580c34dcbabab43f5d4f0c2398a67d47b" checksum = "8066855882f00172935e3fa7d945126580c34dcbabab43f5d4f0c2398a67d47b"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http 0.2.12",
"http-range", "http-range",
"rand 0.8.5", "rand 0.8.5",
"raw-window-handle", "raw-window-handle",
@ -4359,6 +4686,21 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.50.0" version = "1.50.0"
@ -4383,6 +4725,16 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@ -4494,6 +4846,45 @@ dependencies = [
"winnow 1.0.1", "winnow 1.0.1",
] ]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper 1.0.2",
"tokio",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.11.0",
"bytes",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]] [[package]]
name = "tower-service" name = "tower-service"
version = "0.3.3" version = "0.3.3"
@ -4613,6 +5004,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -4931,6 +5328,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webkit2gtk" name = "webkit2gtk"
version = "0.18.2" version = "0.18.2"
@ -4978,6 +5385,15 @@ dependencies = [
"system-deps 6.2.2", "system-deps 6.2.2",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.19.1" version = "0.19.1"
@ -5793,7 +6209,7 @@ dependencies = [
"glib", "glib",
"gtk", "gtk",
"html5ever", "html5ever",
"http", "http 0.2.12",
"kuchikiki", "kuchikiki",
"libc", "libc",
"log", "log",
@ -5987,6 +6403,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"

@ -26,4 +26,6 @@ tauri = { version = "1", features = ["api-all"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
fs2 = "0.4" fs2 = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }

@ -5,6 +5,7 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH}, time::{Duration, SystemTime, UNIX_EPOCH},
}; };
use chrono::{DateTime, Local};
const APP_NAME: &str = "com.ziyun.callclient"; const APP_NAME: &str = "com.ziyun.callclient";
const LOG_FILE_NAME: &str = "app.log"; const LOG_FILE_NAME: &str = "app.log";
@ -106,10 +107,15 @@ pub fn app_log(level: String, message: String) -> Result<(), String> {
cleanup_old_logs(&dir)?; cleanup_old_logs(&dir)?;
rotate_log_if_needed(&path)?; rotate_log_if_needed(&path)?;
let timestamp = SystemTime::now() let now = SystemTime::now();
let unix = now
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map_err(|error| format!("生成日志时间失败: {error}"))? .map_err(|error| format!("生成日志时间失败: {error}"))?;
.as_secs(); let ts_secs = unix.as_secs();
let ts_millis = unix.as_millis();
let local_time: DateTime<Local> = DateTime::from(now);
let formatted_time = local_time.format("%Y-%m-%d %H:%M:%S%.3f %:z");
let single_line_message = message.replace('\n', "\\n").replace('\r', "");
let mut file = OpenOptions::new() let mut file = OpenOptions::new()
.create(true) .create(true)
@ -117,6 +123,10 @@ pub fn app_log(level: String, message: String) -> Result<(), String> {
.open(&path) .open(&path)
.map_err(|error| format!("打开日志文件失败: {error}"))?; .map_err(|error| format!("打开日志文件失败: {error}"))?;
writeln!(file, "[{timestamp}] [{}] {message}", level.to_uppercase()) writeln!(
file,
"[{formatted_time}] [unix_s={ts_secs}] [unix_ms={ts_millis}] [{}] {single_line_message}",
level.to_uppercase()
)
.map_err(|error| format!("写入日志失败: {error}")) .map_err(|error| format!("写入日志失败: {error}"))
} }

@ -2,5 +2,6 @@ pub mod config;
pub mod events; pub mod events;
pub mod logger; pub mod logger;
pub mod session; pub mod session;
pub mod sync;
pub mod update; pub mod update;
pub mod window; pub mod window;

@ -0,0 +1,180 @@
use std::process::{Command, Stdio};
use std::time::Duration;
use reqwest::blocking::Client;
use serde::Deserialize;
use serde_json::json;
use tauri::State;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartScreenSyncPayload {
pub notify_base_url: String,
pub stream_url: String,
pub display: Option<String>,
pub frame_rate: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StopScreenSyncPayload {
pub notify_base_url: Option<String>,
pub stream_url: Option<String>,
}
fn normalize_base_url(raw: &str) -> String {
raw.trim().trim_end_matches('/').to_string()
}
fn post_sync_event(
notify_base_url: &str,
path: &str,
stream_url: Option<&str>,
) -> Result<(), String> {
let endpoint = format!("{}/{}", normalize_base_url(notify_base_url), path);
let mut body = serde_json::Map::new();
if let Some(url) = stream_url {
body.insert("url".to_string(), json!(url));
}
Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|error| format!("创建 HTTP 客户端失败: {error}"))?
.post(endpoint)
.json(&body)
.send()
.and_then(|response| response.error_for_status())
.map_err(|error| format!("通知同步服务失败: {error}"))?;
Ok(())
}
fn kill_child_process(child: &mut std::process::Child) {
let _ = child.kill();
let _ = child.wait();
}
pub fn cleanup_screen_sync(state: &AppState) {
let (notify_base_url, stream_url) = {
let mut guard = match state.screen_sync.lock() {
Ok(guard) => guard,
Err(_) => return,
};
if let Some(child) = guard.ffmpeg.as_mut() {
kill_child_process(child);
}
let notify = guard.notify_base_url.clone();
let stream = guard.stream_url.clone();
guard.ffmpeg = None;
guard.notify_base_url = None;
guard.stream_url = None;
(notify, stream)
};
if let Some(base_url) = notify_base_url {
let _ = post_sync_event(&base_url, "sync/stop", stream_url.as_deref());
}
}
#[tauri::command]
pub fn start_screen_sync(
state: State<AppState>,
payload: StartScreenSyncPayload,
) -> Result<(), String> {
let notify_base_url = normalize_base_url(payload.notify_base_url.as_str());
let stream_url = payload.stream_url.trim().to_string();
if notify_base_url.is_empty() || stream_url.is_empty() {
return Err("同步参数不完整".to_string());
}
let display = payload.display.unwrap_or_else(|| ":0.0".to_string());
let frame_rate = payload.frame_rate.unwrap_or(15).to_string();
let mut command = Command::new("ffmpeg");
command
.arg("-f")
.arg("x11grab")
.arg("-framerate")
.arg(frame_rate)
.arg("-i")
.arg(display)
.arg("-vcodec")
.arg("libx264")
.arg("-preset")
.arg("veryfast")
.arg("-tune")
.arg("zerolatency")
.arg("-f")
.arg("flv")
.arg(stream_url.as_str())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let child = command
.spawn()
.map_err(|error| format!("启动 FFmpeg 失败: {error}"))?;
{
let mut guard = state
.screen_sync
.lock()
.map_err(|_| "同步状态锁异常".to_string())?;
if guard.ffmpeg.is_some() {
let mut spawned = child;
kill_child_process(&mut spawned);
return Err("同步主屏已在进行中".to_string());
}
guard.ffmpeg = Some(child);
guard.notify_base_url = Some(notify_base_url.clone());
guard.stream_url = Some(stream_url.clone());
}
if let Err(error) = post_sync_event(&notify_base_url, "sync/start", Some(stream_url.as_str())) {
cleanup_screen_sync(&state);
return Err(error);
}
Ok(())
}
#[tauri::command]
pub fn stop_screen_sync(
state: State<AppState>,
payload: Option<StopScreenSyncPayload>,
) -> Result<(), String> {
let (mut child, notify_base_url, stream_url) = {
let mut guard = state
.screen_sync
.lock()
.map_err(|_| "同步状态锁异常".to_string())?;
let notify = payload
.as_ref()
.and_then(|item| item.notify_base_url.clone())
.map(|value| normalize_base_url(value.as_str()))
.or_else(|| guard.notify_base_url.clone());
let stream = payload
.as_ref()
.and_then(|item| item.stream_url.clone())
.or_else(|| guard.stream_url.clone());
let child = guard.ffmpeg.take();
guard.notify_base_url = None;
guard.stream_url = None;
(child, notify, stream)
};
if let Some(process) = child.as_mut() {
kill_child_process(process);
}
if let Some(base_url) = notify_base_url {
post_sync_event(&base_url, "sync/stop", stream_url.as_deref())?;
}
Ok(())
}

@ -1,4 +1,7 @@
use tauri::{AppHandle, Manager, WindowBuilder, WindowUrl}; use tauri::{AppHandle, Manager, WindowBuilder, WindowUrl};
use tauri::State;
use crate::{commands::logger::app_log, commands::sync::cleanup_screen_sync, state::AppState};
pub fn ensure_main_window(app: AppHandle) -> Result<(), String> { pub fn ensure_main_window(app: AppHandle) -> Result<(), String> {
if app.get_window("main").is_some() { if app.get_window("main").is_some() {
@ -35,6 +38,28 @@ pub fn ensure_login_window(app: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
pub fn ensure_taxer_info_window(app: AppHandle) -> Result<(), String> {
if app.get_window("taxerInfo").is_some() {
let _ = app_log("info".to_string(), "window.ensure_taxer_info: already exists".to_string());
return Ok(());
}
let _ = app_log("info".to_string(), "window.ensure_taxer_info: start build".to_string());
WindowBuilder::new(&app, "taxerInfo", WindowUrl::App("/#/taxerInfo".into()))
.title("办税员窗口")
.inner_size(1200.0, 760.0)
.min_inner_size(1024.0, 640.0)
.resizable(true)
.decorations(false)
.always_on_top(true)
.visible(true)
.build()
.map_err(|error| format!("创建办税员窗口失败: {error}"))?;
let _ = app_log("info".to_string(), "window.ensure_taxer_info: build done".to_string());
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn open_ticket_window(app: AppHandle) -> Result<(), String> { pub fn open_ticket_window(app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_window("ticketList") { if let Some(window) = app.get_window("ticketList") {
@ -84,6 +109,17 @@ pub fn close_ticket_window(app: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command]
pub fn close_taxer_info_window(app: AppHandle) -> Result<(), String> {
let Some(window) = app.get_window("taxerInfo") else {
return Ok(());
};
// 与票号列表一致:关闭即隐藏,复开时由 open_taxer_info_window show/focus。
let _ = window.hide();
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn focus_window(app: AppHandle, label: String) -> Result<(), String> { pub fn focus_window(app: AppHandle, label: String) -> Result<(), String> {
let Some(window) = app.get_window(label.as_str()) else { let Some(window) = app.get_window(label.as_str()) else {
@ -138,6 +174,29 @@ pub fn open_login_window(app: AppHandle) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
pub fn quit_app(app: AppHandle) { pub fn open_taxer_info_window(app: AppHandle) -> Result<(), String> {
let _ = app_log("info".to_string(), "window.open_taxer_info: start".to_string());
ensure_taxer_info_window(app.clone())?;
let Some(window) = app.get_window("taxerInfo") else {
let _ = app_log("error".to_string(), "window.open_taxer_info: window missing after ensure".to_string());
return Err("办税员窗口不存在".to_string());
};
let _ = app_log("info".to_string(), "window.open_taxer_info: before eval".to_string());
let _ = window.eval("if (window.location.hash !== '#/taxerInfo') { window.location.hash = '/taxerInfo'; }");
let _ = app_log("info".to_string(), "window.open_taxer_info: before show".to_string());
let _ = window.show();
let _ = app_log("info".to_string(), "window.open_taxer_info: before unminimize".to_string());
let _ = window.unminimize();
let _ = app_log("info".to_string(), "window.open_taxer_info: before focus".to_string());
let _ = window.set_focus();
let _ = app_log("info".to_string(), "window.open_taxer_info: success".to_string());
Ok(())
}
#[tauri::command]
pub fn quit_app(app: AppHandle, state: State<AppState>) {
cleanup_screen_sync(&state);
app.exit(0); app.exit(0);
} }

@ -11,10 +11,11 @@ use commands::{
events::{emit_to_window, list_windows}, events::{emit_to_window, list_windows},
logger::app_log, logger::app_log,
session::{session_clear, session_get, session_set}, session::{session_clear, session_get, session_set},
sync::{start_screen_sync, stop_screen_sync},
update::check_apt_update, update::check_apt_update,
window::{ window::{
close_ticket_window, ensure_main_window, focus_window, open_login_window, open_main_window, close_taxer_info_window, close_ticket_window, ensure_main_window, focus_window,
open_ticket_window, quit_app, open_login_window, open_main_window, open_taxer_info_window, open_ticket_window, quit_app,
}, },
}; };
use fs2::FileExt; use fs2::FileExt;
@ -83,9 +84,13 @@ pub fn run() {
check_apt_update, check_apt_update,
open_ticket_window, open_ticket_window,
close_ticket_window, close_ticket_window,
close_taxer_info_window,
focus_window, focus_window,
open_main_window, open_main_window,
open_login_window, open_login_window,
open_taxer_info_window,
start_screen_sync,
stop_screen_sync,
quit_app quit_app
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())

@ -12,12 +12,21 @@ pub struct SessionState {
pub struct AppState { pub struct AppState {
pub session: Mutex<SessionState>, pub session: Mutex<SessionState>,
pub screen_sync: Mutex<ScreenSyncState>,
}
#[derive(Default)]
pub struct ScreenSyncState {
pub ffmpeg: Option<std::process::Child>,
pub notify_base_url: Option<String>,
pub stream_url: Option<String>,
} }
impl Default for AppState { impl Default for AppState {
fn default() -> Self { fn default() -> Self {
Self { Self {
session: Mutex::new(SessionState::default()), session: Mutex::new(SessionState::default()),
screen_sync: Mutex::new(ScreenSyncState::default()),
} }
} }
} }

@ -42,6 +42,17 @@
"maximizable": false, "maximizable": false,
"decorations": false, "decorations": false,
"alwaysOnTop": true "alwaysOnTop": true
},
{
"label": "taxerInfo",
"title": "办税员窗口",
"url": "/#/taxerInfo",
"width": 1200,
"height": 760,
"visible": false,
"resizable": true,
"decorations": false,
"alwaysOnTop": true
} }
], ],
"security": { "security": {

@ -10,6 +10,25 @@ import type { UserRequest, UserResponse } from "../types/user";
import type { WindowResponse } from "../types/window"; import type { WindowResponse } from "../types/window";
import { http } from "../utils/service"; import { http } from "../utils/service";
export interface CallerInitData {
windowUid: number;
windowName: string;
empUid: number;
empName: string;
autoCallEnabled: boolean;
autoCallWaitSeconds: number;
autoStartEnabled: boolean;
autoStartWaitSeconds: number;
callInHistoryEnabled: boolean;
callNotifyForward: number;
callNotifyMode: number;
callNotifySmsUrl: string;
callTransferEnabled: boolean;
realnameCheckApi: string;
rankWaitSeconds: number;
ziyunServiceUrl: string;
}
export const api = { export const api = {
user: { user: {
login: (data: UserRequest) => http.post<UserResponse>("/auth/login", data), login: (data: UserRequest) => http.post<UserResponse>("/auth/login", data),
@ -33,5 +52,6 @@ export const api = {
isRank: (params: IsRankRequest) => http.get<IsRankData>("/call-terminal/is-rank", params), isRank: (params: IsRankRequest) => http.get<IsRankData>("/call-terminal/is-rank", params),
getQueueCount: (params: QueueCountRequest) => getQueueCount: (params: QueueCountRequest) =>
http.get<QueueCountData>("/call-terminal/queue-count", params), http.get<QueueCountData>("/call-terminal/queue-count", params),
callerInit: () => http.post<CallerInitData>("/call-terminal/caller-init", {}),
}, },
}; };

@ -0,0 +1,178 @@
import axios from "axios";
import { getSession } from "../host/session";
type JsonRecord = Record<string, unknown>;
const MOCK_BASE_URL = "http://127.0.0.1:4523/m1/8201806-7961256-default";
type BaiShuiEnvelope<T> = {
code?: string;
msg?: string;
message?: string;
mess?: string;
result?: T;
};
type BsyxxResult = {
xm?: string;
xb?: string;
mz?: string;
sfzhm?: string;
sjhm?: string;
bsyly?: string;
zz?: string;
csrq?: string;
sfzzmPic?: string;
headPic?: string;
};
type SmzcjResult = {
code?: string;
sjhm?: string;
bz?: string;
};
type BdNsrxxItem = {
djxh?: string;
nsrsbh?: string;
nsrmc?: string;
};
type BdNsrxxResult = {
dataList?: BdNsrxxItem[];
};
export type TaxerEnterprise = {
serialNumber: string;
enterpriseName: string;
taxpayerId: string;
};
function upperCaseUrlEncode(content: string): string {
return encodeURIComponent(content).replace(/%[0-9a-f]{2}/gi, (value) => value.toUpperCase());
}
function resolveRealnameApiUrl(baseOrFull: string): string {
const normalized = baseOrFull.trim();
if (!normalized) {
return `${MOCK_BASE_URL}/taxCommon/doService`;
}
if (normalized.includes("/taxCommon/doService")) {
return normalized;
}
return `${normalized.replace(/\/$/, "")}/taxCommon/doService`;
}
async function buildBaiShuiBaseFields(): Promise<Record<string, string>> {
const session = await getSession();
const useDocFields = session.taxer_use_doc_fields === true;
if (!useDocFields) {
return {};
}
const pick = (key: string, fallback = ""): string => {
const raw = session[key as keyof typeof session];
return typeof raw === "string" ? raw.trim() : fallback;
};
return {
uid: pick("taxer_uid"),
sid: pick("taxer_sid"),
ver: pick("taxer_ver", "1.0"),
kz: pick("taxer_kz"),
};
}
function extractResult<T>(payload: unknown, ywId: string): T {
const body = (payload ?? {}) as BaiShuiEnvelope<T>;
const code = String(body.code ?? "").trim();
if (code !== "00") {
const message = String(body.mess ?? body.message ?? body.msg ?? "接口返回失败");
throw new Error(`[${ywId}] ${message || "code != 00"}`);
}
if (body.result === undefined || body.result === null) {
throw new Error(`[${ywId}] 返回 result 为空`);
}
return body.result;
}
async function postBaiShui<T>(ywId: string, params: JsonRecord): Promise<T> {
const session = await getSession();
const apiUrl = resolveRealnameApiUrl(
typeof session.realnameCheckApi === "string" ? session.realnameCheckApi : "",
);
const payload = JSON.stringify({
...(await buildBaiShuiBaseFields()),
...params,
});
const body = `domain.ywId=${upperCaseUrlEncode(ywId)}&domain.parmJson=${upperCaseUrlEncode(payload)}`;
const response = await axios.post(apiUrl, body, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
timeout: 10000,
});
return extractResult<T>(response.data, ywId);
}
export async function loadTaxerProfile(compareCode: string): Promise<{
bsyxx: BsyxxResult;
smzcj: SmzcjResult | null;
bdNsrxx: BdNsrxxResult | null;
}> {
const qhhm = compareCode.trim();
if (!qhhm) {
throw new Error("compareCode 不能为空");
}
const bsyxx = await postBaiShui<BsyxxResult>("smz.jhxtGetBsyxxByqhhm", { qhhm });
const sfzhm = String(bsyxx.sfzhm ?? "").trim();
if (!sfzhm) {
return { bsyxx, smzcj: null, bdNsrxx: null };
}
let smzcj: SmzcjResult | null = null;
let bdNsrxx: BdNsrxxResult | null = null;
try {
smzcj = await postBaiShui<SmzcjResult>("smz.getSmzcjxx", { sfzhm });
} catch {
smzcj = null;
}
try {
bdNsrxx = await postBaiShui<BdNsrxxResult>("smz.jhxtGetBdNsrxx", { sfzhm });
} catch {
bdNsrxx = null;
}
return { bsyxx, smzcj, bdNsrxx };
}
export async function getTodayEnterprises(ticketId: string): Promise<TaxerEnterprise[]> {
const tktId = ticketId.trim();
if (!tktId) {
return [];
}
const session = await getSession();
const baseUrl =
typeof session.ziyunServiceUrl === "string" && session.ziyunServiceUrl.trim() !== ""
? session.ziyunServiceUrl.trim()
: MOCK_BASE_URL;
const url = `${baseUrl.replace(/\/$/, "")}/agentInfo/today-enterprises/${encodeURIComponent(tktId)}`;
const response = await axios.get(url, { timeout: 10000 });
const payload = response.data as { code?: number; data?: Array<Record<string, unknown>> };
if (payload.code !== 200 || !Array.isArray(payload.data)) {
return [];
}
return payload.data.map((item) => ({
serialNumber: String(item.serialNumber ?? ""),
enterpriseName: String(item.serviceTarget ?? ""),
taxpayerId: String(item.serviceTargetCode ?? ""),
}));
}

@ -1,8 +1,9 @@
import { emit, listen, type Event } from "@tauri-apps/api/event"; import { emit, listen, type Event } from "@tauri-apps/api/event";
import type { TicketActionPayload } from "./types"; import type { TaxerTicketContextPayload, TicketActionPayload } from "./types";
const MAIN_TICKET_EVENT = "main:ticket-action"; const MAIN_TICKET_EVENT = "main:ticket-action";
const TICKET_MAIN_EVENT = "ticket:main-action"; const TICKET_MAIN_EVENT = "ticket:main-action";
const TAXER_TICKET_CONTEXT_EVENT = "taxer:ticket-context";
/** /**
* *
@ -69,3 +70,29 @@ export async function listenMainAction(
throw new Error(`订阅主窗口事件失败: ${String(error)}`); throw new Error(`订阅主窗口事件失败: ${String(error)}`);
} }
} }
/**
* 广
*/
export async function emitTaxerTicketContext(payload: TaxerTicketContextPayload): Promise<void> {
try {
await emit(TAXER_TICKET_CONTEXT_EVENT, payload);
} catch (error) {
throw new Error(`发送办税员票号上下文失败: ${String(error)}`);
}
}
/**
*
*/
export async function listenTaxerTicketContext(
handler: (payload: TaxerTicketContextPayload) => void,
): Promise<() => void> {
try {
return await listen<TaxerTicketContextPayload>(TAXER_TICKET_CONTEXT_EVENT, (event) => {
handler(event.payload ?? {});
});
} catch (error) {
throw new Error(`订阅办税员票号上下文失败: ${String(error)}`);
}
}

@ -1,12 +1,37 @@
import { invoke } from "@tauri-apps/api/tauri"; import { invoke } from "@tauri-apps/api/tauri";
import type { LogLevel } from "./types"; import type { LogLevel } from "./types";
function extractCallerFromStack(): string | null {
const stack = new Error().stack;
if (!stack) {
return null;
}
const lines = stack.split("\n").map((line) => line.trim());
for (const line of lines) {
if (line.includes("logger.ts")) {
continue;
}
const match =
line.match(/(src\/[^)\s]+:\d+:\d+)/) ??
line.match(/([A-Za-z]:\\[^)\s]+:\d+:\d+)/);
if (match?.[1]) {
return match[1].replace(/\\/g, "/");
}
}
return null;
}
/** /**
* Rust * Rust
*/ */
export async function log(level: LogLevel, message: string): Promise<void> { export async function log(level: LogLevel, message: string): Promise<void> {
try { try {
await invoke("app_log", { level, message }); const caller = extractCallerFromStack();
const enrichedMessage = caller ? `${message} [source=${caller}]` : message;
await invoke("app_log", { level, message: enrichedMessage });
} catch (error) { } catch (error) {
throw new Error(`写入日志失败: ${String(error)}`); throw new Error(`写入日志失败: ${String(error)}`);
} }

@ -6,19 +6,122 @@ const DEFAULT_SESSION: SessionState = {
winUid: null, winUid: null,
queueToken: null, queueToken: null,
refreshToken: null, refreshToken: null,
windowName: null,
empName: null,
autoCallEnabled: null,
autoCallWaitSeconds: null,
autoStartEnabled: null,
autoStartWaitSeconds: null,
callInHistoryEnabled: null,
callNotifyForward: null,
callNotifyMode: null,
callNotifySmsUrl: null,
callTransferEnabled: null,
realnameCheckApi: null,
rankWaitSeconds: null,
ziyunServiceUrl: null,
taxer_compare_code: null,
taxer_ticket_id: null,
taxer_use_doc_fields: null,
taxer_uid: null,
taxer_sid: null,
taxer_ver: null,
taxer_kz: null,
}; };
function normalizeSession(raw: unknown): SessionState { function normalizeSession(raw: unknown): SessionState {
const source = (raw ?? {}) as Partial<SessionState>; const source = (raw ?? {}) as Partial<SessionState>;
const empUid = const empUid =
typeof source.empUid === "number" && Number.isFinite(source.empUid) ? source.empUid : null; typeof source.empUid === "number" && Number.isFinite(source.empUid)
? source.empUid
: null;
const winUid = const winUid =
typeof source.winUid === "number" && Number.isFinite(source.winUid) ? source.winUid : null; typeof source.winUid === "number" && Number.isFinite(source.winUid)
const queueToken = typeof source.queueToken === "string" ? source.queueToken : null; ? source.winUid
const refreshToken = typeof source.refreshToken === "string" ? source.refreshToken : null; : null;
const queueToken =
typeof source.queueToken === "string" ? source.queueToken : null;
const refreshToken =
typeof source.refreshToken === "string" ? source.refreshToken : null;
const windowName = typeof source.windowName === "string" ? source.windowName : null;
const empName = typeof source.empName === "string" ? source.empName : null;
const autoCallEnabled =
typeof source.autoCallEnabled === "boolean" ? source.autoCallEnabled : null;
const autoCallWaitSeconds =
typeof source.autoCallWaitSeconds === "number" && Number.isFinite(source.autoCallWaitSeconds)
? source.autoCallWaitSeconds
: null;
const autoStartEnabled =
typeof source.autoStartEnabled === "boolean" ? source.autoStartEnabled : null;
const autoStartWaitSeconds =
typeof source.autoStartWaitSeconds === "number" && Number.isFinite(source.autoStartWaitSeconds)
? source.autoStartWaitSeconds
: null;
const callInHistoryEnabled =
typeof source.callInHistoryEnabled === "boolean" ? source.callInHistoryEnabled : null;
const callNotifyForward =
typeof source.callNotifyForward === "number" && Number.isFinite(source.callNotifyForward)
? source.callNotifyForward
: null;
const callNotifyMode =
typeof source.callNotifyMode === "number" && Number.isFinite(source.callNotifyMode)
? source.callNotifyMode
: null;
const callNotifySmsUrl =
typeof source.callNotifySmsUrl === "string" ? source.callNotifySmsUrl : null;
const callTransferEnabled =
typeof source.callTransferEnabled === "boolean" ? source.callTransferEnabled : null;
const realnameCheckApi =
typeof source.realnameCheckApi === "string" ? source.realnameCheckApi : null;
const rankWaitSeconds =
typeof source.rankWaitSeconds === "number" && Number.isFinite(source.rankWaitSeconds)
? source.rankWaitSeconds
: null;
const ziyunServiceUrl =
typeof source.ziyunServiceUrl === "string" ? source.ziyunServiceUrl : null;
const taxer_compare_code =
typeof source.taxer_compare_code === "string"
? source.taxer_compare_code
: null;
const taxer_ticket_id =
typeof source.taxer_ticket_id === "string" ? source.taxer_ticket_id : null;
const taxer_use_doc_fields =
typeof source.taxer_use_doc_fields === "boolean"
? source.taxer_use_doc_fields
: null;
const taxer_uid = typeof source.taxer_uid === "string" ? source.taxer_uid : null;
const taxer_sid = typeof source.taxer_sid === "string" ? source.taxer_sid : null;
const taxer_ver = typeof source.taxer_ver === "string" ? source.taxer_ver : null;
const taxer_kz = typeof source.taxer_kz === "string" ? source.taxer_kz : null;
return { empUid, winUid, queueToken, refreshToken }; return {
empUid,
winUid,
queueToken,
refreshToken,
windowName,
empName,
autoCallEnabled,
autoCallWaitSeconds,
autoStartEnabled,
autoStartWaitSeconds,
callInHistoryEnabled,
callNotifyForward,
callNotifyMode,
callNotifySmsUrl,
callTransferEnabled,
realnameCheckApi,
rankWaitSeconds,
ziyunServiceUrl,
taxer_compare_code,
taxer_ticket_id,
taxer_use_doc_fields,
taxer_uid,
taxer_sid,
taxer_ver,
taxer_kz,
};
} }
/** /**

@ -0,0 +1,35 @@
import { invoke } from "@tauri-apps/api/tauri";
type StartScreenSyncPayload = {
notifyBaseUrl: string;
streamUrl: string;
display?: string;
frameRate?: number;
};
type StopScreenSyncPayload = {
notifyBaseUrl?: string;
streamUrl?: string;
};
/**
* FFmpeg +
*/
export async function startScreenSync(payload: StartScreenSyncPayload): Promise<void> {
try {
await invoke("start_screen_sync", { payload });
} catch (error) {
throw new Error(`启动同步主屏失败: ${String(error)}`);
}
}
/**
* FFmpeg +
*/
export async function stopScreenSync(payload?: StopScreenSyncPayload): Promise<void> {
try {
await invoke("stop_screen_sync", { payload });
} catch (error) {
throw new Error(`停止同步主屏失败: ${String(error)}`);
}
}

@ -7,6 +7,27 @@ export interface SessionState {
winUid: number | null; winUid: number | null;
queueToken: string | null; queueToken: string | null;
refreshToken: string | null; refreshToken: string | null;
windowName?: string | null;
empName?: string | null;
autoCallEnabled?: boolean | null;
autoCallWaitSeconds?: number | null;
autoStartEnabled?: boolean | null;
autoStartWaitSeconds?: number | null;
callInHistoryEnabled?: boolean | null;
callNotifyForward?: number | null;
callNotifyMode?: number | null;
callNotifySmsUrl?: string | null;
callTransferEnabled?: boolean | null;
realnameCheckApi?: string | null;
rankWaitSeconds?: number | null;
ziyunServiceUrl?: string | null;
taxer_compare_code?: string | null;
taxer_ticket_id?: string | null;
taxer_use_doc_fields?: boolean | null;
taxer_uid?: string | null;
taxer_sid?: string | null;
taxer_ver?: string | null;
taxer_kz?: string | null;
} }
export interface NativeConfirmOptions { export interface NativeConfirmOptions {
@ -22,6 +43,11 @@ export interface TicketActionPayload {
tktNum?: string; tktNum?: string;
} }
export interface TaxerTicketContextPayload {
ticketNo?: string;
sfzhm?: string;
}
export type AppConfig = Record<string, JsonValue>; export type AppConfig = Record<string, JsonValue>;
export type LogLevel = "debug" | "info" | "warn" | "error"; export type LogLevel = "debug" | "info" | "warn" | "error";

@ -56,6 +56,17 @@ export async function closeTicketListWindow(): Promise<void> {
} }
} }
/**
*
*/
export async function closeTaxerInfoWindow(): Promise<void> {
try {
await invoke("close_taxer_info_window");
} catch (error) {
throw new Error(`关闭办税员窗口失败: ${String(error)}`);
}
}
/** /**
* *
*/ */
@ -89,6 +100,17 @@ export async function openLoginWindow(): Promise<void> {
} }
} }
/**
*
*/
export async function openTaxerInfoWindow(): Promise<void> {
try {
await invoke("open_taxer_info_window");
} catch (error) {
throw new Error(`打开办税员窗口失败: ${String(error)}`);
}
}
/** /**
* 退 * 退
*/ */

@ -27,9 +27,7 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
* *
*/ */
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const app = createApp(App); // 先应用服务地址,再挂载应用,避免首屏阶段请求拿到空 baseURL。
app.use(router).mount("#app");
try { try {
const config = await withTimeout(getAllConfig(), 1500); const config = await withTimeout(getAllConfig(), 1500);
const serverIp = typeof config.server_ip === "string" ? config.server_ip.trim() : ""; const serverIp = typeof config.server_ip === "string" ? config.server_ip.trim() : "";
@ -39,6 +37,9 @@ async function bootstrap(): Promise<void> {
} catch { } catch {
// 配置缺失时保持默认空地址,由路由守卫引导到设置页。 // 配置缺失时保持默认空地址,由路由守卫引导到设置页。
} }
const app = createApp(App);
app.use(router).mount("#app");
} }
void bootstrap(); void bootstrap();

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

@ -188,6 +188,39 @@ async function handleWindowLogin(): Promise<void> {
sessionState.winUid = winUid; sessionState.winUid = winUid;
await setSession(sessionState); await setSession(sessionState);
// caller-init data session
try {
const callerInit = await api.action.callerInit();
sessionState = {
...sessionState,
winUid: Number(callerInit.windowUid ?? winUid),
empUid: Number(callerInit.empUid ?? sessionState.empUid ?? -1),
windowName: String(callerInit.windowName ?? ""),
empName: String(callerInit.empName ?? ""),
autoCallEnabled: Boolean(callerInit.autoCallEnabled),
autoCallWaitSeconds: Number(callerInit.autoCallWaitSeconds ?? 0),
autoStartEnabled: Boolean(callerInit.autoStartEnabled),
autoStartWaitSeconds: Number(callerInit.autoStartWaitSeconds ?? 0),
callInHistoryEnabled: Boolean(callerInit.callInHistoryEnabled),
callNotifyForward: Number(callerInit.callNotifyForward ?? 0),
callNotifyMode: Number(callerInit.callNotifyMode ?? 0),
callNotifySmsUrl: String(callerInit.callNotifySmsUrl ?? ""),
callTransferEnabled: Boolean(callerInit.callTransferEnabled),
realnameCheckApi: String(callerInit.realnameCheckApi ?? ""),
rankWaitSeconds: Number(callerInit.rankWaitSeconds ?? 0),
ziyunServiceUrl: String(callerInit.ziyunServiceUrl ?? ""),
};
await setSession(sessionState);
await log(
"info",
`caller-init 缓存完成: windowUid=${sessionState.winUid}, empUid=${sessionState.empUid}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await log("warn", `caller-init 调用失败,继续使用本地会话: ${message}`);
}
await openMainWindow(); await openMainWindow();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);

@ -3,6 +3,7 @@ import {
Back, Back,
CircleCheck, CircleCheck,
Close, Close,
DataBoard,
Delete, Delete,
ForkSpoon, ForkSpoon,
HotWater, HotWater,
@ -21,13 +22,16 @@ import { appWindow } from "@tauri-apps/api/window";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { api } from "../api"; import { api } from "../api";
import { getAllConfig } from "../host/config";
import { confirmNative, showErrorNative } from "../host/dialog"; import { confirmNative, showErrorNative } from "../host/dialog";
import { listenMainAction } from "../host/events"; import { emitTaxerTicketContext, listenMainAction } from "../host/events";
import { log } from "../host/logger"; import { log } from "../host/logger";
import { getSession } from "../host/session"; import { getSession } from "../host/session";
import { startScreenSync, stopScreenSync } from "../host/sync";
import type { SessionState } from "../host/types"; import type { SessionState } from "../host/types";
import { import {
minimizeWindow, minimizeWindow,
openTaxerInfoWindow,
openTicketListWindow, openTicketListWindow,
quitApplication, quitApplication,
} from "../host/window"; } from "../host/window";
@ -71,13 +75,85 @@ const EVALUATING_COUNTDOWN_SEC = 15;
const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"]; const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"];
const isMainWindowActive = ref(false); const isMainWindowActive = ref(false);
const buttonPanel = ref<"main" | "more" | "pause">("main"); const buttonPanel = ref<"main" | "more" | "pause">("main");
const isSyncingMainScreen = ref(false);
const isActionPending = ref(false);
/** /**
* 记录错误日志 * 记录错误日志
*/ */
type ErrorLike = {
message?: unknown;
code?: unknown;
response?: {
status?: unknown;
data?: unknown;
};
request?: unknown;
};
function getErrorDetail(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function classifyErrorMessage(error: unknown): string {
const raw = error as ErrorLike;
const detail = getErrorDetail(error);
const normalized = detail.toLowerCase();
const code = typeof raw?.code === "string" ? raw.code : "";
const status =
typeof raw?.response?.status === "number"
? raw.response.status
: Number(raw?.response?.status ?? NaN);
if (
code === "ECONNABORTED" ||
normalized.includes("timeout") ||
normalized.includes("exceeded")
) {
return "接口请求超时10秒请检查服务状态或网络";
}
if (code === "ERR_NETWORK" || normalized.includes("network error")) {
return "网络连接失败,请检查服务器地址和网络连通性";
}
if (Number.isFinite(status)) {
if (status >= 500) {
return `服务端异常HTTP ${status}),请稍后重试`;
}
if (status === 401 || status === 403) {
return `鉴权失败HTTP ${status}),请重新登录`;
}
return `请求失败HTTP ${status}),请确认参数后重试`;
}
if (raw?.request && !raw?.response) {
return "未收到服务响应,请检查服务是否启动";
}
return "操作失败,请稍后重试";
}
async function logErr(context: string, error: unknown): Promise<void> { async function logErr(context: string, error: unknown): Promise<void> {
const message = error instanceof Error ? error.message : String(error); const friendlyMessage = classifyErrorMessage(error);
await log("error", `${context}: ${message}`); const detail = getErrorDetail(error);
updateLog(`${context}${friendlyMessage}`);
await log("error", `${context}: ${friendlyMessage}; detail=${detail}`);
}
async function runWithPending<T>(task: () => Promise<T>): Promise<T> {
if (isActionPending.value) {
throw new Error("操作进行中,请稍候");
}
isActionPending.value = true;
try {
return await task();
} finally {
isActionPending.value = false;
}
} }
/** /**
@ -111,6 +187,11 @@ function getActionTicketNo(res: unknown): string {
return String(getActionData(res).ticketNo ?? ""); return String(getActionData(res).ticketNo ?? "");
} }
function getActionSfzhm(res: unknown): string {
const data = getActionData(res) as Record<string, unknown>;
return String(data.sfzhm ?? data.idCard ?? "").trim();
}
/** /**
* 提取票据 UID * 提取票据 UID
*/ */
@ -130,6 +211,71 @@ function parseOptionalNumber(value: unknown): number | null {
return null; return null;
} }
function parseServerInputToOrigin(serverIp: string): string {
const raw = serverIp.trim();
if (!raw) {
return "";
}
if (raw.startsWith("http://") || raw.startsWith("https://")) {
try {
const url = new URL(raw);
return `${url.protocol}//${url.host}`;
} catch {
return "";
}
}
const hasPort = /:\d+$/.test(raw) || /^\[.+\]:\d+$/.test(raw);
return hasPort ? `http://${raw}` : `http://${raw}:8845`;
}
function parseServerInputToHost(serverIp: string): string {
const raw = serverIp.trim();
if (!raw) {
return "";
}
if (raw.startsWith("http://") || raw.startsWith("https://")) {
try {
return new URL(raw).hostname;
} catch {
return "";
}
}
const withProtocol = raw.startsWith("[") ? `http://${raw}` : `http://${raw}`;
try {
return new URL(withProtocol).hostname;
} catch {
const withoutPort = raw.replace(/:\d+$/, "");
return withoutPort;
}
}
async function resolveSyncEndpoints(): Promise<{
notifyBaseUrl: string;
streamUrl: string;
}> {
const config = await getAllConfig();
const serverIp =
typeof config.server_ip === "string" ? config.server_ip.trim() : "";
if (!serverIp) {
throw new Error("未配置服务器地址,请先到服务地址页完成设置");
}
const notifyBaseUrl = parseServerInputToOrigin(serverIp);
const host = parseServerInputToHost(serverIp);
if (!notifyBaseUrl || !host) {
throw new Error("服务器地址格式不正确,无法启动同步");
}
return {
notifyBaseUrl,
streamUrl: `rtmp://${host}/live/desktop`,
};
}
/** /**
* 清理评价轮询 * 清理评价轮询
*/ */
@ -185,7 +331,17 @@ function startEvaluatingCountdown(prefixText: string): void {
left -= 1; left -= 1;
if (left <= 0) { if (left <= 0) {
clearEvaluatingCountdown(); clearEvaluatingCountdown();
// isRank
clearIsRankPolling();
if (callStatus.value === "evaluating") {
callStatus.value = "idle";
callBtnText.value = "呼叫";
callingTkt.value = -1;
message.value = "欢迎使用紫云呼叫终端";
void log("info", "评价倒计时结束,已停止 isRank 轮询并进入待机");
} else {
message.value = evaluatingPrefixText.value; message.value = evaluatingPrefixText.value;
}
return; return;
} }
applyMessage(); applyMessage();
@ -220,10 +376,6 @@ async function pollIsRankOnce(): Promise<void> {
await log("info", "isRank: 评价完成,进入待机"); await log("info", "isRank: 评价完成,进入待机");
return; return;
} }
if (evaluatingCountdownTimer === null) {
message.value = `${evaluatingPrefixText.value}(等待评价完成...`;
}
} catch (error) { } catch (error) {
await logErr("查询 isRank 失败", error); await logErr("查询 isRank 失败", error);
} finally { } finally {
@ -395,7 +547,11 @@ async function openTicketList(): Promise<void> {
* 发起呼叫或重呼 * 发起呼叫或重呼
*/ */
async function callAction(): Promise<void> { async function callAction(): Promise<void> {
if (isActionPending.value) {
return;
}
try { try {
await runWithPending(async () => {
const windowUid = Number(sessionState.value.winUid ?? -1); const windowUid = Number(sessionState.value.winUid ?? -1);
const empUid = Number(sessionState.value.empUid ?? -1); const empUid = Number(sessionState.value.empUid ?? -1);
const ticketUid = callingTkt.value > 0 ? callingTkt.value : null; const ticketUid = callingTkt.value > 0 ? callingTkt.value : null;
@ -409,6 +565,10 @@ async function callAction(): Promise<void> {
if (isActionSuccess(recallRes)) { if (isActionSuccess(recallRes)) {
updateLog(`已重呼:${getActionTicketNo(recallRes)},请勿重复点击!`); updateLog(`已重呼:${getActionTicketNo(recallRes)},请勿重复点击!`);
await log("info", `重呼成功: ticketNo=${getActionTicketNo(recallRes)}`); await log("info", `重呼成功: ticketNo=${getActionTicketNo(recallRes)}`);
await emitTaxerTicketContext({
ticketNo: getActionTicketNo(recallRes),
sfzhm: getActionSfzhm(recallRes),
});
} else { } else {
updateLog(getActionMessage(recallRes)); updateLog(getActionMessage(recallRes));
} }
@ -425,10 +585,15 @@ async function callAction(): Promise<void> {
"info", "info",
`呼叫成功: ticketNo=${getActionTicketNo(res)}, ticketUid=${getActionTicketUid(res)}`, `呼叫成功: ticketNo=${getActionTicketNo(res)}, ticketUid=${getActionTicketUid(res)}`,
); );
await emitTaxerTicketContext({
ticketNo: getActionTicketNo(res),
sfzhm: getActionSfzhm(res),
});
return; return;
} }
updateLog(getActionMessage(res)); updateLog(getActionMessage(res));
});
} catch (error) { } catch (error) {
await logErr("呼叫失败", error); await logErr("呼叫失败", error);
} }
@ -438,7 +603,11 @@ async function callAction(): Promise<void> {
* 发起弃号 * 发起弃号
*/ */
async function abandonAction(): Promise<void> { async function abandonAction(): Promise<void> {
if (isActionPending.value) {
return;
}
try { try {
await runWithPending(async () => {
const res = await api.action.abandon({ const res = await api.action.abandon({
windowUid: Number(sessionState.value.winUid ?? -1), windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1), empUid: Number(sessionState.value.empUid ?? -1),
@ -454,6 +623,7 @@ async function abandonAction(): Promise<void> {
} }
updateLog(`弃号未成功: ${getActionMessage(res) || "unknown"}`); updateLog(`弃号未成功: ${getActionMessage(res) || "unknown"}`);
});
} catch (error) { } catch (error) {
await logErr("弃号失败", error); await logErr("弃号失败", error);
} }
@ -463,7 +633,11 @@ async function abandonAction(): Promise<void> {
* 开始办理 * 开始办理
*/ */
async function startAction(): Promise<void> { async function startAction(): Promise<void> {
if (isActionPending.value) {
return;
}
try { try {
await runWithPending(async () => {
const res = await api.action.start({ const res = await api.action.start({
windowUid: Number(sessionState.value.winUid ?? -1), windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1), empUid: Number(sessionState.value.empUid ?? -1),
@ -474,6 +648,7 @@ async function startAction(): Promise<void> {
callStatus.value = "working"; callStatus.value = "working";
updateLog(`正在办理:${getActionTicketNo(res)}`); updateLog(`正在办理:${getActionTicketNo(res)}`);
} }
});
} catch (error) { } catch (error) {
await logErr("开始办理失败", error); await logErr("开始办理失败", error);
} }
@ -483,7 +658,11 @@ async function startAction(): Promise<void> {
* 完成办理并进入评价状态 * 完成办理并进入评价状态
*/ */
async function completeAction(): Promise<void> { async function completeAction(): Promise<void> {
if (isActionPending.value) {
return;
}
try { try {
await runWithPending(async () => {
const res = await api.action.complete({ const res = await api.action.complete({
windowUid: Number(sessionState.value.winUid ?? -1), windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1), empUid: Number(sessionState.value.empUid ?? -1),
@ -497,6 +676,7 @@ async function completeAction(): Promise<void> {
callStatus.value = "evaluating"; callStatus.value = "evaluating";
callBtnText.value = "呼叫"; callBtnText.value = "呼叫";
startEvaluatingCountdown(`办理完成:${getActionTicketNo(res)},评价中`); startEvaluatingCountdown(`办理完成:${getActionTicketNo(res)},评价中`);
});
} catch (error) { } catch (error) {
await logErr("完成办理失败", error); await logErr("完成办理失败", error);
} }
@ -506,6 +686,7 @@ async function completeAction(): Promise<void> {
* 发起评价接口调用 * 发起评价接口调用
*/ */
async function invokeEvaluateApi(): Promise<void> { async function invokeEvaluateApi(): Promise<void> {
await runWithPending(async () => {
const res = await api.action.evaluate({ const res = await api.action.evaluate({
windowUid: Number(sessionState.value.winUid ?? -1), windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1), empUid: Number(sessionState.value.empUid ?? -1),
@ -517,12 +698,16 @@ async function invokeEvaluateApi(): Promise<void> {
callBtnText.value = "呼叫"; callBtnText.value = "呼叫";
startEvaluatingCountdown("评价中"); startEvaluatingCountdown("评价中");
} }
});
} }
/** /**
* 发起评价或二次评价 * 发起评价或二次评价
*/ */
async function evaluateAction(): Promise<void> { async function evaluateAction(): Promise<void> {
if (isActionPending.value) {
return;
}
if (callStatus.value === "evaluating") { if (callStatus.value === "evaluating") {
const confirmed = await confirmNative({ const confirmed = await confirmNative({
title: "提示", title: "提示",
@ -546,12 +731,16 @@ async function evaluateAction(): Promise<void> {
* 暂停或恢复 * 暂停或恢复
*/ */
async function pauseAction(): Promise<void> { async function pauseAction(): Promise<void> {
if (isActionPending.value) {
return;
}
try { try {
if (callStatus.value !== "paused") { if (callStatus.value !== "paused") {
buttonPanel.value = "pause"; buttonPanel.value = "pause";
return; return;
} }
await runWithPending(async () => {
const res = await api.action.resume({ const res = await api.action.resume({
windowUid: Number(sessionState.value.winUid ?? -1), windowUid: Number(sessionState.value.winUid ?? -1),
empUid: Number(sessionState.value.empUid ?? -1), empUid: Number(sessionState.value.empUid ?? -1),
@ -562,6 +751,7 @@ async function pauseAction(): Promise<void> {
pauseBtnText.value = "暂停"; pauseBtnText.value = "暂停";
updateLog("已恢复待机"); updateLog("已恢复待机");
} }
});
} catch (error) { } catch (error) {
await logErr("暂停/恢复流程异常", error); await logErr("暂停/恢复流程异常", error);
} }
@ -571,7 +761,11 @@ async function pauseAction(): Promise<void> {
* 确认暂停原因并发起暂停 * 确认暂停原因并发起暂停
*/ */
async function confirmPauseReason(reason: string): Promise<void> { async function confirmPauseReason(reason: string): Promise<void> {
if (isActionPending.value) {
return;
}
try { try {
await runWithPending(async () => {
const pauseReason = reason.trim(); const pauseReason = reason.trim();
if (!pauseReason) { if (!pauseReason) {
ElMessage.warning("请选择暂停原因"); ElMessage.warning("请选择暂停原因");
@ -593,6 +787,7 @@ async function confirmPauseReason(reason: string): Promise<void> {
} }
updateLog(`暂停未成功: ${getActionMessage(res) || "unknown"}`); updateLog(`暂停未成功: ${getActionMessage(res) || "unknown"}`);
});
} catch (error) { } catch (error) {
await logErr("暂停失败", error); await logErr("暂停失败", error);
} finally { } finally {
@ -604,7 +799,7 @@ async function confirmPauseReason(reason: string): Promise<void> {
* 处理动作按钮点击 * 处理动作按钮点击
*/ */
function handleButtonClick(button: ActionButton): void { function handleButtonClick(button: ActionButton): void {
if (!button.enabled) { if (!button.enabled || isActionPending.value) {
return; return;
} }
@ -662,25 +857,67 @@ function getPauseReasonIcon(reason: string) {
* 处理顶部更多菜单动作 * 处理顶部更多菜单动作
*/ */
async function handleMoreCommand( async function handleMoreCommand(
command: "main" | "ticketList" | "logout", command: "main" | "ticketList" | "syncMainScreen" | "logout",
): Promise<void> { ): Promise<void> {
if (isActionPending.value) {
return;
}
try { try {
if (command === "main") { if (command === "main") {
updateLog("当前已在办税员窗口"); const hasActiveTicket =
callingTkt.value > 0 &&
["calling", "working", "evaluating"].includes(callStatus.value);
if (!hasActiveTicket) {
const tip = "当前没有票号正在办理,请先呼叫";
updateLog(tip);
await showErrorNative(tip);
return;
}
await runWithPending(async () => {
updateLog("正在打开办税员窗口...");
await log("info", "more.main: start open taxer info window");
await openTaxerInfoWindow();
await log("info", "more.main: taxer info window opened");
updateLog("办税员窗口已打开");
buttonPanel.value = "main"; buttonPanel.value = "main";
});
return; return;
} }
if (command === "ticketList") { if (command === "ticketList") {
await runWithPending(async () => {
updateLog("正在打开票号列表..."); updateLog("正在打开票号列表...");
// //
await minimizeWindow(); await minimizeWindow();
await openTicketList(); await openTicketList();
updateLog("票号列表已打开"); updateLog("票号列表已打开");
buttonPanel.value = "main"; buttonPanel.value = "main";
});
return; return;
} }
if (command === "syncMainScreen") {
await runWithPending(async () => {
if (!isSyncingMainScreen.value) {
const { notifyBaseUrl, streamUrl } = await resolveSyncEndpoints();
await startScreenSync({
notifyBaseUrl,
streamUrl,
display: ":0.0",
frameRate: 15,
});
isSyncingMainScreen.value = true;
updateLog("已开启同步主屏");
} else {
await stopScreenSync();
isSyncingMainScreen.value = false;
updateLog("已结束同步主屏");
}
});
return;
}
await runWithPending(async () => {
const confirmed = await confirmNative({ const confirmed = await confirmNative({
title: "提示", title: "提示",
message: "确认退出程序吗?", message: "确认退出程序吗?",
@ -693,12 +930,13 @@ async function handleMoreCommand(
} }
await quitApplication(); await quitApplication();
});
} catch (error) { } catch (error) {
await logErr(`更多菜单处理失败: ${command}`, error); await logErr(`更多菜单处理失败: ${command}`, error);
await showErrorNative("操作失败,请查看日志"); await showErrorNative("操作失败,请查看日志");
updateLog(`操作失败: ${command}`); updateLog(`操作失败: ${command}`);
} finally { } finally {
if (command !== "logout") { if (command !== "logout" && command !== "syncMainScreen") {
buttonPanel.value = "main"; buttonPanel.value = "main";
} }
} }
@ -759,6 +997,11 @@ onMounted(async () => {
}); });
onUnmounted(() => { onUnmounted(() => {
if (isSyncingMainScreen.value) {
void stopScreenSync().catch(() => {
// Rust
});
}
clearEvaluatingCountdown(); clearEvaluatingCountdown();
clearIsRankPolling(); clearIsRankPolling();
clearQueueCountPolling(); clearQueueCountPolling();
@ -779,6 +1022,8 @@ onUnmounted(() => {
<button <button
class="action-button action-button-menu" class="action-button action-button-menu"
type="button" type="button"
:disabled="isActionPending"
:class="{ disabled: isActionPending }"
@click="openMoreContextMenu" @click="openMoreContextMenu"
> >
<el-icon class="button-icon"> <el-icon class="button-icon">
@ -792,13 +1037,14 @@ onUnmounted(() => {
type="button" type="button"
class="action-button" class="action-button"
:data-action="btn.action" :data-action="btn.action"
:class="{ disabled: !btn.enabled }" :disabled="!btn.enabled || isActionPending"
:style="{ color: !btn.enabled ? '#ccc' : textColor }" :class="{ disabled: !btn.enabled || isActionPending }"
:style="{ color: !btn.enabled || isActionPending ? '#ccc' : textColor }"
@click="handleButtonClick(btn)" @click="handleButtonClick(btn)"
> >
<el-icon <el-icon
class="button-icon" class="button-icon"
:style="{ color: !btn.enabled ? '#ccc' : iconColor }" :style="{ color: !btn.enabled || isActionPending ? '#ccc' : iconColor }"
> >
<component :is="btn.icon" /> <component :is="btn.icon" />
</el-icon> </el-icon>
@ -806,20 +1052,28 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div v-else-if="buttonPanel === 'more'" key="more" class="panel-content"> <div
v-else-if="buttonPanel === 'more'"
key="more"
class="panel-content"
>
<button <button
class="action-button action-button-panel" class="action-button action-button-panel"
type="button" type="button"
:disabled="isActionPending"
:class="{ disabled: isActionPending }"
@click="handleMoreCommand('main')" @click="handleMoreCommand('main')"
> >
<el-icon class="button-icon"> <el-icon class="button-icon">
<component :is="User" /> <component :is="User" />
</el-icon> </el-icon>
<span class="button-label">办税员窗口</span> <span class="button-label">办税员</span>
</button> </button>
<button <button
class="action-button action-button-panel" class="action-button action-button-panel"
type="button" type="button"
:disabled="isActionPending"
:class="{ disabled: isActionPending }"
@click="handleMoreCommand('ticketList')" @click="handleMoreCommand('ticketList')"
> >
<el-icon class="button-icon"> <el-icon class="button-icon">
@ -830,6 +1084,22 @@ onUnmounted(() => {
<button <button
class="action-button action-button-panel" class="action-button action-button-panel"
type="button" type="button"
:disabled="isActionPending"
:class="{ disabled: isActionPending }"
@click="handleMoreCommand('syncMainScreen')"
>
<el-icon class="button-icon">
<component :is="DataBoard" />
</el-icon>
<span class="button-label">{{
isSyncingMainScreen ? "结束同步" : "同步主屏"
}}</span>
</button>
<button
class="action-button action-button-panel"
type="button"
:disabled="isActionPending"
:class="{ disabled: isActionPending }"
@click="handleMoreCommand('logout')" @click="handleMoreCommand('logout')"
> >
<el-icon class="button-icon"> <el-icon class="button-icon">
@ -840,6 +1110,8 @@ onUnmounted(() => {
<button <button
class="action-button action-button-panel action-button-back" class="action-button action-button-panel action-button-back"
type="button" type="button"
:disabled="isActionPending"
:class="{ disabled: isActionPending }"
@click="backToMainPanel" @click="backToMainPanel"
> >
<el-icon class="button-icon"> <el-icon class="button-icon">
@ -855,6 +1127,8 @@ onUnmounted(() => {
:key="reason" :key="reason"
class="action-button action-button-panel" class="action-button action-button-panel"
type="button" type="button"
:disabled="isActionPending"
:class="{ disabled: isActionPending }"
@click="confirmPauseReason(reason)" @click="confirmPauseReason(reason)"
> >
<el-icon v-if="getPauseReasonIcon(reason)" class="button-icon"> <el-icon v-if="getPauseReasonIcon(reason)" class="button-icon">
@ -865,6 +1139,8 @@ onUnmounted(() => {
<button <button
class="action-button action-button-panel action-button-back" class="action-button action-button-panel action-button-back"
type="button" type="button"
:disabled="isActionPending"
:class="{ disabled: isActionPending }"
@click="backToMainPanel" @click="backToMainPanel"
> >
<el-icon class="button-icon"> <el-icon class="button-icon">
@ -893,11 +1169,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
line-height: 1; line-height: 1;
font-family: font-family:
"Microsoft YaHei", "Microsoft YaHei", "Noto Sans CJK SC", "PingFang SC", "Segoe UI", sans-serif;
"Noto Sans CJK SC",
"PingFang SC",
"Segoe UI",
sans-serif;
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
rgba(53, 64, 94, 0.96) 0%, rgba(53, 64, 94, 0.96) 0%,

@ -0,0 +1,504 @@
<script setup lang="ts">
import { Close, Minus } from "@element-plus/icons-vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { getTodayEnterprises, loadTaxerProfile, type TaxerEnterprise } from "../api/taxer";
import { listenTaxerTicketContext } from "../host/events";
import { log } from "../host/logger";
import { getSession, setSession } from "../host/session";
import type { SessionState } from "../host/types";
import { closeTaxerInfoWindow, minimizeWindow } from "../host/window";
const loading = ref(true);
const enterprises = ref<TaxerEnterprise[]>([]);
const compareCode = ref("");
const currentTicketId = ref("");
const taxNoLookup = ref<Record<string, string>>({});
const realnameHint = ref("");
let unlistenTaxerTicketContext: (() => void) | null = null;
const profile = ref({
name: "办税员",
phone: "",
idCard: "",
gender: "",
nation: "",
address: "",
collectStatus: "未查询",
memo: "",
});
const sessionState = ref<SessionState>({
empUid: null,
winUid: null,
queueToken: null,
refreshToken: null,
});
const canLoadProfile = computed(() => compareCode.value.trim() !== "");
async function safeLog(level: "warn" | "error" | "info", message: string): Promise<void> {
try {
await log(level, message);
} catch {
//
}
}
async function saveTaxerRuntimeToSession(): Promise<void> {
try {
const session = await getSession();
const nextSession: SessionState = {
...session,
taxer_compare_code: compareCode.value.trim() || null,
taxer_ticket_id: currentTicketId.value.trim() || null,
};
sessionState.value = await setSession(nextSession);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await safeLog("warn", `写入办税员运行参数失败: ${message}`);
}
}
function getTaxNoByDjxh(djxh: string): string {
const value = taxNoLookup.value[djxh];
return value ?? "";
}
async function loadProfileByCompareCode(): Promise<void> {
const qhhm = compareCode.value.trim();
if (!qhhm) {
await safeLog("warn", "办税员实名加载取消: compareCode 为空");
return;
}
await saveTaxerRuntimeToSession();
const realnameApiBase =
typeof sessionState.value.realnameCheckApi === "string" ? sessionState.value.realnameCheckApi.trim() : "";
const realnameApiUrl = realnameApiBase
? realnameApiBase.includes("/taxCommon/doService")
? realnameApiBase
: `${realnameApiBase.replace(/\/$/, "")}/taxCommon/doService`
: "http://127.0.0.1:4523/m1/8201806-7961256-default/taxCommon/doService";
await safeLog(
"info",
`办税员实名请求: url=${realnameApiUrl}, qhhm=${qhhm}, ywId=smz.jhxtGetBsyxxByqhhm`,
);
loading.value = true;
try {
const { bsyxx, smzcj, bdNsrxx } = await loadTaxerProfile(qhhm);
taxNoLookup.value = {};
(bdNsrxx?.dataList ?? []).forEach((item) => {
const djxh = String(item.djxh ?? "");
if (djxh) {
taxNoLookup.value[djxh] = String(item.nsrsbh ?? "");
}
});
profile.value = {
name: String(bsyxx.xm ?? "办税员"),
phone: String(smzcj?.sjhm ?? bsyxx.sjhm ?? ""),
idCard: String(bsyxx.sfzhm ?? ""),
gender: bsyxx.xb === "1" ? "男" : bsyxx.xb === "2" ? "女" : String(bsyxx.xb ?? ""),
nation: String(bsyxx.mz ?? ""),
address: String(bsyxx.zz ?? ""),
collectStatus: smzcj?.code === "00" ? "已采集" : "未采集",
memo: String(smzcj?.bz ?? bsyxx.bsyly ?? ""),
};
if (!String(bsyxx.sfzhm ?? "").trim()) {
realnameHint.value = "该票号未实名";
profile.value.collectStatus = "该票号未实名";
} else {
realnameHint.value = "";
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await safeLog("error", `加载实名信息失败: ${message}`);
} finally {
loading.value = false;
}
}
async function applyTicketContextFromCall(payload: { ticketNo?: string; sfzhm?: string }): Promise<void> {
const ticketNo = String(payload.ticketNo ?? "").trim();
const sfzhm = String(payload.sfzhm ?? "").trim();
if (!ticketNo) {
return;
}
compareCode.value = ticketNo;
currentTicketId.value = ticketNo;
await saveTaxerRuntimeToSession();
realnameHint.value = "";
profile.value.idCard = sfzhm;
await safeLog("info", `TaxerInfo 收到票号上下文: ticketNo=${ticketNo}, hasSfzhm=${sfzhm ? "yes" : "no"}`);
await loadEnterprisesByTicketId();
await loadProfileByCompareCode();
}
async function loadEnterprisesByTicketId(): Promise<void> {
const ticketId = currentTicketId.value.trim();
if (!ticketId) {
await safeLog("warn", "加载企业取消: ticketId 为空");
return;
}
await saveTaxerRuntimeToSession();
const baseUrl =
typeof sessionState.value.ziyunServiceUrl === "string" &&
sessionState.value.ziyunServiceUrl.trim() !== ""
? sessionState.value.ziyunServiceUrl.trim()
: "http://127.0.0.1:4523/m1/8201806-7961256-default";
const enterpriseUrl = `${baseUrl.replace(/\/$/, "")}/agentInfo/today-enterprises/${encodeURIComponent(ticketId)}`;
await safeLog("info", `办税员企业清单请求: url=${enterpriseUrl}`);
loading.value = true;
try {
enterprises.value = await getTodayEnterprises(ticketId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await safeLog("error", `加载企业失败: ${message}`);
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const session = await getSession();
const defaultCompareCode =
typeof session.taxer_compare_code === "string" ? session.taxer_compare_code.trim() : "";
const defaultTicketId =
typeof session.taxer_ticket_id === "string" ? session.taxer_ticket_id.trim() : "";
compareCode.value = defaultCompareCode;
currentTicketId.value = defaultTicketId;
sessionState.value = session;
if (compareCode.value) {
void loadProfileByCompareCode();
}
if (currentTicketId.value) {
void loadEnterprisesByTicketId();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await safeLog("error", `办税员窗口初始化失败: ${message}`);
} finally {
loading.value = false;
}
try {
unlistenTaxerTicketContext = await listenTaxerTicketContext((payload) => {
void applyTicketContextFromCall(payload);
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await safeLog("error", `订阅票号上下文失败: ${message}`);
}
});
onUnmounted(() => {
if (unlistenTaxerTicketContext) {
unlistenTaxerTicketContext();
}
});
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);
await safeLog("error", `最小化窗口失败: ${message}`);
}
}
async function handleCloseClick(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
try {
await closeTaxerInfoWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await safeLog("error", `关闭办税员窗口失败: ${message}`);
}
}
</script>
<template>
<div class="taxer-root">
<div class="taxer-header">
<div class="taxer-header-title" data-tauri-drag-region @dblclick.prevent.stop>办税员窗口</div>
<div class="taxer-header-actions">
<button
class="control-button"
type="button"
@mousedown.stop
@click="handleMinimizeClick"
>
<el-icon class="control-icon">
<Minus />
</el-icon>
</button>
<button
class="control-button"
type="button"
@mousedown.stop
@dblclick.prevent.stop
@click="handleCloseClick"
>
<el-icon class="control-icon">
<Close />
</el-icon>
</button>
</div>
</div>
<div class="content">
<div class="left-pane">
<div class="card profile-card">
<div class="card-title">办税员实名信息</div>
<div class="realname-alert" :class="{ inactive: !realnameHint }">
{{ realnameHint || "实名状态正常" }}
</div>
<div class="toolbar">
<el-input
v-model="compareCode"
size="small"
clearable
placeholder="输入 compareCode取号号"
/>
<el-button
size="small"
type="primary"
:disabled="!canLoadProfile"
:loading="loading"
@click="loadProfileByCompareCode"
>
加载实名
</el-button>
</div>
<div class="line"><span>姓名</span>{{ profile.name }}</div>
<div class="line"><span>性别</span>{{ profile.gender || "-" }}</div>
<div class="line"><span>民族</span>{{ profile.nation || "-" }}</div>
<div class="line"><span>手机号</span>{{ profile.phone || "-" }}</div>
<div class="line"><span>身份证</span>{{ profile.idCard || "-" }}</div>
<div class="line"><span>地址</span>{{ profile.address || "-" }}</div>
<div class="line"><span>采集状态</span>{{ profile.collectStatus }}</div>
<div class="line"><span>备注</span>{{ profile.memo || "-" }}</div>
</div>
</div>
<div class="right-pane">
<div class="card enterprise-card">
<div class="card-title">代理人办理企业清单</div>
<div class="hint">当前票号{{ currentTicketId || "-" }}</div>
<div class="hint">窗口{{ sessionState.winUid ?? "-" }}员工{{ sessionState.empUid ?? "-" }}</div>
<div v-if="loading" class="hint">...</div>
<div v-else-if="enterprises.length === 0" class="hint">
暂无数据
</div>
<div v-else class="enterprise-list">
<div v-for="item in enterprises" :key="item.serialNumber || item.taxpayerId" class="enterprise-item">
<div class="enterprise-main">
<span class="name">{{ item.enterpriseName || "-" }}</span>
<span class="tax">{{ item.taxpayerId || "-" }}</span>
</div>
<div class="line">序号{{ item.serialNumber || "-" }}</div>
<div class="line">税号映射{{ getTaxNoByDjxh(item.taxpayerId) || "-" }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.taxer-root {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: #111827;
color: #e5e7eb;
}
.taxer-header {
width: 100%;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px 0 12px;
background: #1f2937;
color: #e5e7eb;
}
.taxer-header-title {
flex: 1;
height: 100%;
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
}
.taxer-header-actions {
display: flex;
align-items: center;
}
.control-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
-webkit-app-region: no-drag;
border: none;
color: #e5e7eb;
background: transparent;
cursor: pointer;
}
.control-button:hover {
background: rgba(255, 255, 255, 0.12);
}
.control-icon {
font-size: 18px;
}
.content {
flex: 1;
min-height: 0;
display: flex;
gap: 10px;
padding: 10px;
}
.left-pane {
width: 360px;
display: flex;
flex-direction: column;
gap: 10px;
}
.right-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
background: #1f2937;
border-radius: 8px;
padding: 12px;
}
.profile-card {
flex: 0 0 auto;
}
.realname-alert {
min-height: 30px;
display: flex;
align-items: center;
padding: 0 10px;
margin-bottom: 10px;
border-radius: 6px;
border: 1px solid #f59e0b;
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
font-size: 13px;
font-weight: 600;
}
.realname-alert.inactive {
border-color: transparent;
background: transparent;
color: transparent;
}
.enterprise-card {
flex: 1;
min-height: 0;
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 10px;
}
.line {
margin-bottom: 8px;
font-size: 13px;
}
.line span {
color: #9ca3af;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.hint {
display: flex;
align-items: center;
justify-content: flex-start;
color: #9ca3af;
font-size: 14px;
}
.empty {
color: #9ca3af;
font-size: 13px;
padding: 8px 2px;
}
.enterprise-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 100%;
overflow: auto;
}
.enterprise-item {
border: 1px solid #374151;
border-radius: 6px;
padding: 8px 10px;
background: #111827;
}
.enterprise-main {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.enterprise-main .name {
font-weight: 600;
}
.enterprise-main .tax {
color: #93c5fd;
}
</style>
Loading…
Cancel
Save