diff --git a/call-client/.trae/rules.md b/call-client/.trae/rules.md index e836fa7..051c519 100644 --- a/call-client/.trae/rules.md +++ b/call-client/.trae/rules.md @@ -9,14 +9,44 @@ ## 代码规范 - 函数名用 camelcase,组件名用 Pascalcase +- 常量:UPPER_SNAKE_CASE +- 文件名:一般 kebab-case 或与默认导出组件同名 PascalCase +- 布尔值变量以 is / has / should 开头,如 isLoading - 所有函数必须有 JSDoc 注释 - 错误处理必须用 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 类型 - 不写裸 console.log,用统一的 logger - css不用内联样式 +- 禁止混用 Tab,使用 2 个空格(或 4 个,项目统一即可) ## 提交规范 diff --git a/call-client/TaxerInfo-tauri-v1-migration.md b/call-client/TaxerInfo-tauri-v1-migration.md new file mode 100644 index 0000000..b71fa73 --- /dev/null +++ b/call-client/TaxerInfo-tauri-v1-migration.md @@ -0,0 +1,537 @@ +# TaxerInfo 模块改写为 Tauri v1(单窗口 + Vue)设计文档 + +## 1. 文档目标 + +将 `CallClient/WPF/TaxerInfo.xaml.cs` + `CallClient/WPF/TaxerInfo.xaml` 对应功能迁移到 **Tauri v1 + Vue(单窗口)** 架构,确保: + +- 业务行为与现有 WPF 一致(取号、开始办理、办结、实名信息、企业列表、网页加载、复制、查询)。 +- 桌面能力由 Tauri(Rust)提供,界面由 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) -> ()` + +- 对应原 `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` + +调用: + +- `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/IPC),Tauri 调该服务; +- 等功能回归稳定后再考虑 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 主流程中打开办税员窗口”,又不会破坏现有呼叫台功能。 diff --git a/call-client/postman/taxer-9.4-mock.postman_collection.json b/call-client/postman/taxer-9.4-mock.postman_collection.json new file mode 100644 index 0000000..82af7de --- /dev/null +++ b/call-client/postman/taxer-9.4-mock.postman_collection.json @@ -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}" + } + ] + } + ] +} diff --git a/call-client/postman/taxer-9.4-openapi-apifox.json b/call-client/postman/taxer-9.4-openapi-apifox.json new file mode 100644 index 0000000..d6a96ae --- /dev/null +++ b/call-client/postman/taxer-9.4-openapi-apifox.json @@ -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" + } + } + } + } + } +} diff --git a/call-client/src-tauri/Cargo.lock b/call-client/src-tauri/Cargo.lock index 0795e8e..ee1b1e7 100644 --- a/call-client/src-tauri/Cargo.lock +++ b/call-client/src-tauri/Cargo.lock @@ -394,7 +394,9 @@ dependencies = [ name = "call-client" version = "0.1.1" dependencies = [ + "chrono", "fs2", + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -1103,6 +1105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1326,8 +1329,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "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]] @@ -1338,7 +1357,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1514,7 +1533,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", "indexmap 2.13.0", "slab", "tokio", @@ -1612,6 +1631,16 @@ dependencies = [ "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]] name = "http-body" version = "0.4.6" @@ -1619,7 +1648,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "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", ] @@ -1652,8 +1704,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa 1.0.18", @@ -1665,6 +1717,42 @@ dependencies = [ "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]] name = "hyper-tls" version = "0.5.0" @@ -1672,12 +1760,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.32", "native-tls", "tokio", "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]] name = "iana-time-zone" version = "0.1.65" @@ -1915,6 +2026,16 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "itoa" version = "0.4.8" @@ -2112,6 +2233,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -3114,6 +3241,61 @@ dependencies = [ "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]] name = "quote" version = "1.0.45" @@ -3123,6 +3305,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -3154,6 +3342,16 @@ dependencies = [ "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]] name = "rand_chacha" version = "0.2.2" @@ -3174,6 +3372,16 @@ dependencies = [ "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]] name = "rand_core" version = "0.5.1" @@ -3192,6 +3400,15 @@ dependencies = [ "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]] name = "rand_hc" version = "0.2.0" @@ -3306,9 +3523,9 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", - "hyper", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", "hyper-tls", "ipnet", "js-sys", @@ -3322,7 +3539,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", @@ -3336,6 +3553,46 @@ dependencies = [ "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]] name = "rfd" version = "0.10.0" @@ -3360,6 +3617,26 @@ dependencies = [ "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]] name = "rustc_version" version = "0.4.1" @@ -3382,6 +3659,20 @@ dependencies = [ "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]] name = "rustls-pemfile" version = "1.0.4" @@ -3391,6 +3682,27 @@ dependencies = [ "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]] name = "rustversion" version = "1.0.22" @@ -3841,6 +4153,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -3869,6 +4187,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "synstructure" version = "0.13.2" @@ -4031,7 +4358,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 0.2.12", "ignore", "indexmap 1.9.3", "log", @@ -4047,7 +4374,7 @@ dependencies = [ "rand 0.8.5", "raw-window-handle", "regex", - "reqwest", + "reqwest 0.11.27", "rfd", "semver", "serde", @@ -4138,7 +4465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8066855882f00172935e3fa7d945126580c34dcbabab43f5d4f0c2398a67d47b" dependencies = [ "gtk", - "http", + "http 0.2.12", "http-range", "rand 0.8.5", "raw-window-handle", @@ -4359,6 +4686,21 @@ dependencies = [ "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]] name = "tokio" version = "1.50.0" @@ -4383,6 +4725,16 @@ dependencies = [ "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]] name = "tokio-util" version = "0.7.18" @@ -4494,6 +4846,45 @@ dependencies = [ "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]] name = "tower-service" version = "0.3.3" @@ -4613,6 +5004,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4931,6 +5328,16 @@ dependencies = [ "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]] name = "webkit2gtk" version = "0.18.2" @@ -4978,6 +5385,15 @@ dependencies = [ "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]] name = "webview2-com" version = "0.19.1" @@ -5793,7 +6209,7 @@ dependencies = [ "glib", "gtk", "html5ever", - "http", + "http 0.2.12", "kuchikiki", "libc", "log", @@ -5987,6 +6403,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/call-client/src-tauri/Cargo.toml b/call-client/src-tauri/Cargo.toml index 83afcdc..591bb52 100644 --- a/call-client/src-tauri/Cargo.toml +++ b/call-client/src-tauri/Cargo.toml @@ -26,4 +26,6 @@ tauri = { version = "1", features = ["api-all"] } serde = { version = "1", features = ["derive"] } serde_json = "1" fs2 = "0.4" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +chrono = { version = "0.4", default-features = false, features = ["clock"] } diff --git a/call-client/src-tauri/src/commands/logger.rs b/call-client/src-tauri/src/commands/logger.rs index 0bac898..c3e48cd 100644 --- a/call-client/src-tauri/src/commands/logger.rs +++ b/call-client/src-tauri/src/commands/logger.rs @@ -5,6 +5,7 @@ use std::{ path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}, }; +use chrono::{DateTime, Local}; const APP_NAME: &str = "com.ziyun.callclient"; 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)?; rotate_log_if_needed(&path)?; - let timestamp = SystemTime::now() + let now = SystemTime::now(); + let unix = now .duration_since(UNIX_EPOCH) - .map_err(|error| format!("生成日志时间失败: {error}"))? - .as_secs(); + .map_err(|error| format!("生成日志时间失败: {error}"))?; + let ts_secs = unix.as_secs(); + let ts_millis = unix.as_millis(); + let local_time: DateTime = 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() .create(true) @@ -117,6 +123,10 @@ pub fn app_log(level: String, message: String) -> Result<(), String> { .open(&path) .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}")) } diff --git a/call-client/src-tauri/src/commands/mod.rs b/call-client/src-tauri/src/commands/mod.rs index 4eb5e49..38ed756 100644 --- a/call-client/src-tauri/src/commands/mod.rs +++ b/call-client/src-tauri/src/commands/mod.rs @@ -2,5 +2,6 @@ pub mod config; pub mod events; pub mod logger; pub mod session; +pub mod sync; pub mod update; pub mod window; diff --git a/call-client/src-tauri/src/commands/sync.rs b/call-client/src-tauri/src/commands/sync.rs new file mode 100644 index 0000000..fcfae3c --- /dev/null +++ b/call-client/src-tauri/src/commands/sync.rs @@ -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, + pub frame_rate: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StopScreenSyncPayload { + pub notify_base_url: Option, + pub stream_url: Option, +} + +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, + 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(¬ify_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, + payload: Option, +) -> 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(()) +} diff --git a/call-client/src-tauri/src/commands/window.rs b/call-client/src-tauri/src/commands/window.rs index bf41ec0..8b23fa9 100644 --- a/call-client/src-tauri/src/commands/window.rs +++ b/call-client/src-tauri/src/commands/window.rs @@ -1,4 +1,7 @@ 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> { if app.get_window("main").is_some() { @@ -35,6 +38,28 @@ pub fn ensure_login_window(app: AppHandle) -> Result<(), String> { 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] pub fn open_ticket_window(app: AppHandle) -> Result<(), String> { if let Some(window) = app.get_window("ticketList") { @@ -84,6 +109,17 @@ pub fn close_ticket_window(app: AppHandle) -> Result<(), String> { 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] pub fn focus_window(app: AppHandle, label: String) -> Result<(), String> { 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] -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) { + cleanup_screen_sync(&state); app.exit(0); } diff --git a/call-client/src-tauri/src/lib.rs b/call-client/src-tauri/src/lib.rs index c9654b4..780f251 100644 --- a/call-client/src-tauri/src/lib.rs +++ b/call-client/src-tauri/src/lib.rs @@ -11,10 +11,11 @@ use commands::{ events::{emit_to_window, list_windows}, logger::app_log, session::{session_clear, session_get, session_set}, + sync::{start_screen_sync, stop_screen_sync}, update::check_apt_update, window::{ - close_ticket_window, ensure_main_window, focus_window, open_login_window, open_main_window, - open_ticket_window, quit_app, + close_taxer_info_window, close_ticket_window, ensure_main_window, focus_window, + open_login_window, open_main_window, open_taxer_info_window, open_ticket_window, quit_app, }, }; use fs2::FileExt; @@ -83,9 +84,13 @@ pub fn run() { check_apt_update, open_ticket_window, close_ticket_window, + close_taxer_info_window, focus_window, open_main_window, open_login_window, + open_taxer_info_window, + start_screen_sync, + stop_screen_sync, quit_app ]) .run(tauri::generate_context!()) diff --git a/call-client/src-tauri/src/state.rs b/call-client/src-tauri/src/state.rs index bf10bca..93f02c6 100644 --- a/call-client/src-tauri/src/state.rs +++ b/call-client/src-tauri/src/state.rs @@ -12,12 +12,21 @@ pub struct SessionState { pub struct AppState { pub session: Mutex, + pub screen_sync: Mutex, +} + +#[derive(Default)] +pub struct ScreenSyncState { + pub ffmpeg: Option, + pub notify_base_url: Option, + pub stream_url: Option, } impl Default for AppState { fn default() -> Self { Self { session: Mutex::new(SessionState::default()), + screen_sync: Mutex::new(ScreenSyncState::default()), } } } diff --git a/call-client/src-tauri/tauri.conf.json b/call-client/src-tauri/tauri.conf.json index c5d8fb0..0650345 100644 --- a/call-client/src-tauri/tauri.conf.json +++ b/call-client/src-tauri/tauri.conf.json @@ -42,6 +42,17 @@ "maximizable": false, "decorations": false, "alwaysOnTop": true + }, + { + "label": "taxerInfo", + "title": "办税员窗口", + "url": "/#/taxerInfo", + "width": 1200, + "height": 760, + "visible": false, + "resizable": true, + "decorations": false, + "alwaysOnTop": true } ], "security": { diff --git a/call-client/src/api/index.ts b/call-client/src/api/index.ts index da737c8..6cbc939 100644 --- a/call-client/src/api/index.ts +++ b/call-client/src/api/index.ts @@ -10,6 +10,25 @@ import type { UserRequest, UserResponse } from "../types/user"; import type { WindowResponse } from "../types/window"; 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 = { user: { login: (data: UserRequest) => http.post("/auth/login", data), @@ -33,5 +52,6 @@ export const api = { isRank: (params: IsRankRequest) => http.get("/call-terminal/is-rank", params), getQueueCount: (params: QueueCountRequest) => http.get("/call-terminal/queue-count", params), + callerInit: () => http.post("/call-terminal/caller-init", {}), }, }; diff --git a/call-client/src/api/taxer.ts b/call-client/src/api/taxer.ts new file mode 100644 index 0000000..340d9f6 --- /dev/null +++ b/call-client/src/api/taxer.ts @@ -0,0 +1,178 @@ +import axios from "axios"; +import { getSession } from "../host/session"; + +type JsonRecord = Record; +const MOCK_BASE_URL = "http://127.0.0.1:4523/m1/8201806-7961256-default"; + +type BaiShuiEnvelope = { + 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> { + 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(payload: unknown, ywId: string): T { + const body = (payload ?? {}) as BaiShuiEnvelope; + 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(ywId: string, params: JsonRecord): Promise { + 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(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("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("smz.getSmzcjxx", { sfzhm }); + } catch { + smzcj = null; + } + + try { + bdNsrxx = await postBaiShui("smz.jhxtGetBdNsrxx", { sfzhm }); + } catch { + bdNsrxx = null; + } + + return { bsyxx, smzcj, bdNsrxx }; +} + +export async function getTodayEnterprises(ticketId: string): Promise { + 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> }; + 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 ?? ""), + })); +} diff --git a/call-client/src/host/events.ts b/call-client/src/host/events.ts index 17ec780..5fada18 100644 --- a/call-client/src/host/events.ts +++ b/call-client/src/host/events.ts @@ -1,8 +1,9 @@ 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 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)}`); } } + +/** + * 向办税员窗口广播当前呼叫票号上下文。 + */ +export async function emitTaxerTicketContext(payload: TaxerTicketContextPayload): Promise { + 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(TAXER_TICKET_CONTEXT_EVENT, (event) => { + handler(event.payload ?? {}); + }); + } catch (error) { + throw new Error(`订阅办税员票号上下文失败: ${String(error)}`); + } +} diff --git a/call-client/src/host/logger.ts b/call-client/src/host/logger.ts index cd857d7..671934f 100644 --- a/call-client/src/host/logger.ts +++ b/call-client/src/host/logger.ts @@ -1,12 +1,37 @@ import { invoke } from "@tauri-apps/api/tauri"; 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 文件日志模块。 */ export async function log(level: LogLevel, message: string): Promise { 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) { throw new Error(`写入日志失败: ${String(error)}`); } diff --git a/call-client/src/host/session.ts b/call-client/src/host/session.ts index 1771008..da65fe8 100644 --- a/call-client/src/host/session.ts +++ b/call-client/src/host/session.ts @@ -6,19 +6,122 @@ const DEFAULT_SESSION: SessionState = { winUid: null, queueToken: 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 { const source = (raw ?? {}) as Partial; 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 = - typeof source.winUid === "number" && Number.isFinite(source.winUid) ? source.winUid : null; - const queueToken = typeof source.queueToken === "string" ? source.queueToken : null; - const refreshToken = typeof source.refreshToken === "string" ? source.refreshToken : null; + typeof source.winUid === "number" && Number.isFinite(source.winUid) + ? source.winUid + : 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, + }; } /** diff --git a/call-client/src/host/sync.ts b/call-client/src/host/sync.ts new file mode 100644 index 0000000..63c09a7 --- /dev/null +++ b/call-client/src/host/sync.ts @@ -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 { + try { + await invoke("start_screen_sync", { payload }); + } catch (error) { + throw new Error(`启动同步主屏失败: ${String(error)}`); + } +} + +/** + * 停止主屏同步(终止 FFmpeg + 通知后端服务)。 + */ +export async function stopScreenSync(payload?: StopScreenSyncPayload): Promise { + try { + await invoke("stop_screen_sync", { payload }); + } catch (error) { + throw new Error(`停止同步主屏失败: ${String(error)}`); + } +} diff --git a/call-client/src/host/types.ts b/call-client/src/host/types.ts index 921f57f..ce4ffa0 100644 --- a/call-client/src/host/types.ts +++ b/call-client/src/host/types.ts @@ -7,6 +7,27 @@ export interface SessionState { winUid: number | null; queueToken: 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 { @@ -22,6 +43,11 @@ export interface TicketActionPayload { tktNum?: string; } +export interface TaxerTicketContextPayload { + ticketNo?: string; + sfzhm?: string; +} + export type AppConfig = Record; export type LogLevel = "debug" | "info" | "warn" | "error"; diff --git a/call-client/src/host/window.ts b/call-client/src/host/window.ts index ec3cf0e..2030116 100644 --- a/call-client/src/host/window.ts +++ b/call-client/src/host/window.ts @@ -56,6 +56,17 @@ export async function closeTicketListWindow(): Promise { } } +/** + * 关闭(隐藏)办税员窗口。 + */ +export async function closeTaxerInfoWindow(): Promise { + try { + await invoke("close_taxer_info_window"); + } catch (error) { + throw new Error(`关闭办税员窗口失败: ${String(error)}`); + } +} + /** * 聚焦指定窗口。 */ @@ -89,6 +100,17 @@ export async function openLoginWindow(): Promise { } } +/** + * 打开或聚焦办税员窗口。 + */ +export async function openTaxerInfoWindow(): Promise { + try { + await invoke("open_taxer_info_window"); + } catch (error) { + throw new Error(`打开办税员窗口失败: ${String(error)}`); + } +} + /** * 退出整个应用进程。 */ diff --git a/call-client/src/main.ts b/call-client/src/main.ts index ffbc613..bff585a 100644 --- a/call-client/src/main.ts +++ b/call-client/src/main.ts @@ -27,9 +27,7 @@ function withTimeout(promise: Promise, timeoutMs: number): Promise { * 按配置初始化前端运行环境。 */ async function bootstrap(): Promise { - const app = createApp(App); - app.use(router).mount("#app"); - + // 先应用服务地址,再挂载应用,避免首屏阶段请求拿到空 baseURL。 try { const config = await withTimeout(getAllConfig(), 1500); const serverIp = typeof config.server_ip === "string" ? config.server_ip.trim() : ""; @@ -39,6 +37,9 @@ async function bootstrap(): Promise { } catch { // 配置缺失时保持默认空地址,由路由守卫引导到设置页。 } + + const app = createApp(App); + app.use(router).mount("#app"); } void bootstrap(); diff --git a/call-client/src/router/index.ts b/call-client/src/router/index.ts index 334304c..f95b84c 100644 --- a/call-client/src/router/index.ts +++ b/call-client/src/router/index.ts @@ -1,11 +1,13 @@ import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router"; import { getAllConfig } from "../host/config"; import type { AppConfig } from "../host/types"; +import { applyServerIpToHttp } from "../utils/service"; import TicketListView from "../views/TicketListView.vue"; const LoginView = () => import("../views/LoginView.vue"); const MainView = () => import("../views/MainView.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: "/login", name: "login", component: LoginView }, { path: "/main", name: "main", component: MainView }, + { path: "/taxerInfo", name: "taxerInfo", component: TaxerInfoView }, { path: "/ticketList", name: "ticketList", component: TicketListView }, // 避免在窗口初始 URL/hash 不匹配时出现空白页面 { path: "/:pathMatch(.*)*", redirect: "/ticketList" }, @@ -32,7 +35,7 @@ export const router = createRouter({ router.beforeEach(async (to, _from, next) => { // 票号列表窗口由主窗口显式打开,优先保证路由可渲染,避免守卫异步阻塞导致白屏。 - if (to.path === "/ticketList") { + if (to.path === "/ticketList" || to.path === "/taxerInfo") { next(); return; } @@ -41,6 +44,9 @@ router.beforeEach(async (to, _from, next) => { try { const config = await getAllConfig(); ip = getServerIpFromConfig(config); + if (ip) { + applyServerIpToHttp(ip); + } } catch (error) { // 如果当前窗口没有权限/IPC 失败,避免导航被中断导致页面空白 console.error("[router] getAllConfig failed, skip guard:", error); diff --git a/call-client/src/views/LoginView.vue b/call-client/src/views/LoginView.vue index 5b03385..95187b7 100644 --- a/call-client/src/views/LoginView.vue +++ b/call-client/src/views/LoginView.vue @@ -188,6 +188,39 @@ async function handleWindowLogin(): Promise { sessionState.winUid = winUid; 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(); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/call-client/src/views/MainView.vue b/call-client/src/views/MainView.vue index 30a707b..a5fb2c2 100644 --- a/call-client/src/views/MainView.vue +++ b/call-client/src/views/MainView.vue @@ -3,6 +3,7 @@ import { Back, CircleCheck, Close, + DataBoard, Delete, ForkSpoon, HotWater, @@ -21,13 +22,16 @@ import { appWindow } from "@tauri-apps/api/window"; import { ElMessage } from "element-plus"; import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { api } from "../api"; +import { getAllConfig } from "../host/config"; import { confirmNative, showErrorNative } from "../host/dialog"; -import { listenMainAction } from "../host/events"; +import { emitTaxerTicketContext, listenMainAction } from "../host/events"; import { log } from "../host/logger"; import { getSession } from "../host/session"; +import { startScreenSync, stopScreenSync } from "../host/sync"; import type { SessionState } from "../host/types"; import { minimizeWindow, + openTaxerInfoWindow, openTicketListWindow, quitApplication, } from "../host/window"; @@ -71,13 +75,85 @@ const EVALUATING_COUNTDOWN_SEC = 15; const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"]; const isMainWindowActive = ref(false); 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 { - const message = error instanceof Error ? error.message : String(error); - await log("error", `${context}: ${message}`); + const friendlyMessage = classifyErrorMessage(error); + const detail = getErrorDetail(error); + updateLog(`${context}:${friendlyMessage}`); + await log("error", `${context}: ${friendlyMessage}; detail=${detail}`); +} + +async function runWithPending(task: () => Promise): Promise { + 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 ?? ""); } +function getActionSfzhm(res: unknown): string { + const data = getActionData(res) as Record; + return String(data.sfzhm ?? data.idCard ?? "").trim(); +} + /** * 提取票据 UID。 */ @@ -130,6 +211,71 @@ function parseOptionalNumber(value: unknown): number | 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; if (left <= 0) { clearEvaluatingCountdown(); - message.value = evaluatingPrefixText.value; + // 倒计时结束:停止 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; + } return; } applyMessage(); @@ -220,10 +376,6 @@ async function pollIsRankOnce(): Promise { await log("info", "isRank: 评价完成,进入待机"); return; } - - if (evaluatingCountdownTimer === null) { - message.value = `${evaluatingPrefixText.value}(等待评价完成...)`; - } } catch (error) { await logErr("查询 isRank 失败", error); } finally { @@ -395,40 +547,53 @@ async function openTicketList(): Promise { * 发起呼叫或重呼。 */ async function callAction(): Promise { + if (isActionPending.value) { + return; + } try { - const windowUid = Number(sessionState.value.winUid ?? -1); - const empUid = Number(sessionState.value.empUid ?? -1); - const ticketUid = callingTkt.value > 0 ? callingTkt.value : null; - - if (callStatus.value === "calling" && callingTkt.value > 0) { - const recallRes = await api.action.recall({ - windowUid, - empUid, - ticketUid: callingTkt.value, - }); - if (isActionSuccess(recallRes)) { - updateLog(`已重呼:${getActionTicketNo(recallRes)},请勿重复点击!`); - await log("info", `重呼成功: ticketNo=${getActionTicketNo(recallRes)}`); - } else { - updateLog(getActionMessage(recallRes)); + await runWithPending(async () => { + const windowUid = Number(sessionState.value.winUid ?? -1); + const empUid = Number(sessionState.value.empUid ?? -1); + const ticketUid = callingTkt.value > 0 ? callingTkt.value : null; + + if (callStatus.value === "calling" && callingTkt.value > 0) { + const recallRes = await api.action.recall({ + windowUid, + empUid, + ticketUid: callingTkt.value, + }); + if (isActionSuccess(recallRes)) { + updateLog(`已重呼:${getActionTicketNo(recallRes)},请勿重复点击!`); + await log("info", `重呼成功: ticketNo=${getActionTicketNo(recallRes)}`); + await emitTaxerTicketContext({ + ticketNo: getActionTicketNo(recallRes), + sfzhm: getActionSfzhm(recallRes), + }); + } else { + updateLog(getActionMessage(recallRes)); + } + return; } - return; - } - const res = await api.action.call({ windowUid, empUid, ticketUid }); - if (isActionSuccess(res)) { - callStatus.value = "calling"; - callBtnText.value = "重呼"; - callingTkt.value = getActionTicketUid(res); - updateLog(`正在呼叫:${getActionTicketNo(res)}`); - await log( - "info", - `呼叫成功: ticketNo=${getActionTicketNo(res)}, ticketUid=${getActionTicketUid(res)}`, - ); - return; - } + const res = await api.action.call({ windowUid, empUid, ticketUid }); + if (isActionSuccess(res)) { + callStatus.value = "calling"; + callBtnText.value = "重呼"; + callingTkt.value = getActionTicketUid(res); + updateLog(`正在呼叫:${getActionTicketNo(res)}`); + await log( + "info", + `呼叫成功: ticketNo=${getActionTicketNo(res)}, ticketUid=${getActionTicketUid(res)}`, + ); + await emitTaxerTicketContext({ + ticketNo: getActionTicketNo(res), + sfzhm: getActionSfzhm(res), + }); + return; + } - updateLog(getActionMessage(res)); + updateLog(getActionMessage(res)); + }); } catch (error) { await logErr("呼叫失败", error); } @@ -438,22 +603,27 @@ async function callAction(): Promise { * 发起弃号。 */ async function abandonAction(): Promise { + if (isActionPending.value) { + return; + } try { - const res = await api.action.abandon({ - windowUid: Number(sessionState.value.winUid ?? -1), - empUid: Number(sessionState.value.empUid ?? -1), - ticketUid: callingTkt.value, - }); + await runWithPending(async () => { + const res = await api.action.abandon({ + windowUid: Number(sessionState.value.winUid ?? -1), + empUid: Number(sessionState.value.empUid ?? -1), + ticketUid: callingTkt.value, + }); - if (isActionSuccess(res)) { - callStatus.value = "idle"; - callBtnText.value = "呼叫"; - callingTkt.value = -1; - updateLog(`弃号成功: ${getActionTicketNo(res)}`); - return; - } + if (isActionSuccess(res)) { + callStatus.value = "idle"; + callBtnText.value = "呼叫"; + callingTkt.value = -1; + updateLog(`弃号成功: ${getActionTicketNo(res)}`); + return; + } - updateLog(`弃号未成功: ${getActionMessage(res) || "unknown"}`); + updateLog(`弃号未成功: ${getActionMessage(res) || "unknown"}`); + }); } catch (error) { await logErr("弃号失败", error); } @@ -463,17 +633,22 @@ async function abandonAction(): Promise { * 开始办理。 */ async function startAction(): Promise { + if (isActionPending.value) { + return; + } try { - const res = await api.action.start({ - windowUid: Number(sessionState.value.winUid ?? -1), - empUid: Number(sessionState.value.empUid ?? -1), - ticketUid: callingTkt.value, - }); + await runWithPending(async () => { + const res = await api.action.start({ + windowUid: Number(sessionState.value.winUid ?? -1), + empUid: Number(sessionState.value.empUid ?? -1), + ticketUid: callingTkt.value, + }); - if (isActionSuccess(res)) { - callStatus.value = "working"; - updateLog(`正在办理:${getActionTicketNo(res)}`); - } + if (isActionSuccess(res)) { + callStatus.value = "working"; + updateLog(`正在办理:${getActionTicketNo(res)}`); + } + }); } catch (error) { await logErr("开始办理失败", error); } @@ -483,20 +658,25 @@ async function startAction(): Promise { * 完成办理并进入评价状态。 */ async function completeAction(): Promise { + if (isActionPending.value) { + return; + } try { - const res = await api.action.complete({ - windowUid: Number(sessionState.value.winUid ?? -1), - empUid: Number(sessionState.value.empUid ?? -1), - ticketUid: callingTkt.value, - }); + await runWithPending(async () => { + const res = await api.action.complete({ + windowUid: Number(sessionState.value.winUid ?? -1), + empUid: Number(sessionState.value.empUid ?? -1), + ticketUid: callingTkt.value, + }); - if (!isActionSuccess(res)) { - return; - } + if (!isActionSuccess(res)) { + return; + } - callStatus.value = "evaluating"; - callBtnText.value = "呼叫"; - startEvaluatingCountdown(`办理完成:${getActionTicketNo(res)},评价中`); + callStatus.value = "evaluating"; + callBtnText.value = "呼叫"; + startEvaluatingCountdown(`办理完成:${getActionTicketNo(res)},评价中`); + }); } catch (error) { await logErr("完成办理失败", error); } @@ -506,23 +686,28 @@ async function completeAction(): Promise { * 发起评价接口调用。 */ async function invokeEvaluateApi(): Promise { - const res = await api.action.evaluate({ - windowUid: Number(sessionState.value.winUid ?? -1), - empUid: Number(sessionState.value.empUid ?? -1), - ticketUid: callingTkt.value, - }); + await runWithPending(async () => { + const res = await api.action.evaluate({ + windowUid: Number(sessionState.value.winUid ?? -1), + empUid: Number(sessionState.value.empUid ?? -1), + ticketUid: callingTkt.value, + }); - if (res.success) { - callStatus.value = "evaluating"; - callBtnText.value = "呼叫"; - startEvaluatingCountdown("评价中"); - } + if (res.success) { + callStatus.value = "evaluating"; + callBtnText.value = "呼叫"; + startEvaluatingCountdown("评价中"); + } + }); } /** * 发起评价或二次评价。 */ async function evaluateAction(): Promise { + if (isActionPending.value) { + return; + } if (callStatus.value === "evaluating") { const confirmed = await confirmNative({ title: "提示", @@ -546,22 +731,27 @@ async function evaluateAction(): Promise { * 暂停或恢复。 */ async function pauseAction(): Promise { + if (isActionPending.value) { + return; + } try { if (callStatus.value !== "paused") { buttonPanel.value = "pause"; return; } - const res = await api.action.resume({ - windowUid: Number(sessionState.value.winUid ?? -1), - empUid: Number(sessionState.value.empUid ?? -1), - }); + await runWithPending(async () => { + const res = await api.action.resume({ + windowUid: Number(sessionState.value.winUid ?? -1), + empUid: Number(sessionState.value.empUid ?? -1), + }); - if (isActionSuccess(res)) { - callStatus.value = "idle"; - pauseBtnText.value = "暂停"; - updateLog("已恢复待机"); - } + if (isActionSuccess(res)) { + callStatus.value = "idle"; + pauseBtnText.value = "暂停"; + updateLog("已恢复待机"); + } + }); } catch (error) { await logErr("暂停/恢复流程异常", error); } @@ -571,28 +761,33 @@ async function pauseAction(): Promise { * 确认暂停原因并发起暂停。 */ async function confirmPauseReason(reason: string): Promise { + if (isActionPending.value) { + return; + } try { - const pauseReason = reason.trim(); - if (!pauseReason) { - ElMessage.warning("请选择暂停原因"); - return; - } + await runWithPending(async () => { + const pauseReason = reason.trim(); + if (!pauseReason) { + ElMessage.warning("请选择暂停原因"); + return; + } - const res = await api.action.pause({ - windowUid: Number(sessionState.value.winUid ?? -1), - empUid: Number(sessionState.value.empUid ?? -1), - pauseReason, - }); + const res = await api.action.pause({ + windowUid: Number(sessionState.value.winUid ?? -1), + empUid: Number(sessionState.value.empUid ?? -1), + pauseReason, + }); - if (isActionSuccess(res)) { - callStatus.value = "paused"; - pauseBtnText.value = "恢复"; - updateLog(`暂停中,原因:${pauseReason}`); - buttonPanel.value = "main"; - return; - } + if (isActionSuccess(res)) { + callStatus.value = "paused"; + pauseBtnText.value = "恢复"; + updateLog(`暂停中,原因:${pauseReason}`); + buttonPanel.value = "main"; + return; + } - updateLog(`暂停未成功: ${getActionMessage(res) || "unknown"}`); + updateLog(`暂停未成功: ${getActionMessage(res) || "unknown"}`); + }); } catch (error) { await logErr("暂停失败", error); } finally { @@ -604,7 +799,7 @@ async function confirmPauseReason(reason: string): Promise { * 处理动作按钮点击。 */ function handleButtonClick(button: ActionButton): void { - if (!button.enabled) { + if (!button.enabled || isActionPending.value) { return; } @@ -662,43 +857,86 @@ function getPauseReasonIcon(reason: string) { * 处理顶部更多菜单动作。 */ async function handleMoreCommand( - command: "main" | "ticketList" | "logout", + command: "main" | "ticketList" | "syncMainScreen" | "logout", ): Promise { + if (isActionPending.value) { + return; + } try { if (command === "main") { - updateLog("当前已在办税员窗口"); - buttonPanel.value = "main"; + 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"; + }); return; } if (command === "ticketList") { - updateLog("正在打开票号列表..."); - // 主窗口在无边框模式下常驻置顶,先最小化主窗口避免票号列表被遮挡 - await minimizeWindow(); - await openTicketList(); - updateLog("票号列表已打开"); - buttonPanel.value = "main"; + await runWithPending(async () => { + updateLog("正在打开票号列表..."); + // 主窗口在无边框模式下常驻置顶,先最小化主窗口避免票号列表被遮挡 + await minimizeWindow(); + await openTicketList(); + updateLog("票号列表已打开"); + buttonPanel.value = "main"; + }); return; } - const confirmed = await confirmNative({ - title: "提示", - message: "确认退出程序吗?", - okLabel: "确认", - cancelLabel: "取消", - }); - - if (!confirmed) { + 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 quitApplication(); + await runWithPending(async () => { + const confirmed = await confirmNative({ + title: "提示", + message: "确认退出程序吗?", + okLabel: "确认", + cancelLabel: "取消", + }); + + if (!confirmed) { + return; + } + + await quitApplication(); + }); } catch (error) { await logErr(`更多菜单处理失败: ${command}`, error); await showErrorNative("操作失败,请查看日志"); updateLog(`操作失败: ${command}`); } finally { - if (command !== "logout") { + if (command !== "logout" && command !== "syncMainScreen") { buttonPanel.value = "main"; } } @@ -759,6 +997,11 @@ onMounted(async () => { }); onUnmounted(() => { + if (isSyncingMainScreen.value) { + void stopScreenSync().catch(() => { + // 关闭阶段由 Rust 侧兜底清理,这里静默处理前端卸载时序错误。 + }); + } clearEvaluatingCountdown(); clearIsRankPolling(); clearQueueCountPolling(); @@ -779,6 +1022,8 @@ onUnmounted(() => { -
+
+