diff --git a/call-client/TaxerInfo-tauri-v1-migration.md b/call-client/TaxerInfo-tauri-v1-migration.md index b71fa73..62dfca0 100644 --- a/call-client/TaxerInfo-tauri-v1-migration.md +++ b/call-client/TaxerInfo-tauri-v1-migration.md @@ -1,537 +1,100 @@ -# TaxerInfo 模块改写为 Tauri v1(单窗口 + Vue)设计文档 +# TaxerInfo 迁移说明文档(节选) -## 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` +> 说明:仓库中若曾存在更完整的迁移章节,可与本节合并。 +> **本节仅描述「纳税人信息」Tab 内嵌页面相关功能**(对应 `TaxerInfo.xaml` 中第一个 `TabItem` 及 `TaxerInfoViewModel` 中驱动 `WebBrowser` 的逻辑)。代理人企业清单等其余能力假定已在其他模块完成,此处不展开。 --- -## 4. 接口设计(Tauri Command + 业务 HTTP) - -以下为建议接口(前端调用 Rust command): - -## 4.1 初始化与加载 - -### `taxer_load_info(compareCode: String, currentTicketId: String) -> TaxerLoadInfoDto` +## 纳税人信息 Tab — 页面功能详细说明(Tauri 复刻用) -聚合返回: +**术语(文中 `t`)**:下文及源码参数中的 **`t` 均指 `ticket`**,即叫号/办税流程里的一条 **票据实体**(队列库 `ticket` 表对应的一行数据),与 **点击呼叫后进入办理环节所关联的票号数据** 为同一套模型。在办税员窗口内,传入 `Analize_URL` / `Load_BS_NSR_URL` 的 `t` 多为 **`CurrentTicket`(主票)** 或列表选中的 **`SelectedSubTicket`(当日关联子票)**;占位符里的 `t.TKT_ID`、`t.ENTERPRISE_ID` 等均来自该条 `ticket` 已落库字段,而非本 Tab 内单独请求计算。 -- 办税员实名信息(姓名、性别、身份证、地址、头像、身份证底图) -- 实名采集结果(手机号、备注、是否已采集) -- 绑定纳税人列表(用于税号映射) -- 当日票据列表(`TicketDto[]`) -- 默认选中票据 -- 代理人办理企业清单(`EnterpriseDto[]`) +### 1. 功能本质 -> 说明:WPF 中由 `BackgroundWorker + 多次接口请求 + 本地票据查询` 完成,迁移后建议统一为一个“聚合命令”,减少前端编排复杂度。 +- **不是**先调用第三方 JSON 接口再渲染自有 HTML 模板。 +- **是**在 Tab 内放置 **Web 视图**(WPF 为 `WebBrowser`),将配置项 **`BS_NSR_URL`** 与当前业务上下文拼成 **完整 HTTP(S) URL**,再 **`navigate` / 设置 `src`** 打开第三方页面。 +- 第三方站点负责展示「纳税人信息」;客户端只负责 **拼 URL、导航、刷新、加载态、办结后清空**。 -## 4.2 票据动作 +### 2. UI 与控件范围(仅限该 Tab) -### `taxer_start_ticket(ticketId: String, windowUid: i64, employeeUid: i64) -> TicketDto` +| 项目 | WPF 行为 | Tauri 对应建议 | +|------|-----------|----------------| +| 容器 | `TabItem Header="纳税人信息"` 内嵌 `WebBrowser Name="Browser"` | `webview`(Tauri Webview)或 `iframe`;需可设 URL、可 reload | +| 加载中 | `IsLoading == true` 时 `WebBrowser` `Visibility = Collapsed` | 加载中为 true 时隐藏 webview 或盖一层 loading,避免白块/半渲染 | +| 可见性 | 与 Tab 切换无关的特殊逻辑;随 Tab 切换自然显示/隐藏即可 | 与 Vue `el-tab-pane` 或路由子页一致 | -- 更新开始时间、状态(dealing)、窗口/员工绑定。 -- 返回更新后的票据数据。 +### 3. URL 来源与占位符替换 -### `taxer_end_ticket(ticketId: String) -> TicketDto` +- **模板来源**:`SystemControl.Get("BS_NSR_URL")`(运行时配置,非写死)。仓库内默认示例形态为带 query 的 URL,内含占位符。 +- **拼接入口**:`Analize_URL(string url, ticket t)`;`t == null` 时返回 **`about:blank`**(避免空引用;实际导航前仍应判票)。 +- **占位符与取值**(与现 C# 一致即可保证第三方页参数一致): -- 标记办结(对应原 `t.Stop(DateTime.Now)`)。 -- 返回更新后的票据数据。 +| 占位符 | 替换为 | +|--------|--------| +| `{djxh}` | `t.ENTERPRISE_ID`(登记序号/电局登记序号语境,与现字段同名) | +| `{sfzhm}` | 办税员身份证 `Card.Code`(来自先前置办的百税办税员信息;无 `Card` 则为空串) | +| `{sflb}` | `t.IS_ENTERPRISE == 1` → 字符串 `"1"`,否则 `"2"` | +| `{swjgDm}` | `SystemControl.Get("BS_TAX_AUTHORITY_NUM")` | +| `{qhhm}` | `t.TKT_ID`(票号/取号展示号,与现逻辑一致) | +| `{sjhm}` | ViewModel 的 `PhoneNo`(实名采集接口侧手机号,可为空) | -### `taxer_batch_end_or_abandon(ticketIds: Vec) -> ()` +- **空模板**:若 `BS_NSR_URL` 配置为空,除 `t==null` 外仍会 `Replace`,最终可能得到无效地址;复刻时建议对 **结果 URL** 做 `http/https` 校验,非法则不调导航并打日志。 -- 对应原 `End()` 的兜底逻辑(办理中停止、待办废弃)。 +### 4. 导航与「避免重复加载」 -## 4.3 URL 与外部查询 +- **方法名**:`Load_BS_NSR_URL(ticket t)`(在 UI 线程上执行:`Dispatcher.Invoke`)。 +- **步骤**: + 1. `url = Analize_URL(SystemControl.Get("BS_NSR_URL"), t)` + 2. 若当前浏览器已有 `Source`,且 **`Source.AbsoluteUri == url`**(字符串与规范化 URI 一致),则 **直接 return,不重复导航**(减轻第三方页重复初始化、闪烁)。 + 3. 否则设置 `Browser.Source = new Uri(url)`(或等价 `navigate(url)`)。 -### `taxer_build_nsr_url(ticketId: String, phoneNo: String, compareCode: String) -> String` +Tauri 实现时:在设置 `src` 前比较 **规范化后的当前 URL 与目标 URL**;一致则跳过。 -根据模板参数替换生成 URL(等价 `Analize_URL`): +### 5. 何时触发加载(与 Tab 内页相关的触发点) -- `{djxh}` `ENTERPRISE_ID` -- `{sfzhm}` 身份证号 -- `{sflb}` 企业/个人标识 -- `{swjgDm}` 税务机关代码 -- `{qhhm}` 取号号码 -- `{sjhm}` 手机号 +以下情形会调用 `Load_BS_NSR_URL`(或等价逻辑),**仅列与内嵌页相关的**: -### `taxer_build_yhscx_url(ticketId: String) -> String` +1. **`CurrentTicket` setter**(且 `Browser != null`):当新票 **非企业**(`IS_ENTERPRISE != 1`)时,对主票加载纳税人 URL。企业业务时左侧列表仍可能通过子票切换加载,见下。 +2. **`SelectedSubTicket` setter**:选中子票变化时,在更新 `CuttentCompany` 文案后 **始终** `Load_BS_NSR_URL(value)`。 +3. **`StartCommand`**:开始办理后,若存在 `Browser`,对命令参数票或回落 `CurrentTicket` 再 `Load_BS_NSR_URL`。 +4. **后台加载完成 `loadWorker_RunWorkerCompleted`**:在身份证/采集等数据就绪后,若有 `SelectedSubTicket` 则对其加载,否则对 `CurrentTicket` 加载。 +5. **个人业务路径**:与「当前展示票」一致,保证打开纳税人页时 query 与当前 `ticket` 一致。 -生成“一户式查询”URL。 +### 6. 办结与清空页面 -## 4.4 代理人企业清单 +- **`EndCommand`**(办结当前参数票)成功后:若存在 `Browser`,执行 **`Navigate("about:blank")`**,即 Tab 内页面回到空白页,避免继续显示上一纳税人敏感页。 +- Tauri:办结成功后对 webview 执行 `about:blank` 或 `data:text/html,` 空文档等价行为。 -### `taxer_get_today_enterprises(ticketId: String) -> Vec` +### 7. 刷新(与内嵌页直接相关) -调用: +- **顶栏「刷新」**(与 Tab 同列、操作对象为该 `WebBrowser`):若 `Browser.Document != null && Browser.Source != null`,则 **`Browser.Refresh()`**。 +- Tauri:`webview.reload()` 或 iframe `contentWindow.location.reload()`;无有效 `src` 时不应报错。 -- `GET {ZIYUN_SERVICE_URL}/agentInfo/today-enterprises/{ticketId}` +> 若你们其他模块已实现顶栏而仅缺 Tab 内逻辑:**最小集**为在「纳税人信息」可见且已导航过时提供 reload 能力即可。 -返回字段映射: +### 8. 与加载态 `IsLoading` 的配合 -- `serialNumber -> SerialNumber` -- `serviceTarget -> EnterpriseName` -- `serviceTargetCode -> TaxpayerId` +- Worker 开始时 `IsLoading = true`;完成时在 `finally` 中 `IsLoading = false`。 +- `IsLoading == true` 时该 Tab 内 `WebBrowser` 被 Collapsed,避免在数据未就绪时露出错误 URL 或半屏。 +- Tauri:建议在 **首次导航前** 与 **远程页首屏 onload 前** 之间展示 loading;若仅依赖「接口已返回」即导航,可与 WPF 一致在 `IsLoading` 为 true 期间隐藏 webview。 -## 4.5 系统能力 +### 9. 配置与安全要点(复刻 checklist) -### `system_copy_text(text: String) -> ()` +- [ ] `BS_NSR_URL`、`BS_TAX_AUTHORITY_NUM` 与现网配置一致。 +- [ ] URL 编码:若第三方要求对参数编码,需在拼接后整体 URL 合法;现 C# 为模板 `Replace`,一般 **不对整段 URL 二次编码**,除非模板里已含编码后的占位。 +- [ ] HTTPS / 证书 / 混合内容:Tauri webview 与 WPF IE 内核行为不同,需在目标环境验证登录态与 Cookie。 +- [ ] `about:blank` 与 `file://` 策略按平台区分处理。 -- 复制号码、复制税号统一复用。 +### 10. 与 WPF 行为差异说明(便于测试验收) -### `system_open_external(url: String) -> ()` - -- 一户式查询可在系统默认浏览器打开(可选)。 +| 场景 | 期望行为 | +|------|----------| +| 切换子票 | 新票对应 URL 与当前不同 → 应导航;相同 → 不重复导航 | +| 办结 | 内嵌页变为空白 | +| 加载中 | 内嵌页不可见(或等价遮罩) | +| 无票 / url 为 about:blank | 不崩溃、不导航非法地址 | --- -## 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 主流程中打开办税员窗口”,又不会破坏现有呼叫台功能。 +**文档版本**:节选 — 仅「纳税人信息」Tab 页面功能。 +**对应源码**:`CallClient/WPF/TaxerInfo.xaml`(TabItem「纳税人信息」)、`CallClient/WPF/TaxerInfo.xaml.cs`(`Analize_URL`、`Load_BS_NSR_URL`、`EndCommand` 内 `about:blank`、`Reflesh_Click`)。 diff --git a/call-client/package-lock.json b/call-client/package-lock.json index 4846030..b9b3d10 100644 --- a/call-client/package-lock.json +++ b/call-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "call-client", - "version": "0.1.0", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "call-client", - "version": "0.1.0", + "version": "0.1.5", "dependencies": { "@element-plus/icons-vue": "^2.3.2", "@tauri-apps/api": "^1", diff --git a/call-client/package.json b/call-client/package.json index e0163c7..ba5246e 100644 --- a/call-client/package.json +++ b/call-client/package.json @@ -1,7 +1,7 @@ { "name": "call-client", "private": true, - "version": "0.1.4", + "version": "0.1.5", "type": "module", "scripts": { "dev": "vite", diff --git a/call-client/src-tauri/Cargo.lock b/call-client/src-tauri/Cargo.lock index 9175349..a920f76 100644 --- a/call-client/src-tauri/Cargo.lock +++ b/call-client/src-tauri/Cargo.lock @@ -392,7 +392,7 @@ dependencies = [ [[package]] name = "call-client" -version = "0.1.4" +version = "0.1.5" dependencies = [ "chrono", "fs2", diff --git a/call-client/src-tauri/Cargo.toml b/call-client/src-tauri/Cargo.toml index 40040eb..1698757 100644 --- a/call-client/src-tauri/Cargo.toml +++ b/call-client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "call-client" -version = "0.1.4" +version = "0.1.5" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/call-client/src-tauri/tauri.conf.json b/call-client/src-tauri/tauri.conf.json index 758d6ad..b559094 100644 --- a/call-client/src-tauri/tauri.conf.json +++ b/call-client/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "../node_modules/@tauri-apps/cli/schema.json", "package": { "productName": "call-client", - "version": "0.1.4" + "version": "0.1.5" }, "build": { "beforeDevCommand": "npm run dev", diff --git a/call-client/src/api/index.ts b/call-client/src/api/index.ts index ce7e620..5cb91cb 100644 --- a/call-client/src/api/index.ts +++ b/call-client/src/api/index.ts @@ -10,6 +10,7 @@ import type { UserRequest, UserResponse } from "../types/user"; import type { WindowResponse } from "../types/window"; import { http } from "../utils/service"; +/** `POST /call-terminal/caller-init` 的 data(选窗登录后初始化呼号端) */ export interface CallerInitData { windowUid: number; windowName: string; @@ -27,6 +28,9 @@ export interface CallerInitData { realnameCheckApi: string; rankWaitSeconds: number; ziyunServiceUrl: string; + /** 纳税人信息页 URL 模板(原 BS_NSR_URL) */ + bsNsrUrl?: string; + bsTaxAuthorityNum?: string; } export const api = { @@ -41,19 +45,29 @@ export const api = { }, action: { - call: (data: CallRequest) => http.post("/call-terminal/call", data), - init: (data: CallRequest) => http.post("/call-terminal/init", data), - recall: (data: ReCallRequest) => http.post("/call-terminal/recall", data), - abandon: (data: ReCallRequest) => http.post("/call-terminal/abandon", data), - pause: (data: PauseRequest) => http.post("/call-terminal/pause", data), - resume: (data: CallRequest) => http.post("/call-terminal/resume", data), - start: (data: ReCallRequest) => http.post("/call-terminal/start", data), - complete: (data: ReCallRequest) => http.post("/call-terminal/complete", data), - evaluate: (data: ReCallRequest) => http.post("/call-terminal/evaluate", data), - pool: (params: TicketPoolRequest) => http.get("/call-terminal/pool", params), - isRank: (params: IsRankRequest) => http.get("/call-terminal/is-rank", params), + call: (data: CallRequest) => + http.post("/call-terminal/call", data), + callerInit: (data: CallRequest) => + http.post("/call-terminal/caller-init", data), + recall: (data: ReCallRequest) => + http.post("/call-terminal/recall", data), + abandon: (data: ReCallRequest) => + http.post("/call-terminal/abandon", data), + pause: (data: PauseRequest) => + http.post("/call-terminal/pause", data), + resume: (data: CallRequest) => + http.post("/call-terminal/resume", data), + start: (data: ReCallRequest) => + http.post("/call-terminal/start", data), + complete: (data: ReCallRequest) => + http.post("/call-terminal/complete", data), + evaluate: (data: ReCallRequest) => + http.post("/call-terminal/evaluate", data), + pool: (params: TicketPoolRequest) => + http.get("/call-terminal/pool", params), + 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/composables/useBsNsrBrowser.ts b/call-client/src/composables/useBsNsrBrowser.ts new file mode 100644 index 0000000..e7f1fb1 --- /dev/null +++ b/call-client/src/composables/useBsNsrBrowser.ts @@ -0,0 +1,125 @@ +import { ref } from "vue"; +import { log } from "../host/logger"; +import type { AnalyzeBsNsrUrlContext, TaxerTicketForNsr } from "../types/taxerTicket"; +import { + analyzeBsNsrUrl, + BS_NSR_ABOUT_BLANK, + isNavigableHttpUrl, + normalizeUrlForCompare, +} from "../utils/bsNsrUrl"; +import { formatConfigForLog } from "../utils/systemControlFromInit"; + +/** + * 纳税人信息 Tab 内嵌页导航(对应 WPF `Load_BS_NSR_URL`)。 + */ +export function useBsNsrBrowser() { + const iframeSrc = ref(BS_NSR_ABOUT_BLANK); + const iframePageLoading = ref(false); + const lastNavigatedUrl = ref(""); + + async function safeLog(level: "info" | "warn" | "error", message: string): Promise { + try { + await log(level, message); + } catch { + // ignore + } + } + + function buildAnalyzeContext( + partial: Pick & { + taxerIdCard: string; + phoneNo: string; + }, + ): AnalyzeBsNsrUrlContext { + return { + bsNsrUrlTemplate: partial.bsNsrUrlTemplate, + bsTaxAuthorityNum: partial.bsTaxAuthorityNum, + taxerIdCard: partial.taxerIdCard, + phoneNo: partial.phoneNo, + }; + } + + /** + * 加载指定票的纳税人页;URL 与当前一致时跳过(避免重复导航)。 + */ + async function loadBsNsrUrl( + ticket: TaxerTicketForNsr | null, + ctx: AnalyzeBsNsrUrlContext, + reason: string, + ): Promise { + const targetUrl = analyzeBsNsrUrl(ticket, ctx); + const normalizedTarget = normalizeUrlForCompare(targetUrl); + const normalizedCurrent = normalizeUrlForCompare(iframeSrc.value); + + if (normalizedTarget === normalizedCurrent) { + await safeLog( + "info", + `纳税人页跳过重复导航: reason=${reason}, url=${formatConfigForLog(normalizedTarget, 200)}`, + ); + return; + } + + if (targetUrl === BS_NSR_ABOUT_BLANK) { + navigateToBlank(reason); + return; + } + + if (!isNavigableHttpUrl(targetUrl)) { + await safeLog( + "warn", + `纳税人页 URL 非法,未导航: reason=${reason}, url=${formatConfigForLog(targetUrl)}`, + ); + return; + } + + iframePageLoading.value = true; + iframeSrc.value = targetUrl; + lastNavigatedUrl.value = normalizedTarget; + await safeLog( + "info", + `纳税人页导航: reason=${reason}, qhhm=${ticket?.tktId ?? ""}, url=${formatConfigForLog(targetUrl)}`, + ); + } + + function navigateToBlank(reason: string): void { + iframePageLoading.value = false; + iframeSrc.value = BS_NSR_ABOUT_BLANK; + lastNavigatedUrl.value = BS_NSR_ABOUT_BLANK; + void safeLog("info", `纳税人页已清空: reason=${reason}`); + } + + function onIframeLoad(): void { + iframePageLoading.value = false; + } + + function reloadIframe(): void { + if ( + !iframeSrc.value || + iframeSrc.value === BS_NSR_ABOUT_BLANK || + !isNavigableHttpUrl(iframeSrc.value) + ) { + return; + } + iframePageLoading.value = true; + const current = iframeSrc.value; + iframeSrc.value = BS_NSR_ABOUT_BLANK; + requestAnimationFrame(() => { + iframeSrc.value = current; + }); + void safeLog("info", "纳税人页刷新"); + } + + const canReloadIframe = () => + isNavigableHttpUrl(iframeSrc.value) && iframeSrc.value !== BS_NSR_ABOUT_BLANK; + + return { + iframeSrc, + iframePageLoading, + loadBsNsrUrl, + navigateToBlank, + onIframeLoad, + reloadIframe, + canReloadIframe, + buildAnalyzeContext, + }; +} diff --git a/call-client/src/host/events.ts b/call-client/src/host/events.ts index 5fada18..8d06c85 100644 --- a/call-client/src/host/events.ts +++ b/call-client/src/host/events.ts @@ -4,6 +4,8 @@ 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"; +const TAXER_NSR_CLEAR_EVENT = "taxer:nsr-clear"; +const TAXER_TICKET_START_EVENT = "taxer:ticket-start"; /** * 向主叫号页发送票号动作事件。 @@ -96,3 +98,45 @@ export async function listenTaxerTicketContext( throw new Error(`订阅办税员票号上下文失败: ${String(error)}`); } } + +/** 主窗口开始办理成功后通知办税员页加载纳税人 URL */ +export async function emitTaxerTicketStart( + payload: TaxerTicketContextPayload, +): Promise { + try { + await emit(TAXER_TICKET_START_EVENT, payload); + } catch (error) { + throw new Error(`发送办税员开始办理事件失败: ${String(error)}`); + } +} + +export async function listenTaxerTicketStart( + handler: (payload: TaxerTicketContextPayload) => void, +): Promise<() => void> { + try { + return await listen(TAXER_TICKET_START_EVENT, (event) => { + handler(event.payload ?? {}); + }); + } catch (error) { + throw new Error(`订阅办税员开始办理事件失败: ${String(error)}`); + } +} + +/** 办结/弃号等场景清空纳税人内嵌页 */ +export async function emitTaxerNsrClear(): Promise { + try { + await emit(TAXER_NSR_CLEAR_EVENT, {}); + } catch (error) { + throw new Error(`发送办税员清空纳税人页事件失败: ${String(error)}`); + } +} + +export async function listenTaxerNsrClear(handler: () => void): Promise<() => void> { + try { + return await listen(TAXER_NSR_CLEAR_EVENT, () => { + handler(); + }); + } catch (error) { + throw new Error(`订阅办税员清空纳税人页事件失败: ${String(error)}`); + } +} diff --git a/call-client/src/host/session.ts b/call-client/src/host/session.ts index 7687679..02d86d8 100644 --- a/call-client/src/host/session.ts +++ b/call-client/src/host/session.ts @@ -22,6 +22,8 @@ const DEFAULT_SESSION: SessionState = { realnameCheckApi: null, rankWaitSeconds: null, ziyunServiceUrl: null, + bsNsrUrl: null, + bsTaxAuthorityNum: null, taxer_compare_code: null, taxer_ticket_id: null, taxer_use_doc_fields: null, @@ -88,6 +90,9 @@ function normalizeSession(raw: unknown): SessionState { : null; const ziyunServiceUrl = typeof source.ziyunServiceUrl === "string" ? source.ziyunServiceUrl : null; + const bsNsrUrl = typeof source.bsNsrUrl === "string" ? source.bsNsrUrl : null; + const bsTaxAuthorityNum = + typeof source.bsTaxAuthorityNum === "string" ? source.bsTaxAuthorityNum : null; const taxer_compare_code = typeof source.taxer_compare_code === "string" ? source.taxer_compare_code @@ -122,6 +127,8 @@ function normalizeSession(raw: unknown): SessionState { realnameCheckApi, rankWaitSeconds, ziyunServiceUrl, + bsNsrUrl, + bsTaxAuthorityNum, taxer_compare_code, taxer_ticket_id, taxer_use_doc_fields, diff --git a/call-client/src/host/types.ts b/call-client/src/host/types.ts index 77c7e49..f3c493f 100644 --- a/call-client/src/host/types.ts +++ b/call-client/src/host/types.ts @@ -21,6 +21,10 @@ export interface SessionState { realnameCheckApi?: string | null; rankWaitSeconds?: number | null; ziyunServiceUrl?: string | null; + /** 纳税人信息页 URL 模板(来自 call-terminal/init 的 BS_NSR_URL) */ + bsNsrUrl?: string | null; + /** 税务机关代码模板占位(来自 init 的 BS_TAX_AUTHORITY_NUM) */ + bsTaxAuthorityNum?: string | null; taxer_compare_code?: string | null; taxer_ticket_id?: string | null; taxer_use_doc_fields?: boolean | null; @@ -59,6 +63,10 @@ export interface TicketActionPayload { export interface TaxerTicketContextPayload { ticketNo?: string; sfzhm?: string; + /** 登记序号,对应 `ENTERPRISE_ID` / `{djxh}` */ + enterpriseId?: string; + /** 是否企业业务,`1` 为企业 */ + isEnterprise?: number | boolean; } export type AppConfig = Record; diff --git a/call-client/src/types/taxerTicket.ts b/call-client/src/types/taxerTicket.ts new file mode 100644 index 0000000..1ec2557 --- /dev/null +++ b/call-client/src/types/taxerTicket.ts @@ -0,0 +1,18 @@ +/** 与 WPF `ticket` 对齐,用于 `Analize_URL` / `Load_BS_NSR_URL` */ +export interface TaxerTicketForNsr { + /** 票号 / 取号展示号 `TKT_ID` → `{qhhm}` */ + tktId: string; + /** 登记序号 `ENTERPRISE_ID` → `{djxh}` */ + enterpriseId: string; + /** `IS_ENTERPRISE == 1` 为企业 */ + isEnterprise: number; +} + +export interface AnalyzeBsNsrUrlContext { + bsNsrUrlTemplate: string; + bsTaxAuthorityNum: string; + /** 办税员身份证 `Card.Code` → `{sfzhm}` */ + taxerIdCard: string; + /** 实名采集手机号 → `{sjhm}` */ + phoneNo: string; +} diff --git a/call-client/src/utils/bsNsrUrl.ts b/call-client/src/utils/bsNsrUrl.ts new file mode 100644 index 0000000..bb1c366 --- /dev/null +++ b/call-client/src/utils/bsNsrUrl.ts @@ -0,0 +1,54 @@ +import type { AnalyzeBsNsrUrlContext, TaxerTicketForNsr } from "../types/taxerTicket"; + +export const BS_NSR_ABOUT_BLANK = "about:blank"; + +/** + * 与 WPF `Analize_URL` 一致:按模板替换占位符;`ticket == null` 或空模板时返回 `about:blank`。 + */ +export function analyzeBsNsrUrl( + ticket: TaxerTicketForNsr | null, + ctx: AnalyzeBsNsrUrlContext, +): string { + if (!ticket) { + return BS_NSR_ABOUT_BLANK; + } + + const template = ctx.bsNsrUrlTemplate.trim(); + if (!template) { + return BS_NSR_ABOUT_BLANK; + } + + const sflb = ticket.isEnterprise === 1 ? "1" : "2"; + return template + .replace(/\{djxh\}/g, ticket.enterpriseId) + .replace(/\{sfzhm\}/g, ctx.taxerIdCard) + .replace(/\{sflb\}/g, sflb) + .replace(/\{swjgDm\}/g, ctx.bsTaxAuthorityNum) + .replace(/\{qhhm\}/g, ticket.tktId) + .replace(/\{sjhm\}/g, ctx.phoneNo); +} + +/** 拼接结果是否为可导航的 http(s) URL */ +export function isNavigableHttpUrl(url: string): boolean { + if (!url || url === BS_NSR_ABOUT_BLANK) { + return false; + } + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +/** 与 WPF `Source.AbsoluteUri` 比较一致,避免重复导航 */ +export function normalizeUrlForCompare(url: string): string { + if (!url || url === BS_NSR_ABOUT_BLANK) { + return BS_NSR_ABOUT_BLANK; + } + try { + return new URL(url).href; + } catch { + return url.trim(); + } +} diff --git a/call-client/src/utils/systemControlFromInit.ts b/call-client/src/utils/systemControlFromInit.ts new file mode 100644 index 0000000..b413f69 --- /dev/null +++ b/call-client/src/utils/systemControlFromInit.ts @@ -0,0 +1,126 @@ +/** + * 从 `POST /call-terminal/init` 响应中解析系统配置项(与 WPF `SystemControl.Get` 键名一致)。 + * 兼容多种后端 JSON 命名(大写下划线 / camelCase / snake_case / systemControls 映射)。 + */ + +const BS_NSR_URL_KEYS = ["BS_NSR_URL", "bsNsrUrl", "bs_nsr_url"] as const; +const BS_TAX_AUTHORITY_NUM_KEYS = [ + "BS_TAX_AUTHORITY_NUM", + "bsTaxAuthorityNum", + "bs_tax_authority_num", +] as const; + +function readTrimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function pickFromRecord( + record: Record, + keys: readonly string[], +): string { + for (const key of keys) { + const v = readTrimmedString(record[key]); + if (v) { + return v; + } + } + return ""; +} + +function pickFromSystemControls( + controls: unknown, + keys: readonly string[], +): string { + if (!controls || typeof controls !== "object" || Array.isArray(controls)) { + return ""; + } + return pickFromRecord(controls as Record, keys); +} + +/** + * 从 init 接口 data 对象读取指定系统配置键。 + */ +export function pickSystemControlFromInit( + initData: unknown, + keys: readonly string[], +): string { + if (!initData || typeof initData !== "object" || Array.isArray(initData)) { + return ""; + } + const root = initData as Record; + const direct = pickFromRecord(root, keys); + if (direct) { + return direct; + } + return pickFromSystemControls(root.systemControls, keys); +} + +export function pickBsNsrUrlFromInit(initData: unknown): string { + return pickSystemControlFromInit(initData, BS_NSR_URL_KEYS); +} + +export function pickBsTaxAuthorityNumFromInit(initData: unknown): string { + return pickSystemControlFromInit(initData, BS_TAX_AUTHORITY_NUM_KEYS); +} + +/** 供日志输出:过长 URL 模板截断,避免单条日志过大 */ +export function formatConfigForLog(value: string, maxLen = 512): string { + if (!value) { + return "(空)"; + } + if (value.length <= maxLen) { + return value; + } + return `${value.slice(0, maxLen)}…(共 ${value.length} 字符)`; +} + +/** 列出 init 响应顶层及 systemControls 的键名,便于对照后端字段 */ +export function summarizeInitResponseKeys(initData: unknown): string { + if (!initData || typeof initData !== "object" || Array.isArray(initData)) { + return "顶层=(非对象)"; + } + const root = initData as Record; + const topKeys = Object.keys(root); + const parts = [`顶层 keys=[${topKeys.join(", ")}]`]; + const nestedKeys = [ + "systemControls", + "systemControl", + "controls", + "config", + "settings", + ]; + for (const nk of nestedKeys) { + const nested = root[nk]; + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + parts.push( + `${nk} keys=[${Object.keys(nested as Record).join(", ")}]`, + ); + } + } + return parts.join("; "); +} + +/** + * 将 init 响应序列化写入日志(长字符串字段会截断,避免单条过大)。 + */ +export function formatInitResponseForLog(initData: unknown, maxLen = 8000): string { + try { + const serialized = JSON.stringify( + initData, + (_key, value: unknown) => { + if (typeof value === "string" && value.length > 400) { + return `${value.slice(0, 400)}…(len=${value.length})`; + } + return value; + }, + 0, + ); + if (serialized.length <= maxLen) { + return serialized; + } + return `${serialized.slice(0, maxLen)}…(共 ${serialized.length} 字符)`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `(无法序列化 init 响应: ${message})`; + } +} diff --git a/call-client/src/utils/taxerTicketContext.ts b/call-client/src/utils/taxerTicketContext.ts new file mode 100644 index 0000000..5074ea2 --- /dev/null +++ b/call-client/src/utils/taxerTicketContext.ts @@ -0,0 +1,65 @@ +import type { TaxerTicketContextPayload } from "../host/types"; +import type { TaxerTicketForNsr } from "../types/taxerTicket"; +import type { TaxerEnterprise } from "../api/taxer"; + +export function isEnterpriseFlag(value: unknown): number { + if (value === true || value === 1 || value === "1") { + return 1; + } + return 0; +} + +export function ticketFromContext( + payload: TaxerTicketContextPayload, +): TaxerTicketForNsr | null { + const tktId = String(payload.ticketNo ?? "").trim(); + if (!tktId) { + return null; + } + return { + tktId, + enterpriseId: String(payload.enterpriseId ?? "").trim(), + isEnterprise: isEnterpriseFlag(payload.isEnterprise), + }; +} + +export function buildPersonalTicket( + tktId: string, + enterpriseId = "", +): TaxerTicketForNsr { + return { + tktId: tktId.trim(), + enterpriseId: enterpriseId.trim(), + isEnterprise: 0, + }; +} + +/** 代理人企业清单项作为子票(企业业务) */ +export function ticketFromEnterprise( + mainTktId: string, + item: TaxerEnterprise, +): TaxerTicketForNsr { + return { + tktId: mainTktId.trim(), + enterpriseId: String(item.taxpayerId ?? "").trim(), + isEnterprise: 1, + }; +} + +export function buildTaxerContextFromActionData( + data: Record, + ticketNo: string, + sfzhm: string, +): TaxerTicketContextPayload { + const enterpriseId = String( + data.enterpriseId ?? data.ENTERPRISE_ID ?? data.djxh ?? "", + ).trim(); + const isEnterprise = + data.isEnterprise ?? data.IS_ENTERPRISE ?? (enterpriseId ? 1 : 0); + return { + ticketNo, + sfzhm, + enterpriseId, + isEnterprise: isEnterpriseFlag(isEnterprise), + }; +} diff --git a/call-client/src/views/LoginView.vue b/call-client/src/views/LoginView.vue index 580b94b..aea36db 100644 --- a/call-client/src/views/LoginView.vue +++ b/call-client/src/views/LoginView.vue @@ -14,6 +14,13 @@ import { invoke } from "@tauri-apps/api/tauri"; import { computed, onMounted, onUnmounted, reactive, ref } from "vue"; import { api } from "../api"; import { getAllConfig, mergeConfig } from "../host/config"; +import { + formatConfigForLog, + formatInitResponseForLog, + pickBsNsrUrlFromInit, + pickBsTaxAuthorityNumFromInit, + summarizeInitResponseKeys, +} from "../utils/systemControlFromInit"; import { confirmNative, showErrorNative, @@ -303,80 +310,76 @@ async function handleWindowLogin(): Promise { (item) => item.value === selectedWin.value, ); - const initRes = await api.action.init({ + const callerInit = await api.action.callerInit({ empUid: Number(sessionState.empUid ?? -1), windowUid: winUid, }); - const initSuccess = - ((initRes as { data?: { success?: boolean } }).data?.success ?? - (initRes as { success?: boolean }).success) === true; - - if (!initSuccess) { - const initData = (initRes as { data?: { message?: string } }).data; - const msg = - (typeof initData?.message === "string" && initData.message.trim() !== "" - ? initData.message - : null) ?? - (typeof (initRes as { message?: string }).message === "string" - ? (initRes as { message?: string }).message - : null) ?? - "窗口初始化失败,请重试或更换窗口"; - await showErrorNative(String(msg), "窗口初始化"); - await log("warn", `call-terminal/init 未成功: windowUid=${winUid}`); - return; + await log( + "info", + `call-terminal/caller-init 响应字段摘要: ${summarizeInitResponseKeys(callerInit)}`, + ); + await log( + "info", + `call-terminal/caller-init 响应正文(截断): ${formatInitResponseForLog(callerInit)}`, + ); + + const bsNsrUrl = pickBsNsrUrlFromInit(callerInit); + const bsTaxAuthorityNum = pickBsTaxAuthorityNumFromInit(callerInit); + const callerWin = Number(callerInit.windowUid); + const resolvedWinUid = + Number.isFinite(callerWin) && callerWin > 0 ? callerWin : winUid; + const callerEmp = Number(callerInit.empUid); + const resolvedEmpUid = Number.isFinite(callerEmp) + ? callerEmp + : Number(sessionState.empUid ?? -1); + + await log( + "info", + [ + `call-terminal/caller-init 完成: windowUid=${resolvedWinUid}, empUid=${resolvedEmpUid}`, + `bsNsrUrl=${formatConfigForLog(bsNsrUrl)}`, + `bsTaxAuthorityNum=${formatConfigForLog(bsTaxAuthorityNum)}`, + ].join("; "), + ); + + if (!bsNsrUrl) { + await log( + "warn", + `call-terminal/caller-init 未返回 bsNsrUrl,纳税人信息页将无法拼 URL;windowUid=${resolvedWinUid}`, + ); } await mergeConfig({ last_username: loginForm.username.trim(), selected_win_key: selectedWin.value, selected_win_value: selected?.label ?? "", - selected_win_uid: winUid, + selected_win_uid: resolvedWinUid, }); - sessionState.winUid = winUid; + sessionState = { + ...sessionState, + winUid: resolvedWinUid, + empUid: resolvedEmpUid, + 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 ?? ""), + bsNsrUrl: bsNsrUrl || null, + bsTaxAuthorityNum: bsTaxAuthorityNum || null, + }; await setSession(sessionState); - // 登录完成后拉取 caller-init,并将返回 data 作为运行时缓存写入 session。 - try { - const callerInit = await api.action.callerInit(); - const callerWin = Number(callerInit.windowUid); - const resolvedWinUid = - Number.isFinite(callerWin) && callerWin > 0 ? callerWin : winUid; - const callerEmp = Number(callerInit.empUid); - const resolvedEmpUid = Number.isFinite(callerEmp) - ? callerEmp - : Number(sessionState.empUid ?? -1); - - sessionState = { - ...sessionState, - winUid: resolvedWinUid, - empUid: resolvedEmpUid, - 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 9505e69..a7f6c89 100644 --- a/call-client/src/views/MainView.vue +++ b/call-client/src/views/MainView.vue @@ -28,7 +28,13 @@ import { showErrorNativeWithLog, showWarningNative, } from "../host/dialog"; -import { emitTaxerTicketContext, listenMainAction } from "../host/events"; +import { + emitTaxerNsrClear, + emitTaxerTicketContext, + emitTaxerTicketStart, + listenMainAction, +} from "../host/events"; +import { buildTaxerContextFromActionData } from "../utils/taxerTicketContext"; import { log } from "../host/logger"; import { getSession, setSession } from "../host/session"; import { startScreenSync, stopScreenSync } from "../host/sync"; @@ -59,6 +65,10 @@ const sessionState = ref({ winUid: -1, queueToken: "", refreshToken: "", + autoCallEnabled: null, + autoCallWaitSeconds: null, + autoStartEnabled: null, + autoStartWaitSeconds: null, }); /** 与产品流程一致:账号登录拿到 token + 选窗写入 winUid 之后,才允许等候人数轮询(避免主窗口提前挂载时误启动) */ @@ -87,18 +97,429 @@ let lastQueueCountSkipInvalidWinLogAt = 0; let lastQueueCountSkipNoTokenLogAt = 0; let unlistenWindowFocusChanged: (() => void) | null = null; let unlistenMainAction: (() => void) | null = null; +/** 自动叫号倒计时(caller-init 的 autoCallWaitSeconds) */ +let autoCallCountdownTimer: ReturnType | null = null; +/** 计时器内部剩余秒数(暂停时保留,恢复后续计) */ +let autoCallTickLeft = 0; +const autoCallCountdownLeft = ref(0); +/** 待机轮询得到的等候人数,倒计时期间用于 log-span 展示 */ +const idleQueueCount = ref(null); +/** 自动开始倒计时(caller-init 的 autoStartWaitSeconds,calling 状态) */ +let autoStartCountdownTimer: ReturnType | null = null; +let autoStartTickLeft = 0; +const autoStartCountdownLeft = ref(0); +/** 当前呼叫票号展示(自动开始倒计时文案) */ +const callingTicketNoLabel = ref(""); const EVALUATING_COUNTDOWN_SEC = 15; +const DEFAULT_AUTO_CALL_WAIT_SECONDS = 5; +const DEFAULT_AUTO_START_WAIT_SECONDS = 50; const pauseReasonOptions = ["午休", "休息一下", "整理资料", "其他"]; /** - * 主窗口是否处于前台焦点(用于高频 isRank 轮询;失焦后暂停以减轻请求)。 - * 等候人数 queue-count 不与该标志绑定,待机时后台仍定时拉取。 + * 主窗口是否处于前台焦点(用于评价 isRank 轮询;失焦后暂停以减轻请求)。 + * 自动叫号不依赖该标志,仅在看得到主窗口未最小化时倒计时。 */ const isMainWindowActive = ref(true); +/** 主窗口是否已最小化(自动叫号仅在非最小化时运行) */ +const isMainWindowMinimized = ref(false); const buttonPanel = ref<"main" | "more" | "pause">("main"); const isSyncingMainScreen = ref(false); const isActionPending = ref(false); +/** caller-init 缓存:为 true 时待机且窗口激活则自动倒计时并呼叫 */ +const autoCallEnabled = computed( + () => sessionState.value.autoCallEnabled === true, +); + +function resolveAutoCallWaitSeconds(): number { + const raw = sessionState.value.autoCallWaitSeconds; + if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { + return Math.floor(raw); + } + return DEFAULT_AUTO_CALL_WAIT_SECONDS; +} + +async function refreshMainWindowMinimizedState(): Promise { + try { + const minimized = await appWindow.isMinimized(); + if (isMainWindowMinimized.value === minimized) { + return; + } + isMainWindowMinimized.value = minimized; + void log( + "info", + `主窗口最小化状态: ${minimized ? "已最小化" : "未最小化"}`, + ); + syncAutoCallCountdown(); + syncAutoStartCountdown(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + await log("warn", `读取主窗口最小化状态失败: ${detail}`); + } +} + +/** caller-init 缓存:为 true 且在 calling 时自动倒计时并开始办理 */ +const autoStartEnabled = computed( + () => sessionState.value.autoStartEnabled === true, +); + +function resolveAutoStartWaitSeconds(): number { + const raw = sessionState.value.autoStartWaitSeconds; + if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { + return Math.floor(raw); + } + return DEFAULT_AUTO_START_WAIT_SECONDS; +} + +function shouldRunAutoStartCountdown(): boolean { + return ( + autoStartEnabled.value && + !isMainWindowMinimized.value && + callStatus.value === "calling" && + sessionReadyForQueueCount.value && + !isActionPending.value + ); +} + +function isAutoStartCountdownRunning(): boolean { + return autoStartCountdownTimer !== null; +} + +function applyAutoStartCountdownMessage(left: number): void { + if (left <= 0 || callStatus.value === "paused") { + return; + } + const ticket = callingTicketNoLabel.value.trim() || "—"; + message.value = `正在呼叫:${ticket},自动开始 ${left} 秒后办理`; +} + +function applyPausedLogMessage(pauseReason: string): void { + message.value = `窗口已暂停,原因:${pauseReason}`; +} + +function pauseAutoStartCountdownTimerOnly(): void { + if (autoStartCountdownTimer !== null) { + clearInterval(autoStartCountdownTimer); + autoStartCountdownTimer = null; + } + if (autoStartTickLeft > 0) { + autoStartCountdownLeft.value = autoStartTickLeft; + } +} + +function pauseAutoStartCountdown(reason: string): void { + const hadTimer = isAutoStartCountdownRunning(); + const remaining = + autoStartTickLeft > 0 ? autoStartTickLeft : autoStartCountdownLeft.value; + pauseAutoStartCountdownTimerOnly(); + if (remaining > 0) { + autoStartTickLeft = remaining; + autoStartCountdownLeft.value = remaining; + if (callStatus.value !== "paused") { + applyAutoStartCountdownMessage(remaining); + } + } + if (hadTimer || remaining > 0) { + void log("info", `auto-start 倒计时已暂停: ${reason},剩余 ${remaining}s`); + } +} + +function stopAutoStartCountdown(reason: string): void { + pauseAutoStartCountdownTimerOnly(); + autoStartTickLeft = 0; + autoStartCountdownLeft.value = 0; + void log("info", `auto-start 倒计时已清除: ${reason}`); +} + +function runAutoStartCountdownDisplayTick(): void { + if (!shouldRunAutoStartCountdown()) { + pauseAutoStartCountdown("展示 tick 时条件不满足"); + return; + } + autoStartCountdownLeft.value = autoStartTickLeft; + applyAutoStartCountdownMessage(autoStartTickLeft); +} + +function beginAutoStartCountdownInterval(): void { + if (autoStartCountdownTimer !== null) { + return; + } + autoStartCountdownTimer = setInterval(() => { + autoStartTickLeft -= 1; + if (autoStartTickLeft <= 0) { + pauseAutoStartCountdownTimerOnly(); + autoStartCountdownLeft.value = 0; + autoStartTickLeft = 0; + void log("info", "auto-start 倒计时结束,触发开始办理"); + void startAction({ triggeredByAutoStart: true }); + return; + } + runAutoStartCountdownDisplayTick(); + }, 1000); +} + +/** 从配置的满秒重新计时(仅「非 calling → calling」时调用) */ +function resetAndStartAutoStartCountdown(): void { + if (!autoStartEnabled.value || callStatus.value !== "calling") { + return; + } + pauseAutoStartCountdownTimerOnly(); + autoStartTickLeft = resolveAutoStartWaitSeconds(); + void log( + "info", + `auto-start 倒计时重新开始: ${autoStartTickLeft}s (进入呼叫中)`, + ); + if (!shouldRunAutoStartCountdown()) { + autoStartCountdownLeft.value = autoStartTickLeft; + return; + } + runAutoStartCountdownDisplayTick(); + beginAutoStartCountdownInterval(); +} + +function resumeAutoStartCountdown(): void { + if (!shouldRunAutoStartCountdown()) { + pauseAutoStartCountdown( + !autoStartEnabled.value + ? "未开启自动开始" + : isMainWindowMinimized.value + ? "主窗口已最小化" + : callStatus.value !== "calling" + ? `callStatus=${callStatus.value}` + : !sessionReadyForQueueCount.value + ? "会话未就绪" + : "操作进行中", + ); + return; + } + if (isAutoStartCountdownRunning()) { + return; + } + if (autoStartTickLeft <= 0 && autoStartCountdownLeft.value > 0) { + autoStartTickLeft = autoStartCountdownLeft.value; + } + if (autoStartTickLeft <= 0) { + resetAndStartAutoStartCountdown(); + return; + } + void log("info", `auto-start 倒计时继续: 剩余 ${autoStartTickLeft}s`); + runAutoStartCountdownDisplayTick(); + beginAutoStartCountdownInterval(); +} + +function syncAutoStartCountdown(): void { + if (callStatus.value === "paused") { + pauseAutoStartCountdownTimerOnly(); + return; + } + if (shouldRunAutoStartCountdown()) { + resumeAutoStartCountdown(); + } else if (callStatus.value !== "calling") { + pauseAutoStartCountdown(`callStatus=${callStatus.value}`); + } else { + pauseAutoStartCountdown( + !autoStartEnabled.value + ? "未开启自动开始" + : isMainWindowMinimized.value + ? "主窗口已最小化" + : !sessionReadyForQueueCount.value + ? "会话未就绪" + : "操作进行中", + ); + } +} + +function handleAutoStartOnCallStatusChange( + newStatus: string, + oldStatus: string | undefined, +): void { + if ( + newStatus === "calling" && + oldStatus !== undefined && + oldStatus !== "calling" + ) { + if (autoStartEnabled.value) { + resetAndStartAutoStartCountdown(); + } else { + stopAutoStartCountdown("进入呼叫中但未开启自动开始"); + } + return; + } + if (oldStatus === "calling" && newStatus !== "calling") { + pauseAutoStartCountdown(`离开呼叫中 -> ${newStatus}`); + } +} + +function shouldRunAutoCallCountdown(): boolean { + return ( + autoCallEnabled.value && + !isMainWindowMinimized.value && + callStatus.value === "idle" && + sessionReadyForQueueCount.value && + !isActionPending.value + ); +} + +function isAutoCallCountdownRunning(): boolean { + return autoCallCountdownTimer !== null; +} + +function applyAutoCallCountdownMessage(left: number): void { + if (left <= 0 || callStatus.value === "paused") { + return; + } + const countLabel = + idleQueueCount.value === null ? "—" : String(idleQueueCount.value); + message.value = `当前窗口等候人数:${countLabel},自动叫号 ${left} 秒后呼叫`; +} + +function pauseAutoCallCountdownTimerOnly(): void { + if (autoCallCountdownTimer !== null) { + clearInterval(autoCallCountdownTimer); + autoCallCountdownTimer = null; + } + if (autoCallTickLeft > 0) { + autoCallCountdownLeft.value = autoCallTickLeft; + } +} + +function pauseAutoCallCountdown(reason: string): void { + const hadTimer = isAutoCallCountdownRunning(); + const remaining = autoCallTickLeft > 0 ? autoCallTickLeft : autoCallCountdownLeft.value; + pauseAutoCallCountdownTimerOnly(); + if (remaining > 0) { + autoCallTickLeft = remaining; + autoCallCountdownLeft.value = remaining; + if (callStatus.value !== "paused") { + applyAutoCallCountdownMessage(remaining); + } + } + if (hadTimer || remaining > 0) { + void log("info", `auto-call 倒计时已暂停: ${reason},剩余 ${remaining}s`); + } +} + +function stopAutoCallCountdown(reason: string): void { + pauseAutoCallCountdownTimerOnly(); + autoCallTickLeft = 0; + autoCallCountdownLeft.value = 0; + void log("info", `auto-call 倒计时已清除: ${reason}`); +} + +function runAutoCallCountdownDisplayTick(): void { + if (!shouldRunAutoCallCountdown()) { + pauseAutoCallCountdown("展示 tick 时条件不满足"); + return; + } + autoCallCountdownLeft.value = autoCallTickLeft; + applyAutoCallCountdownMessage(autoCallTickLeft); +} + +function beginAutoCallCountdownInterval(): void { + if (autoCallCountdownTimer !== null) { + return; + } + autoCallCountdownTimer = setInterval(() => { + autoCallTickLeft -= 1; + if (autoCallTickLeft <= 0) { + pauseAutoCallCountdownTimerOnly(); + autoCallCountdownLeft.value = 0; + autoCallTickLeft = 0; + void log("info", "auto-call 倒计时结束,触发呼叫"); + void callAction({ triggeredByAutoCall: true }); + return; + } + runAutoCallCountdownDisplayTick(); + }, 1000); +} + +/** 从配置的满秒重新计时(仅「非 idle → idle」时调用) */ +function resetAndStartAutoCallCountdown(): void { + if (!autoCallEnabled.value || callStatus.value !== "idle") { + return; + } + pauseAutoCallCountdownTimerOnly(); + autoCallTickLeft = resolveAutoCallWaitSeconds(); + void log( + "info", + `auto-call 倒计时重新开始: ${autoCallTickLeft}s (切换回待机)`, + ); + void pollQueueCountOnce(); + runAutoCallCountdownDisplayTick(); + beginAutoCallCountdownInterval(); +} + +/** 在已有剩余秒数上继续(最小化恢复、操作结束等) */ +function resumeAutoCallCountdown(): void { + if (!shouldRunAutoCallCountdown()) { + pauseAutoCallCountdown( + !autoCallEnabled.value + ? "未开启自动叫号" + : isMainWindowMinimized.value + ? "主窗口已最小化" + : callStatus.value !== "idle" + ? `callStatus=${callStatus.value}` + : !sessionReadyForQueueCount.value + ? "会话未就绪" + : "操作进行中", + ); + return; + } + if (isAutoCallCountdownRunning()) { + return; + } + if (autoCallTickLeft <= 0 && autoCallCountdownLeft.value > 0) { + autoCallTickLeft = autoCallCountdownLeft.value; + } + if (autoCallTickLeft <= 0) { + resetAndStartAutoCallCountdown(); + return; + } + void log("info", `auto-call 倒计时继续: 剩余 ${autoCallTickLeft}s`); + void pollQueueCountOnce(); + runAutoCallCountdownDisplayTick(); + beginAutoCallCountdownInterval(); +} + +function syncAutoCallCountdown(): void { + if (callStatus.value === "paused") { + pauseAutoCallCountdownTimerOnly(); + return; + } + if (shouldRunAutoCallCountdown()) { + resumeAutoCallCountdown(); + } else if (callStatus.value !== "idle") { + pauseAutoCallCountdown(`callStatus=${callStatus.value}`); + } else { + pauseAutoCallCountdown( + !autoCallEnabled.value + ? "未开启自动叫号" + : isMainWindowMinimized.value + ? "主窗口已最小化" + : !sessionReadyForQueueCount.value + ? "会话未就绪" + : "操作进行中", + ); + } +} + +function handleAutoCallOnCallStatusChange( + newStatus: string, + oldStatus: string | undefined, +): void { + if (newStatus === "idle" && oldStatus !== undefined && oldStatus !== "idle") { + if (autoCallEnabled.value) { + resetAndStartAutoCallCountdown(); + } else { + stopAutoCallCountdown("切换回待机但未开启自动叫号"); + } + return; + } + if (oldStatus === "idle" && newStatus !== "idle") { + pauseAutoCallCountdown(`离开待机 -> ${newStatus}`); + } +} + /** * 记录错误日志。 */ @@ -213,6 +634,19 @@ function getActionSfzhm(res: unknown): string { return String(data.sfzhm ?? data.idCard ?? "").trim(); } +function buildTaxerContextFromAction(res: unknown) { + const data = getActionData(res) as Record; + return buildTaxerContextFromActionData( + data, + getActionTicketNo(res), + getActionSfzhm(res), + ); +} + +async function emitTaxerContextFromAction(res: unknown): Promise { + await emitTaxerTicketContext(buildTaxerContextFromAction(res)); +} + /** * 提取票据 UID。 */ @@ -423,6 +857,8 @@ async function refreshMainSessionStateFromDisk(): Promise { try { const session = await getSession(); sessionState.value = { + ...sessionState.value, + ...session, empUid: parseOptionalNumber(session.empUid), winUid: parseOptionalNumber(session.winUid), queueToken: @@ -443,6 +879,10 @@ async function refreshMainSessionStateFromDisk(): Promise { * 轮询当前窗口等候人数。 */ async function pollQueueCountOnce(): Promise { + const autoCallUiActive = + autoCallCountdownLeft.value > 0 || isAutoCallCountdownRunning(); + const autoStartUiActive = + autoStartCountdownLeft.value > 0 || isAutoStartCountdownRunning(); try { const session = await getSession(); const token = @@ -481,6 +921,23 @@ async function pollQueueCountOnce(): Promise { "info", `GET /call-terminal/queue-count: windowUid=${windowUid}, queueCount=${count}`, ); + idleQueueCount.value = count; + if (autoCallUiActive) { + const left = + autoCallTickLeft > 0 ? autoCallTickLeft : autoCallCountdownLeft.value; + if (left > 0) { + applyAutoCallCountdownMessage(left); + } + return; + } + if (autoStartUiActive) { + const left = + autoStartTickLeft > 0 ? autoStartTickLeft : autoStartCountdownLeft.value; + if (left > 0) { + applyAutoStartCountdownMessage(left); + } + return; + } message.value = `欢迎使用紫云呼叫终端,当前窗口等候人数:${count}`; } catch (error) { await logErr("查询 getQueueCount 失败", error); @@ -534,6 +991,56 @@ watch(isMainWindowActive, (active) => { } }); +watch(isMainWindowMinimized, () => { + syncAutoCallCountdown(); + syncAutoStartCountdown(); +}); + +watch(callStatus, (newStatus, oldStatus) => { + handleAutoCallOnCallStatusChange(newStatus, oldStatus); + handleAutoStartOnCallStatusChange(newStatus, oldStatus); +}); + +watch([autoCallEnabled, sessionReadyForQueueCount], () => { + if (callStatus.value !== "idle") { + return; + } + if (autoCallEnabled.value && sessionReadyForQueueCount.value) { + if (autoCallTickLeft <= 0 && autoCallCountdownLeft.value <= 0) { + resetAndStartAutoCallCountdown(); + } else { + syncAutoCallCountdown(); + } + } else { + pauseAutoCallCountdown("自动叫号未启用或会话未就绪"); + } +}); + +watch([autoStartEnabled, sessionReadyForQueueCount], () => { + if (callStatus.value !== "calling") { + return; + } + if (autoStartEnabled.value && sessionReadyForQueueCount.value) { + if (autoStartTickLeft <= 0 && autoStartCountdownLeft.value <= 0) { + resetAndStartAutoStartCountdown(); + } else { + syncAutoStartCountdown(); + } + } else { + pauseAutoStartCountdown("自动开始未启用或会话未就绪"); + } +}); + +watch(isActionPending, (pending) => { + if (pending) { + pauseAutoCallCountdown("操作进行中"); + pauseAutoStartCountdown("操作进行中"); + } else { + syncAutoCallCountdown(); + syncAutoStartCountdown(); + } +}); + const buttons = computed(() => { const startOrComplete = callStatus.value === "working" @@ -616,8 +1123,14 @@ async function openTicketList(): Promise { /** * 发起呼叫或重呼。 + * @param triggeredByAutoCall 为 true 时表示由自动叫号倒计时触发,不清除自动叫号倒计时 */ -async function callAction(): Promise { +async function callAction(options?: { + triggeredByAutoCall?: boolean; +}): Promise { + if (!options?.triggeredByAutoCall) { + stopAutoCallCountdown("手动点击呼叫"); + } if (isActionPending.value) { return; } @@ -634,15 +1147,13 @@ async function callAction(): Promise { ticketUid: callingTkt.value, }); if (isActionSuccess(recallRes)) { + callingTicketNoLabel.value = getActionTicketNo(recallRes); updateLog(`已重呼:${getActionTicketNo(recallRes)},请勿重复点击!`); await log( "info", `重呼成功: ticketNo=${getActionTicketNo(recallRes)}`, ); - await emitTaxerTicketContext({ - ticketNo: getActionTicketNo(recallRes), - sfzhm: getActionSfzhm(recallRes), - }); + await emitTaxerContextFromAction(recallRes); } else { updateLog(getActionMessage(recallRes)); } @@ -654,15 +1165,13 @@ async function callAction(): Promise { callStatus.value = "calling"; callBtnText.value = "重呼"; callingTkt.value = getActionTicketUid(res); + callingTicketNoLabel.value = getActionTicketNo(res); updateLog(`正在呼叫:${getActionTicketNo(res)}`); await log( "info", `呼叫成功: ticketNo=${getActionTicketNo(res)}, ticketUid=${getActionTicketUid(res)}`, ); - await emitTaxerTicketContext({ - ticketNo: getActionTicketNo(res), - sfzhm: getActionSfzhm(res), - }); + await emitTaxerContextFromAction(res); return; } @@ -692,7 +1201,9 @@ async function abandonAction(): Promise { callStatus.value = "idle"; callBtnText.value = "呼叫"; callingTkt.value = -1; + callingTicketNoLabel.value = ""; updateLog(`弃号成功: ${getActionTicketNo(res)}`); + await emitTaxerNsrClear(); return; } @@ -705,8 +1216,14 @@ async function abandonAction(): Promise { /** * 开始办理。 + * @param triggeredByAutoStart 为 true 时表示由自动开始倒计时触发,不清除自动开始倒计时 */ -async function startAction(): Promise { +async function startAction(options?: { + triggeredByAutoStart?: boolean; +}): Promise { + if (!options?.triggeredByAutoStart) { + stopAutoStartCountdown("手动点击开始"); + } if (isActionPending.value) { return; } @@ -721,6 +1238,7 @@ async function startAction(): Promise { if (isActionSuccess(res)) { callStatus.value = "working"; updateLog(`正在办理:${getActionTicketNo(res)}`); + await emitTaxerTicketStart(buildTaxerContextFromAction(res)); } }); } catch (error) { @@ -747,6 +1265,7 @@ async function completeAction(): Promise { return; } + await emitTaxerNsrClear(); callStatus.value = "evaluating"; callBtnText.value = "呼叫"; startEvaluatingCountdown(`办理完成:${getActionTicketNo(res)},评价中`); @@ -855,7 +1374,9 @@ async function confirmPauseReason(reason: string): Promise { if (isActionSuccess(res)) { callStatus.value = "paused"; pauseBtnText.value = "恢复"; - updateLog(`暂停中,原因:${pauseReason}`); + pauseAutoCallCountdown("暂停成功"); + pauseAutoStartCountdown("暂停成功"); + applyPausedLogMessage(pauseReason); buttonPanel.value = "main"; return; } @@ -963,6 +1484,7 @@ async function handleMoreCommand( updateLog("正在打开票号列表..."); // 主窗口在无边框模式下常驻置顶,先最小化主窗口避免票号列表被遮挡 await minimizeWindow(); + await refreshMainWindowMinimizedState(); await openTicketList(); updateLog("票号列表已打开"); buttonPanel.value = "main"; @@ -1049,11 +1571,30 @@ onMounted(async () => { } sessionState.value = { + ...session, empUid, winUid, queueToken, refreshToken, }; + void log( + "info", + `MainView 会话: autoCallEnabled=${sessionState.value.autoCallEnabled === true}, autoCallWaitSeconds=${resolveAutoCallWaitSeconds()}, autoStartEnabled=${sessionState.value.autoStartEnabled === true}, autoStartWaitSeconds=${resolveAutoStartWaitSeconds()}`, + ); + if ( + autoCallEnabled.value && + callStatus.value === "idle" && + sessionReadyForQueueCount.value + ) { + resetAndStartAutoCallCountdown(); + } + if ( + autoStartEnabled.value && + callStatus.value === "calling" && + sessionReadyForQueueCount.value + ) { + resetAndStartAutoStartCountdown(); + } } catch (error) { await logErr("读取 session 失败", error); } @@ -1066,21 +1607,31 @@ onMounted(async () => { if (visible) { isMainWindowActive.value = true; } - const onFocus = () => { - isMainWindowActive.value = true; - void refreshMainSessionStateFromDisk(); - }; - const onBlur = () => { - isMainWindowActive.value = false; - }; - window.addEventListener("focus", onFocus); - window.addEventListener("blur", onBlur); + isMainWindowActive.value = await appWindow.isFocused(); + await refreshMainWindowMinimizedState(); + + const unlistenFns: Array<() => void> = []; + unlistenFns.push( + await appWindow.onFocusChanged(({ payload: focused }) => { + isMainWindowActive.value = focused; + if (focused) { + void refreshMainSessionStateFromDisk(); + } + void refreshMainWindowMinimizedState(); + }), + ); + unlistenFns.push( + await appWindow.onResized(() => { + void refreshMainWindowMinimizedState(); + }), + ); unlistenWindowFocusChanged = () => { - window.removeEventListener("focus", onFocus); - window.removeEventListener("blur", onBlur); + for (const unlisten of unlistenFns) { + unlisten(); + } }; } catch (error) { - await logErr("订阅主窗口焦点事件失败", error); + await logErr("订阅主窗口焦点/尺寸事件失败", error); } unlistenMainAction = await listenMainAction((action, payload) => { @@ -1105,6 +1656,8 @@ onUnmounted(() => { }); } clearEvaluatingCountdown(); + stopAutoCallCountdown("组件卸载"); + stopAutoStartCountdown("组件卸载"); clearIsRankPolling(); clearQueueCountPolling("组件卸载"); if (unlistenWindowFocusChanged) { diff --git a/call-client/src/views/TaxerInfoView.vue b/call-client/src/views/TaxerInfoView.vue index 9986698..8c631e4 100644 --- a/call-client/src/views/TaxerInfoView.vue +++ b/call-client/src/views/TaxerInfoView.vue @@ -1,20 +1,50 @@