自动呼叫、开始

master
cysamurai 3 months ago
parent 44af8a34ab
commit c8242e98e0

@ -1,537 +1,100 @@
# TaxerInfo 模块改写为 Tauri v1单窗口 + Vue设计文档
# TaxerInfo 迁移说明文档(节选)
## 1. 文档目标
`CallClient/WPF/TaxerInfo.xaml.cs` + `CallClient/WPF/TaxerInfo.xaml` 对应功能迁移到 **Tauri v1 + Vue单窗口** 架构,确保:
- 业务行为与现有 WPF 一致(取号、开始办理、办结、实名信息、企业列表、网页加载、复制、查询)。
- 桌面能力由 TauriRust提供界面由 Vue 负责。
- 后续可以逐步替换原 `WebBrowser` 内嵌业务页,最终统一为前端组件化实现。
---
## 2. 现有模块功能盘点WPF
## 2.1 核心职责
`TaxerInfo` 窗口当前承担:
1. 展示办税员实名信息(身份证图、头像、姓名、手机号、采集状态)。
2. 展示当前取号下的企业/票据列表,支持“开始”“办结”“选择企业”。
3. 根据当前票据拼接 URL 并加载纳税人页面(`WebBrowser`)。
4. 提供操作按钮:复制税号、复制号码、刷新、一户式查询。
5. 展示“代理人办理企业清单”(通过 HTTP 接口获取)。
## 2.2 主要数据模型
- `ticket`:票据/办理对象(状态、企业信息、号票信息等)。
- `Enterprise`:代理人办理企业清单项。
- `SerialNumber`
- `EnterpriseName`
- `TaxpayerId`
- `ApiResponse / DataItem``today-enterprises` 接口返回结构。
- 外部响应:
- `SmzJhxtGetBsyxxByqhhmResponse`
- `SmzJhxtGetSmzcjxxResponse`
- `SmzJhxtGetBdNsrxxResponse`
## 2.3 关键流程
1. `LoadInfo(comparecode)` 触发加载:
- 调实名接口、绑定纳税人接口、绑定企业接口;
- 查询当日关联票据列表;
- 填充 UI。
2. 票据切换(`SelectedSubTicket`
- 更新“当前办理文案”;
- 刷新纳税人页面 URL。
3. 开始办理(`StartCommand`
- 更新票据状态为办理中;
- 记录窗口/员工信息;
- 刷新右侧页面。
4. 办结(`EndCommand`
- 结束当前票据;
- 浏览器跳转 `about:blank`
---
## 3. 迁移目标架构Tauri v1
## 3.1 技术分层
- **前端Vue**
- 单窗口页面:`TaxerInfoView.vue`
- 状态管理Pinia推荐或 Vue reactive store
- UI 组件Element Plus / Ant Design Vue任选
- **Tauri 后端Rust**
- `#[tauri::command]` 提供桌面能力与系统访问
- 封装对旧服务接口调用(可直接 HTTP 调用)
- 封装票据数据库/SDK 调用(通过 Rust 侧 adapter
- **桥接**
- `invoke()`:前端调用命令
- `event`:推送异步状态(可选)
## 3.2 单窗口布局建议(与 WPF 对齐)
- 左侧(固定宽度)
- 办税员实名信息卡片(身份证图 + 头像 + 姓名 + 手机 + 采集状态)
- 企业/票据列表(开始、办结按钮)
- “选择企业”按钮
- 右侧(自适应)
- 顶部操作栏(当前办理文案 + 四个按钮)
- Tabs
- `纳税人信息``iframe/webview` 或前端重构页
- `代理人办理企业清单`:表格展示 `EnterpriseList`
> 说明:仓库中若曾存在更完整的迁移章节,可与本节合并。
> **本节仅描述「纳税人信息」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<String>) -> ()`
- **空模板**:若 `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<EnterpriseDto>`
### 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/IPCTauri 调该服务;
- 等功能回归稳定后再考虑 Rust 直连库替换。
### C. URL 模板能力
- `Analize_URL` 的 6 个占位符替换规则必须逐字兼容:
- `{djxh}`、`{sfzhm}`、`{sflb}`、`{swjgDm}`、`{qhhm}`、`{sjhm}`
## 13.8 额外注意点(代码库现状)
- `SmzJhxtGetBdNsrxxRequest` 类定义在 `SmzJhxtGetBsyGlqyxxRequest.cs` 文件中(文件名与类名不一致),迁移时建议统一命名避免维护歧义。
- `HttpHelper.httpGet` 未设置超时与异常细化Tauri 迁移时建议新增超时、重试与可观测日志。
---
## 14. 基于现有 Tauri 项目(`F:\workspace\zyclient_linux\TauriClient\call-client`)的落位分析
本节基于当前 Tauri v1 代码现状补充,目的是将 `TaxerInfo` 改写方案落到“现项目可实施”的路径,而非抽象设计。
## 14.1 当前 Tauri 主结构(已实现)
- 前端路由:
- `/main` -> `MainView.vue`
- `/ticketList` -> `TicketListView.vue`
- `/login` -> `LoginView.vue`
- `/setup` -> `ServerSetupView.vue`
- Rust 窗口命令(已实现):
- `open_ticket_window`
- `close_ticket_window`
- `focus_window`
- `open_main_window`
- `open_login_window`
- 当前窗口形态:
- `main` 窗口:`500x100`、`always_on_top`、无边框(条形呼叫台)
- `ticketList` 窗口:`1024x720`、无边框、独立路由页
## 14.2 “办税员窗口”当前行为(关键结论)
`MainView.vue` 的“更多菜单”里确有 `办税员窗口` 按钮,但目前逻辑是:
- `handleMoreCommand("main")` 仅提示“当前已在办税员窗口”
- 并不会打开新的 TaxerInfo 页面/窗口
这说明:**现项目尚未承载 C# `TaxerInfo` 的完整 UI 与业务**,仅有一个呼叫控制条主窗。
## 14.3 与 `TaxerInfo` 的差距(按能力分层)
### A. 窗口与页面承载差距
- 现 `main` 尺寸为 `500x100`,不具备 `TaxerInfo`(约 `1200x600`)承载条件。
- 当前仅有一个次窗口 `ticketList`,尚无 `taxerInfo` 路由/窗口定义。
### B. 接口协议差距
- 现有 `src/api/index.ts` 全部走 `API_QUEUE_CALLER_PATH=/api/queue/caller`JSON REST
- `TaxerInfo` 依赖的百税接口协议是 form-url-encoded + `domain.ywId/domain.parmJson`,目前项目中尚未实现该适配器。
### C. 数据源差距
- 现 `MainView/TicketListView` 主要消费 `call-terminal/*` 接口(呼叫、票池、评价)。
- `TaxerInfo` 需要额外数据源:
- 百税 9.2.2 / 9.2.12 / 9.2.3
- 紫云 `today-enterprises`
- 本地票据扩展信息(企业名、税号映射等)
## 14.4 在现项目中建议的落位方案(不改代码阶段的设计结论)
### 方案选型
推荐新增独立窗口 `taxerInfo`(与 `ticketList` 同级),而不是直接塞入当前 `500x100` 主窗:
1. 保留 `main` 作为呼叫条形控制台(最小侵入)。
2. 在“更多菜单 -> 办税员窗口”中打开/聚焦 `taxerInfo`
3. `taxerInfo` 使用新路由页面(建议 `/taxerInfo`)承载原 WPF 界面。
### 这样做的原因
- 与现有多窗口架构一致(已有 `ticketList` 模式可复用)。
- 不影响当前呼叫链路稳定性。
- 便于逐步迁移 `TaxerInfo` 而非一次性替换 `MainView`
## 14.5 与现项目文件的映射建议
- 前端:
- 新增 `src/views/TaxerInfoView.vue`
- `src/router/index.ts` 增加 `/taxerInfo`
- `src/host/window.ts` 增加 `openTaxerInfoWindow()`
- `src/views/MainView.vue``handleMoreCommand("main")` 改为打开/聚焦 `taxerInfo`
- Rust
- `src-tauri/src/commands/window.rs` 增加
- `open_taxer_info_window`
- `close_taxer_info_window`(可选)
- `src-tauri/src/lib.rs` 注册上述命令
## 14.6 `TaxerInfo` 接口在现项目中的接入位置建议
### 前端 API 层
`src/api` 下新增 `taxer.ts`,避免和 `call-terminal` 混杂:
- `loadTaxerInfo(compareCode, currentTicketId)`
- `startTaxerTicket(...)`
- `endTaxerTicket(...)`
- `getTodayEnterprises(ticketId)`
- `buildNsrUrl(...)` / `buildYhscxUrl(...)`(可前后端任选)
### HTTP 传输层
沿用 `src/utils/service.ts` 的 axios + Tauri HTTP adapter 机制,但需要区分两类 baseURL
1. 现有 `API_QUEUE_CALLER_PATH`
2. 百税 API 专用 baseURL`REALNAME_CHECK_API`+ form 编码适配
## 14.7 文档结论更新(针对你的目标)
结合当前 Tauri 项目,`TaxerInfo` 改写最可行路径是:
- **先新增 `taxerInfo` 独立窗口**(由 `MainView` 的“办税员窗口”按钮打开)
- **再迁移 C# `TaxerInfo` 的 UI 与接口编排**
- **最后再评估是否把条形主窗与办税员窗合并**
这样既满足“在 Tauri 主流程中打开办税员窗口”,又不会破坏现有呼叫台功能。
**文档版本**:节选 — 仅「纳税人信息」Tab 页面功能。
**对应源码**`CallClient/WPF/TaxerInfo.xaml`TabItem「纳税人信息」、`CallClient/WPF/TaxerInfo.xaml.cs``Analize_URL`、`Load_BS_NSR_URL`、`EndCommand` 内 `about:blank`、`Reflesh_Click`)。

@ -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",

@ -1,7 +1,7 @@
{
"name": "call-client",
"private": true,
"version": "0.1.4",
"version": "0.1.5",
"type": "module",
"scripts": {
"dev": "vite",

@ -392,7 +392,7 @@ dependencies = [
[[package]]
name = "call-client"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"chrono",
"fs2",

@ -1,6 +1,6 @@
[package]
name = "call-client"
version = "0.1.4"
version = "0.1.5"
description = "A Tauri App"
authors = ["you"]
edition = "2021"

@ -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",

@ -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<CallResponse>("/call-terminal/call", data),
init: (data: CallRequest) => http.post<CallResponse>("/call-terminal/init", data),
recall: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/recall", data),
abandon: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/abandon", data),
pause: (data: PauseRequest) => http.post<CallResponse>("/call-terminal/pause", data),
resume: (data: CallRequest) => http.post<CallResponse>("/call-terminal/resume", data),
start: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/start", data),
complete: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/complete", data),
evaluate: (data: ReCallRequest) => http.post<CallResponse>("/call-terminal/evaluate", data),
pool: (params: TicketPoolRequest) => http.get<TicketPoolResponse>("/call-terminal/pool", params),
isRank: (params: IsRankRequest) => http.get<IsRankData>("/call-terminal/is-rank", params),
call: (data: CallRequest) =>
http.post<CallResponse>("/call-terminal/call", data),
callerInit: (data: CallRequest) =>
http.post<CallerInitData>("/call-terminal/caller-init", data),
recall: (data: ReCallRequest) =>
http.post<CallResponse>("/call-terminal/recall", data),
abandon: (data: ReCallRequest) =>
http.post<CallResponse>("/call-terminal/abandon", data),
pause: (data: PauseRequest) =>
http.post<CallResponse>("/call-terminal/pause", data),
resume: (data: CallRequest) =>
http.post<CallResponse>("/call-terminal/resume", data),
start: (data: ReCallRequest) =>
http.post<CallResponse>("/call-terminal/start", data),
complete: (data: ReCallRequest) =>
http.post<CallResponse>("/call-terminal/complete", data),
evaluate: (data: ReCallRequest) =>
http.post<CallResponse>("/call-terminal/evaluate", data),
pool: (params: TicketPoolRequest) =>
http.get<TicketPoolResponse>("/call-terminal/pool", params),
isRank: (params: IsRankRequest) =>
http.get<IsRankData>("/call-terminal/is-rank", params),
getQueueCount: (params: QueueCountRequest) =>
http.get<QueueCountData>("/call-terminal/queue-count", params),
callerInit: () => http.post<CallerInitData>("/call-terminal/caller-init", {}),
},
};

@ -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<void> {
try {
await log(level, message);
} catch {
// ignore
}
}
function buildAnalyzeContext(
partial: Pick<AnalyzeBsNsrUrlContext, "bsNsrUrlTemplate" | "bsTaxAuthorityNum"> & {
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<void> {
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,
};
}

@ -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<void> {
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<TaxerTicketContextPayload>(TAXER_TICKET_START_EVENT, (event) => {
handler(event.payload ?? {});
});
} catch (error) {
throw new Error(`订阅办税员开始办理事件失败: ${String(error)}`);
}
}
/** 办结/弃号等场景清空纳税人内嵌页 */
export async function emitTaxerNsrClear(): Promise<void> {
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)}`);
}
}

@ -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,

@ -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<string, JsonValue>;

@ -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;
}

@ -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();
}
}

@ -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<string, unknown>,
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<string, unknown>, 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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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})`;
}
}

@ -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<string, unknown>,
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),
};
}

@ -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,43 +310,22 @@ async function handleWindowLogin(): Promise<void> {
(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 mergeConfig({
last_username: loginForm.username.trim(),
selected_win_key: selectedWin.value,
selected_win_value: selected?.label ?? "",
selected_win_uid: winUid,
});
sessionState.winUid = winUid;
await setSession(sessionState);
await log(
"info",
`call-terminal/caller-init 响应字段摘要: ${summarizeInitResponseKeys(callerInit)}`,
);
await log(
"info",
`call-terminal/caller-init 响应正文(截断): ${formatInitResponseForLog(callerInit)}`,
);
// caller-init data session
try {
const callerInit = await api.action.callerInit();
const bsNsrUrl = pickBsNsrUrlFromInit(callerInit);
const bsTaxAuthorityNum = pickBsTaxAuthorityNumFromInit(callerInit);
const callerWin = Number(callerInit.windowUid);
const resolvedWinUid =
Number.isFinite(callerWin) && callerWin > 0 ? callerWin : winUid;
@ -348,6 +334,29 @@ async function handleWindowLogin(): Promise<void> {
? 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纳税人信息页将无法拼 URLwindowUid=${resolvedWinUid}`,
);
}
await mergeConfig({
last_username: loginForm.username.trim(),
selected_win_key: selectedWin.value,
selected_win_value: selected?.label ?? "",
selected_win_uid: resolvedWinUid,
});
sessionState = {
...sessionState,
winUid: resolvedWinUid,
@ -366,16 +375,10 @@ async function handleWindowLogin(): Promise<void> {
realnameCheckApi: String(callerInit.realnameCheckApi ?? ""),
rankWaitSeconds: Number(callerInit.rankWaitSeconds ?? 0),
ziyunServiceUrl: String(callerInit.ziyunServiceUrl ?? ""),
bsNsrUrl: bsNsrUrl || null,
bsTaxAuthorityNum: bsTaxAuthorityNum || null,
};
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) {

@ -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<SessionState>({
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<typeof setInterval> | null = null;
/** 计时器内部剩余秒数(暂停时保留,恢复后续计) */
let autoCallTickLeft = 0;
const autoCallCountdownLeft = ref(0);
/** 待机轮询得到的等候人数,倒计时期间用于 log-span 展示 */
const idleQueueCount = ref<number | null>(null);
/** 自动开始倒计时caller-init 的 autoStartWaitSecondscalling 状态) */
let autoStartCountdownTimer: ReturnType<typeof setInterval> | 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<void> {
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<string, unknown>;
return buildTaxerContextFromActionData(
data,
getActionTicketNo(res),
getActionSfzhm(res),
);
}
async function emitTaxerContextFromAction(res: unknown): Promise<void> {
await emitTaxerTicketContext(buildTaxerContextFromAction(res));
}
/**
* 提取票据 UID
*/
@ -423,6 +857,8 @@ async function refreshMainSessionStateFromDisk(): Promise<void> {
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<void> {
* 轮询当前窗口等候人数
*/
async function pollQueueCountOnce(): Promise<void> {
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<void> {
"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<ActionButton[]>(() => {
const startOrComplete =
callStatus.value === "working"
@ -616,8 +1123,14 @@ async function openTicketList(): Promise<void> {
/**
* 发起呼叫或重呼
* @param triggeredByAutoCall true 时表示由自动叫号倒计时触发不清除自动叫号倒计时
*/
async function callAction(): Promise<void> {
async function callAction(options?: {
triggeredByAutoCall?: boolean;
}): Promise<void> {
if (!options?.triggeredByAutoCall) {
stopAutoCallCountdown("手动点击呼叫");
}
if (isActionPending.value) {
return;
}
@ -634,15 +1147,13 @@ async function callAction(): Promise<void> {
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<void> {
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<void> {
callStatus.value = "idle";
callBtnText.value = "呼叫";
callingTkt.value = -1;
callingTicketNoLabel.value = "";
updateLog(`弃号成功: ${getActionTicketNo(res)}`);
await emitTaxerNsrClear();
return;
}
@ -705,8 +1216,14 @@ async function abandonAction(): Promise<void> {
/**
* 开始办理
* @param triggeredByAutoStart true 时表示由自动开始倒计时触发不清除自动开始倒计时
*/
async function startAction(): Promise<void> {
async function startAction(options?: {
triggeredByAutoStart?: boolean;
}): Promise<void> {
if (!options?.triggeredByAutoStart) {
stopAutoStartCountdown("手动点击开始");
}
if (isActionPending.value) {
return;
}
@ -721,6 +1238,7 @@ async function startAction(): Promise<void> {
if (isActionSuccess(res)) {
callStatus.value = "working";
updateLog(`正在办理:${getActionTicketNo(res)}`);
await emitTaxerTicketStart(buildTaxerContextFromAction(res));
}
});
} catch (error) {
@ -747,6 +1265,7 @@ async function completeAction(): Promise<void> {
return;
}
await emitTaxerNsrClear();
callStatus.value = "evaluating";
callBtnText.value = "呼叫";
startEvaluatingCountdown(`办理完成:${getActionTicketNo(res)},评价中`);
@ -855,7 +1374,9 @@ async function confirmPauseReason(reason: string): Promise<void> {
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;
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();
};
const onBlur = () => {
isMainWindowActive.value = false;
};
window.addEventListener("focus", onFocus);
window.addEventListener("blur", onBlur);
}
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) {

@ -1,20 +1,50 @@
<script setup lang="ts">
import { Close, Minus } from "@element-plus/icons-vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { Close, Minus, Refresh } from "@element-plus/icons-vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { getTodayEnterprises, loadTaxerProfile, type TaxerEnterprise } from "../api/taxer";
import { listenTaxerTicketContext } from "../host/events";
import { useBsNsrBrowser } from "../composables/useBsNsrBrowser";
import {
listenTaxerNsrClear,
listenTaxerTicketContext,
listenTaxerTicketStart,
} from "../host/events";
import { log } from "../host/logger";
import { getSession, setSession } from "../host/session";
import type { SessionState } from "../host/types";
import type { SessionState, TaxerTicketContextPayload } from "../host/types";
import { closeTaxerInfoWindow, minimizeWindow } from "../host/window";
import type { TaxerTicketForNsr } from "../types/taxerTicket";
import {
buildPersonalTicket,
ticketFromContext,
ticketFromEnterprise,
} from "../utils/taxerTicketContext";
const loading = ref(true);
const activeTab = ref<"nsr" | "enterprise">("nsr");
const enterprises = ref<TaxerEnterprise[]>([]);
const compareCode = ref("");
const currentTicketId = ref("");
const taxNoLookup = ref<Record<string, string>>({});
const realnameHint = ref("");
const currentCompanyLabel = ref("");
const currentTicket = ref<TaxerTicketForNsr | null>(null);
const selectedSubTicket = ref<TaxerTicketForNsr | null>(null);
let unlistenTaxerTicketContext: (() => void) | null = null;
let unlistenTaxerTicketStart: (() => void) | null = null;
let unlistenTaxerNsrClear: (() => void) | null = null;
const {
iframeSrc,
iframePageLoading,
loadBsNsrUrl,
navigateToBlank,
onIframeLoad,
reloadIframe,
canReloadIframe,
buildAnalyzeContext,
} = useBsNsrBrowser();
const profile = ref({
name: "办税员",
@ -35,12 +65,42 @@ const sessionState = ref<SessionState>({
});
const canLoadProfile = computed(() => compareCode.value.trim() !== "");
const nsrHiddenByLoading = computed(() => loading.value);
const nsrOverlayLoading = computed(
() => iframePageLoading.value && !nsrHiddenByLoading.value,
);
const bsNsrUrlTemplate = computed(() =>
typeof sessionState.value.bsNsrUrl === "string"
? sessionState.value.bsNsrUrl.trim()
: "",
);
const bsTaxAuthorityNum = computed(() =>
typeof sessionState.value.bsTaxAuthorityNum === "string"
? sessionState.value.bsTaxAuthorityNum.trim()
: "",
);
function analyzeContext() {
return buildAnalyzeContext({
bsNsrUrlTemplate: bsNsrUrlTemplate.value,
bsTaxAuthorityNum: bsTaxAuthorityNum.value,
taxerIdCard: profile.value.idCard.trim(),
phoneNo: profile.value.phone.trim(),
});
}
/** 当前应展示纳税人页的票:有子票用子票,否则主票 */
function ticketForNsrDisplay(): TaxerTicketForNsr | null {
return selectedSubTicket.value ?? currentTicket.value;
}
async function safeLog(level: "warn" | "error" | "info", message: string): Promise<void> {
try {
await log(level, message);
} catch {
//
//
}
}
@ -60,8 +120,65 @@ async function saveTaxerRuntimeToSession(): Promise<void> {
}
function getTaxNoByDjxh(djxh: string): string {
const value = taxNoLookup.value[djxh];
return value ?? "";
return taxNoLookup.value[djxh] ?? "";
}
function updateCompanyLabel(): void {
const sub = selectedSubTicket.value;
if (sub && sub.isEnterprise === 1) {
const ent = enterprises.value.find((e) => e.taxpayerId === sub.enterpriseId);
currentCompanyLabel.value = ent?.enterpriseName
? `${ent.enterpriseName}${sub.enterpriseId}`
: sub.enterpriseId || "-";
return;
}
currentCompanyLabel.value = currentTicket.value?.tktId
? `个人业务 · ${currentTicket.value.tktId}`
: "";
}
async function refreshNsrForCurrentTicket(reason: string): Promise<void> {
const ticket = ticketForNsrDisplay();
if (!ticket) {
navigateToBlank(`${reason}:无票`);
return;
}
if (ticket.isEnterprise !== 1 && selectedSubTicket.value === null) {
await loadBsNsrUrl(ticket, analyzeContext(), reason);
return;
}
if (selectedSubTicket.value) {
await loadBsNsrUrl(selectedSubTicket.value, analyzeContext(), reason);
}
}
function applyCurrentTicketFromContext(payload: TaxerTicketContextPayload): void {
const ticket = ticketFromContext(payload);
currentTicket.value = ticket;
if (ticket && ticket.isEnterprise === 1) {
selectedSubTicket.value = null;
} else if (ticket) {
selectedSubTicket.value = null;
}
updateCompanyLabel();
}
async function onSelectedSubTicketChanged(reason: string): Promise<void> {
updateCompanyLabel();
const sub = selectedSubTicket.value;
if (!sub) {
return;
}
await loadBsNsrUrl(sub, analyzeContext(), reason);
}
function selectEnterpriseItem(item: TaxerEnterprise): void {
const mainId = currentTicketId.value.trim();
if (!mainId) {
return;
}
selectedSubTicket.value = ticketFromEnterprise(mainId, item);
void onSelectedSubTicketChanged("选中子票");
}
async function loadProfileByCompareCode(): Promise<void> {
@ -72,7 +189,9 @@ async function loadProfileByCompareCode(): Promise<void> {
}
await saveTaxerRuntimeToSession();
const realnameApiBase =
typeof sessionState.value.realnameCheckApi === "string" ? sessionState.value.realnameCheckApi.trim() : "";
typeof sessionState.value.realnameCheckApi === "string"
? sessionState.value.realnameCheckApi.trim()
: "";
const realnameApiUrl = realnameApiBase
? realnameApiBase.includes("/taxCommon/doService")
? realnameApiBase
@ -87,10 +206,10 @@ async function loadProfileByCompareCode(): Promise<void> {
try {
const { bsyxx, smzcj, bdNsrxx } = await loadTaxerProfile(qhhm);
taxNoLookup.value = {};
(bdNsrxx?.dataList ?? []).forEach((item) => {
const djxh = String(item.djxh ?? "");
(bdNsrxx?.dataList ?? []).forEach((row) => {
const djxh = String(row.djxh ?? "");
if (djxh) {
taxNoLookup.value[djxh] = String(item.nsrsbh ?? "");
taxNoLookup.value[djxh] = String(row.nsrsbh ?? "");
}
});
@ -115,10 +234,27 @@ async function loadProfileByCompareCode(): Promise<void> {
await safeLog("error", `加载实名信息失败: ${message}`);
} finally {
loading.value = false;
await refreshNsrAfterDataReady("实名/采集数据就绪");
}
}
async function refreshNsrAfterDataReady(reason: string): Promise<void> {
if (selectedSubTicket.value) {
await loadBsNsrUrl(selectedSubTicket.value, analyzeContext(), reason);
return;
}
if (currentTicket.value && currentTicket.value.isEnterprise !== 1) {
await loadBsNsrUrl(currentTicket.value, analyzeContext(), reason);
return;
}
if (currentTicket.value?.isEnterprise === 1 && enterprises.value.length > 0) {
selectEnterpriseItem(enterprises.value[0]);
}
}
async function applyTicketContextFromCall(payload: { ticketNo?: string; sfzhm?: string }): Promise<void> {
async function applyTicketContextFromCall(
payload: TaxerTicketContextPayload,
): Promise<void> {
const ticketNo = String(payload.ticketNo ?? "").trim();
const sfzhm = String(payload.sfzhm ?? "").trim();
if (!ticketNo) {
@ -132,11 +268,35 @@ async function applyTicketContextFromCall(payload: { ticketNo?: string; sfzhm?:
realnameHint.value = "";
profile.value.idCard = sfzhm;
await safeLog("info", `TaxerInfo 收到票号上下文: ticketNo=${ticketNo}, hasSfzhm=${sfzhm ? "yes" : "no"}`);
applyCurrentTicketFromContext(payload);
if (!currentTicket.value) {
currentTicket.value = buildPersonalTicket(ticketNo);
}
await safeLog(
"info",
`TaxerInfo 收到票号上下文: ticketNo=${ticketNo}, isEnterprise=${currentTicket.value.isEnterprise}`,
);
await loadEnterprisesByTicketId();
await loadProfileByCompareCode();
}
async function applyTicketStartFromMain(
payload: TaxerTicketContextPayload,
): Promise<void> {
applyCurrentTicketFromContext(payload);
const ticket = ticketFromContext(payload) ?? currentTicket.value;
if (!ticket) {
return;
}
currentTicket.value = ticket;
await loadBsNsrUrl(
selectedSubTicket.value ?? ticket,
analyzeContext(),
"主窗口开始办理",
);
}
async function loadEnterprisesByTicketId(): Promise<void> {
const ticketId = currentTicketId.value.trim();
if (!ticketId) {
@ -155,6 +315,9 @@ async function loadEnterprisesByTicketId(): Promise<void> {
loading.value = true;
try {
enterprises.value = await getTodayEnterprises(ticketId);
if (currentTicket.value?.isEnterprise === 1 && enterprises.value.length > 0) {
selectEnterpriseItem(enterprises.value[0]);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await safeLog("error", `加载企业失败: ${message}`);
@ -163,16 +326,49 @@ async function loadEnterprisesByTicketId(): Promise<void> {
}
}
watch(
() => profile.value.phone,
() => {
if (!loading.value && ticketForNsrDisplay()) {
void refreshNsrForCurrentTicket("手机号更新");
}
},
);
watch(
() => profile.value.idCard,
() => {
if (!loading.value && ticketForNsrDisplay()) {
void refreshNsrForCurrentTicket("身份证更新");
}
},
);
onMounted(async () => {
try {
const session = await getSession();
const defaultCompareCode =
typeof session.taxer_compare_code === "string" ? session.taxer_compare_code.trim() : "";
typeof session.taxer_compare_code === "string"
? session.taxer_compare_code.trim()
: "";
const defaultTicketId =
typeof session.taxer_ticket_id === "string" ? session.taxer_ticket_id.trim() : "";
typeof session.taxer_ticket_id === "string"
? session.taxer_ticket_id.trim()
: "";
compareCode.value = defaultCompareCode;
currentTicketId.value = defaultTicketId;
sessionState.value = session;
if (!bsNsrUrlTemplate.value) {
await safeLog(
"warn",
"bsNsrUrl 未配置(请确认登录后 call-terminal/caller-init 已返回并写入 session",
);
}
if (defaultTicketId) {
currentTicket.value = buildPersonalTicket(defaultTicketId);
}
if (compareCode.value) {
void loadProfileByCompareCode();
}
@ -190,16 +386,24 @@ onMounted(async () => {
unlistenTaxerTicketContext = await listenTaxerTicketContext((payload) => {
void applyTicketContextFromCall(payload);
});
unlistenTaxerTicketStart = await listenTaxerTicketStart((payload) => {
void applyTicketStartFromMain(payload);
});
unlistenTaxerNsrClear = await listenTaxerNsrClear(() => {
navigateToBlank("主窗口办结/弃号");
selectedSubTicket.value = null;
updateCompanyLabel();
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await safeLog("error", `订阅票号上下文失败: ${message}`);
await safeLog("error", `订阅办税员事件失败: ${message}`);
}
});
onUnmounted(() => {
if (unlistenTaxerTicketContext) {
unlistenTaxerTicketContext();
}
unlistenTaxerTicketContext?.();
unlistenTaxerTicketStart?.();
unlistenTaxerNsrClear?.();
});
async function handleMinimizeClick(event: MouseEvent): Promise<void> {
@ -223,12 +427,18 @@ async function handleCloseClick(event: MouseEvent): Promise<void> {
await safeLog("error", `关闭办税员窗口失败: ${message}`);
}
}
function handleNsrRefreshClick(): void {
reloadIframe();
}
</script>
<template>
<div class="taxer-root">
<div class="taxer-header">
<div class="taxer-header-title" data-tauri-drag-region @dblclick.prevent.stop>办税员窗口</div>
<div class="taxer-header-title" data-tauri-drag-region @dblclick.prevent.stop>
办税员窗口
</div>
<div class="taxer-header-actions">
<button
class="control-button"
@ -253,6 +463,7 @@ async function handleCloseClick(event: MouseEvent): Promise<void> {
</button>
</div>
</div>
<div class="content">
<div class="left-pane">
<div class="card profile-card">
@ -286,29 +497,82 @@ async function handleCloseClick(event: MouseEvent): Promise<void> {
<div class="line"><span>采集状态</span>{{ profile.collectStatus }}</div>
<div class="line"><span>备注</span>{{ profile.memo || "-" }}</div>
</div>
</div>
<div class="right-pane">
<el-tabs v-model="activeTab" class="taxer-tabs">
<el-tab-pane label="纳税人信息" name="nsr">
<div class="nsr-tab">
<div class="nsr-toolbar">
<span class="nsr-company">{{ currentCompanyLabel || "未选择业务票" }}</span>
<el-button
size="small"
:disabled="!canReloadIframe()"
@click="handleNsrRefreshClick"
>
<el-icon class="nsr-refresh-icon">
<Refresh />
</el-icon>
刷新
</el-button>
</div>
<div class="nsr-browser-wrap">
<div
v-if="nsrHiddenByLoading || nsrOverlayLoading"
class="nsr-loading-mask"
>
{{ nsrHiddenByLoading ? "数据加载中…" : "页面加载中…" }}
</div>
<iframe
v-show="!nsrHiddenByLoading"
class="nsr-iframe"
:src="iframeSrc"
title="纳税人信息"
@load="onIframeLoad"
/>
<div v-if="!bsNsrUrlTemplate && !nsrHiddenByLoading" class="nsr-empty-hint">
未配置 BS_NSR_URL无法打开纳税人信息页
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="代理人办理企业清单" name="enterprise">
<div class="card enterprise-card">
<div class="card-title">代理人办理企业清单</div>
<div class="hint">当前票号{{ currentTicketId || "-" }}</div>
<div class="hint">窗口{{ sessionState.winUid ?? "-" }}员工{{ sessionState.empUid ?? "-" }}</div>
<div v-if="loading" class="hint">...</div>
<div v-else-if="enterprises.length === 0" class="hint">
暂无数据
<div class="hint">
窗口{{ sessionState.winUid ?? "-" }}员工{{ sessionState.empUid ?? "-" }}
</div>
<div v-if="loading" class="hint">...</div>
<div v-else-if="enterprises.length === 0" class="hint">暂无数据</div>
<div v-else class="enterprise-list">
<div v-for="item in enterprises" :key="item.serialNumber || item.taxpayerId" class="enterprise-item">
<div
v-for="item in enterprises"
:key="item.serialNumber || item.taxpayerId"
class="enterprise-item"
:class="{
selected:
selectedSubTicket?.enterpriseId === item.taxpayerId &&
selectedSubTicket?.isEnterprise === 1,
}"
role="button"
tabindex="0"
@click="selectEnterpriseItem(item)"
@keydown.enter.prevent="selectEnterpriseItem(item)"
>
<div class="enterprise-main">
<span class="name">{{ item.enterpriseName || "-" }}</span>
<span class="tax">{{ item.taxpayerId || "-" }}</span>
</div>
<div class="line">序号{{ item.serialNumber || "-" }}</div>
<div class="line">税号映射{{ getTaxNoByDjxh(item.taxpayerId) || "-" }}</div>
<div class="line">
税号映射{{ getTaxNoByDjxh(item.taxpayerId) || "-" }}
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
@ -391,9 +655,96 @@ async function handleCloseClick(event: MouseEvent): Promise<void> {
.right-pane {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.taxer-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.taxer-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
.taxer-tabs :deep(.el-tab-pane) {
height: 100%;
}
.nsr-tab {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.nsr-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
}
.nsr-company {
font-size: 13px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nsr-refresh-icon {
margin-right: 4px;
}
.nsr-browser-wrap {
position: relative;
flex: 1;
min-height: 0;
background: #fff;
border-radius: 6px;
overflow: hidden;
}
.nsr-iframe {
width: 100%;
height: 100%;
border: none;
display: block;
background: #fff;
}
.nsr-loading-mask {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
background: rgba(17, 24, 39, 0.85);
color: #e5e7eb;
font-size: 14px;
}
.nsr-empty-hint {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
text-align: center;
color: #6b7280;
font-size: 14px;
pointer-events: none;
}
.card {
@ -427,8 +778,10 @@ async function handleCloseClick(event: MouseEvent): Promise<void> {
}
.enterprise-card {
flex: 1;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.card-title {
@ -452,31 +805,21 @@ async function handleCloseClick(event: MouseEvent): Promise<void> {
margin-bottom: 10px;
}
.actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.hint {
display: flex;
align-items: center;
justify-content: flex-start;
color: #9ca3af;
font-size: 14px;
}
.empty {
color: #9ca3af;
font-size: 13px;
padding: 8px 2px;
margin-bottom: 6px;
}
.enterprise-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 100%;
flex: 1;
min-height: 0;
overflow: auto;
}
@ -485,6 +828,16 @@ async function handleCloseClick(event: MouseEvent): Promise<void> {
border-radius: 6px;
padding: 8px 10px;
background: #111827;
cursor: pointer;
}
.enterprise-item:hover {
border-color: #60a5fa;
}
.enterprise-item.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.45);
}
.enterprise-main {

Loading…
Cancel
Save