Compare commits
15 Commits
feature/ga
...
iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dec2b42f8 | ||
|
|
0d094a0537 | ||
|
|
c907d6e21f | ||
|
|
227dd63bb8 | ||
|
|
8fe65ca66d | ||
|
|
8b01169219 | ||
|
|
3411b66c71 | ||
|
|
984db9889c | ||
|
|
49a4d32eab | ||
|
|
d24506ab54 | ||
|
|
8569255f75 | ||
|
|
e41adb4505 | ||
|
|
2c8de04041 | ||
|
|
98a4be8719 | ||
|
|
f5730089a1 |
@@ -3,12 +3,11 @@ name: 构建并部署前端到测试环境
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feature/gateway-rate-limit
|
||||
- iteration/august
|
||||
- iteration/8-11
|
||||
- dev
|
||||
- test
|
||||
# 如果不再需要 main 分支触发构建,可以把下面这行删掉;
|
||||
# 如果只是想保留构建(不部署),可以留着,反正部署条件已经只认 iteration/august
|
||||
# 如果只是想保留构建(不部署),可以留着,反正部署条件已经只认 iteration/8-11
|
||||
- main
|
||||
|
||||
env:
|
||||
@@ -41,7 +40,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 部署到测试环境
|
||||
if: github.ref == 'refs/heads/feature/gateway-rate-limit'
|
||||
if: github.ref == 'refs/heads/iteration/8-11'
|
||||
run: |
|
||||
mkdir -p "${{ env.DEPLOY_DIR }}"
|
||||
cp -v docker-compose.prod.yml "${{ env.DEPLOY_DIR }}/"
|
||||
|
||||
23
openspec/changes/add-iot-card-recall-line-input/proposal.md
Normal file
23
openspec/changes/add-iot-card-recall-line-input/proposal.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Change: 批量回收支持一行一个 ICCID
|
||||
|
||||
用户已确认并完成实施;验证结果见 `tasks.md`。
|
||||
|
||||
## Why
|
||||
|
||||
IoT 卡管理的批量回收目前只能从表格勾选 ICCID、输入号段范围或使用筛选条件。已有卡号清单的用户需要直接粘贴每行一个 ICCID,并复用现有 ICCID 列表的回收接口。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在“批量回收”弹窗的选卡方式中增加“一行一个ICCID”,选择后显示带标签和输入提示的多行文本框,沿用现有 Element Plus 表单样式。
|
||||
- 按换行拆分输入,去除每行首尾空白并忽略空行;ICCID 始终按字符串处理,不作数字转换。不增加额外的卡号长度或格式限制。
|
||||
- 提交时仍调用 `POST /api/admin/iot-cards/standalone/recall`,使用 `selection_type: "list"` 和 `iccids: string[]`,不向接口发送新的选卡方式或文本框字段。
|
||||
- 没有有效输入时展示校验提示,不提交请求;切换方式后按当前方式构建参数,关闭再打开时清空手工输入。
|
||||
- 取消批量回收入口必须勾选表格的限制。有勾选时仍默认“ICCID列表”,无勾选时默认“一行一个ICCID”;原有列表模式仍校验必须有勾选项。
|
||||
- 保持号段范围、筛选条件、权限、备注、结果展示和刷新逻辑不变;不修改其他批量操作。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `iot-card-management`
|
||||
- Affected code: `src/views/asset-management/iot-card-management/index.vue`
|
||||
- Backend impact: 无;复用 `CardService.recallStandaloneCards` 及其现有列表请求结构。
|
||||
- Related changes: `update-batch-range-count-hints-formatting`(保留其号段数量提示)。
|
||||
@@ -0,0 +1,55 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: IoT Card Batch Recall Line Input
|
||||
|
||||
The IoT card batch recall dialog SHALL offer “一行一个ICCID” as an additional frontend-only selection mode alongside the existing ICCID list, range and filter modes.
|
||||
|
||||
The new mode MUST show a labeled multiline input with a hint to enter one ICCID per line. It MUST split LF and CRLF line endings, trim each line, ignore empty lines and preserve each ICCID as a string without numeric conversion or additional length or format restrictions.
|
||||
|
||||
Submitting this mode MUST call the existing `POST /api/admin/iot-cards/standalone/recall` endpoint with `selection_type: "list"`, the entered values in `iccids`, and the existing optional remark. It MUST NOT include selected table rows, range fields, filter fields or frontend-only fields in that request.
|
||||
|
||||
#### Scenario: Submit a pasted ICCID list
|
||||
|
||||
- **GIVEN** 用户选择“一行一个ICCID”并输入多个 ICCID,行首尾存在空白且内容中包含空行
|
||||
- **WHEN** 用户点击“确认回收”
|
||||
- **THEN** 系统 MUST 去除首尾空白和空行,将剩余各行作为字符串放入 `iccids`
|
||||
- **AND** 系统 MUST 使用原有列表模式的接口和 `selection_type: "list"` 提交
|
||||
- **AND** 系统 MUST 沿用现有成功数、失败项及列表刷新流程
|
||||
|
||||
#### Scenario: Reject empty manual input
|
||||
|
||||
- **GIVEN** 用户选择“一行一个ICCID”
|
||||
- **WHEN** 输入为空或仅包含空白和空行并尝试提交
|
||||
- **THEN** 系统 MUST 显示请输入 ICCID 的校验提示
|
||||
- **AND** 系统 MUST NOT 发送回收请求
|
||||
|
||||
#### Scenario: Keep each selection mode isolated
|
||||
|
||||
- **GIVEN** 用户在逐行输入与现有列表、号段或筛选方式之间切换
|
||||
- **WHEN** 用户提交当前选择方式
|
||||
- **THEN** 系统 MUST 仅根据当前方式构建请求参数
|
||||
- **AND** 原有列表、号段和筛选方式 MUST 保持既有行为及号段数量提示
|
||||
|
||||
### Requirement: IoT Card Batch Recall Manual Input Entry
|
||||
|
||||
The batch recall action SHALL remain permission-controlled and be available without selected table rows so users can enter ICCIDs directly. When opening the dialog, it MUST default to the existing list mode if rows are selected and to line input mode otherwise. The existing list mode MUST still reject submission without selected cards. Manual input MUST be cleared when the dialog is reopened.
|
||||
|
||||
#### Scenario: Open batch recall without selected rows
|
||||
|
||||
- **GIVEN** 用户拥有 `iot_card:batch_recycle` 权限且未勾选表格行
|
||||
- **WHEN** 用户点击“批量回收”
|
||||
- **THEN** 系统 MUST 打开弹窗并默认选择“一行一个ICCID”
|
||||
- **AND** 用户 MUST 能直接粘贴卡号,而无需先勾选无关卡片
|
||||
|
||||
#### Scenario: Preserve the selected-row workflow
|
||||
|
||||
- **GIVEN** 用户已在表格勾选卡片
|
||||
- **WHEN** 用户打开“批量回收”弹窗
|
||||
- **THEN** 系统 MUST 默认选择现有“ICCID列表”方式
|
||||
- **AND** 用户按列表方式提交时 MUST 继续使用已勾选卡片的 ICCID
|
||||
|
||||
#### Scenario: Clear manual input when reopening
|
||||
|
||||
- **GIVEN** 用户在“一行一个ICCID”方式下输入过卡号并关闭弹窗
|
||||
- **WHEN** 用户再次打开“批量回收”弹窗
|
||||
- **THEN** 手工输入框 MUST 为空,不得沿用上一次回收的输入
|
||||
31
openspec/changes/add-iot-card-recall-line-input/tasks.md
Normal file
31
openspec/changes/add-iot-card-recall-line-input/tasks.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## 1. Specification
|
||||
|
||||
- [x] 1.1 核对批量回收入口、选卡方式和现有列表模式接口。
|
||||
- [x] 1.2 明确逐行输入、空值校验、接口复用和既有模式兼容场景。
|
||||
- [x] 1.3 获得用户对提案的确认后开始实施(用户回复“执行”)。
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 新增仅用于前端的逐行输入选项和多行文本框,保留后端选卡方式枚举。
|
||||
- [x] 2.2 实现按行解析、非空校验及 `selection_type: "list"` 的请求参数构建。
|
||||
- [x] 2.3 调整无勾选时的入口和默认方式,并确保弹窗重开清空输入。
|
||||
- [x] 2.4 保持既有列表、号段、筛选、权限及操作结果流程不变。
|
||||
|
||||
## 3. Validation
|
||||
|
||||
- [x] 3.1 运行 `openspec validate add-iot-card-recall-line-input --strict`(通过)。
|
||||
- [x] 3.2 检查多行输入(LF / CRLF)、首尾空白、空行、全空输入和长 ICCID 字符串处理。
|
||||
- [x] 3.3 检查模式切换、无勾选打开、有勾选默认列表、关闭重开及接口参数隔离。
|
||||
- [x] 3.4 运行相关文件代码检查和项目类型检查,记录结果。
|
||||
|
||||
## 4. Verification results
|
||||
|
||||
- `node --test scripts/tests/iotCardRecallLineInput.test.mjs`:11 项通过;执行真实 Vue 组件 setup、校验规则和提交函数,并使用 mock 请求验证原有回收 API 的路径及参数,无真实回收操作。
|
||||
- `node --import ./scripts/tests/register-esbuild.mjs --test scripts/tests/*.test.mjs`:项目全部 141 项测试通过(PowerShell 中先枚举测试文件再传入)。
|
||||
- `pnpm exec eslint src/views/asset-management/iot-card-management/index.vue scripts/tests/iotCardRecallLineInput.test.mjs`:通过。
|
||||
- `pnpm exec prettier --check src/views/asset-management/iot-card-management/index.vue scripts/tests/iotCardRecallLineInput.test.mjs`:通过。
|
||||
- `node scripts/check-encoding.mjs src/views/asset-management/iot-card-management/index.vue scripts/tests/iotCardRecallLineInput.test.mjs`:通过。
|
||||
- `pnpm exec vue-tsc --noEmit`:通过。
|
||||
- `node scripts/tests/verifyBuild.mjs`:生产构建通过,输出到临时目录并保留生成声明文件;其他页面仍有 `searchForm` 常量赋值、循环依赖及大 chunk 警告,本次未修改这些页面。
|
||||
- `openspec validate add-iot-card-recall-line-input --strict`、`git diff --check`:通过。
|
||||
- 验证范围:本地自动化及构建检查,未执行真实后端回收或浏览器人工验收。工作区另有 `src/types/components.d.ts` 的生成变动,本次未回退或覆盖。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-07
|
||||
@@ -0,0 +1,141 @@
|
||||
## Context
|
||||
|
||||
- **后端(8-11,不改)**:接口和字段见 `junhong_cmp_fiber/docs/2026-11-06-测试环境8-11新功能前端对接与权限现状.md` 第 2.9、2.11、2.12 节,以 8-11 生成的 `admin-openapi.yaml` 为准。
|
||||
- **账号类型限制**(后端只按账号类型校验,没有对应权限码):
|
||||
- 记录查询接口不传资产标识时查全部,仅平台账号可用。
|
||||
- 资产详情的采集记录、冲正查询与提交,企业账号无权调用。
|
||||
- 退款后处理人工重试、尝试记录查询,仅平台账号(含超级管理员)可用。
|
||||
- 人工重试只接受 `asset_post_status = 3`(需人工处理)且资产后处理未完成的退款单。
|
||||
- **管理后台现状(`iteration/8-11`)**:
|
||||
- 菜单为静态路由(`src/router/routes/asyncRoutes.ts`),用 `meta.permissions` 过滤;按钮用 `useAuth().hasAuth()`;超级管理员不受限。
|
||||
- 列表页通用组件:`ArtSearchBar`、`ArtTableHeader`、`ArtTableFullScreen`。
|
||||
- 可复用的模式是 `components/business/gateway/StopResumeRecords.vue`:同一个组件既作为独立页(`views/polling-management/stop-resume-records`),也嵌在资产详情里(传入卡 ID)。
|
||||
- 资产详情:
|
||||
- `views/asset-management/asset-information` 用 `BasicInfoCard` 触发停复机记录、`PackageListCard` 触发日流量详单(`DailyRecordsDialog`)。
|
||||
- 同步按钮走 `useAssetInfo.refreshAsset`,目前忽略响应体。
|
||||
- 退款:`views/finance/refund/index.vue`、`detail.vue`,接口在 `api/modules/refund.ts`,类型在 `types/api/refund.ts`。
|
||||
- **约束**:基于 `iteration/8-11`,只部署测试环境;不改停复机逻辑;网关只读开关保持开启。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 运营能在后台查清楚"一张卡的流量读数从采集到入账"的全过程,并能人工冲正。
|
||||
- 退款后处理卡住时,运营能看到原因并人工重试。
|
||||
- 新页面和按钮有明确的权限码,可以分配给非超级管理员角色。
|
||||
|
||||
**Non-Goals:**
|
||||
- 后端接口或权限校验改动。
|
||||
- 采集记录导出、归档文件下载(后端未提供)。
|
||||
- 生产发布与生产权限数据。
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 记录列表做成可复用组件,页面只是壳
|
||||
|
||||
参照 `StopResumeRecords.vue`,在 `src/components/business/traffic-records/` 下新建组件:
|
||||
|
||||
- `TrafficCollectionRecords.vue`:成功记录;传 `failures` 时切到失败记录。
|
||||
- `CardDailyUsages.vue`
|
||||
- `PackageUsageDailyRecords.vue`
|
||||
- `PackageTrafficAdjustments.vue`
|
||||
- `RefundPostProcessAttempts.vue`
|
||||
|
||||
每个组件接受可选的固定筛选项,例如 `identifier`、`packageUsageId`、`refundId`。独立页面不传,资产详情和退款详情传入,复用同一套列表与原始数据展开。
|
||||
|
||||
**备选**:页面与弹窗各写一套。**拒绝原因**:字段多(约 30 个),两套容易不一致。
|
||||
|
||||
### D2 菜单位置
|
||||
|
||||
| 页面 | 菜单位置 |
|
||||
|---|---|
|
||||
| 流量采集成功记录、流量采集失败记录、卡日流量、套餐日流量详单、套餐流量冲正流水 | 资产管理 → 记录管理(与"资产分配""授权记录"并列) |
|
||||
| 退款后处理尝试记录 | 财务管理(紧跟"退款管理") |
|
||||
|
||||
路由别名在 `routesAlias.ts` 注册,页面目录分别为 `views/asset-management/record-management/<name>/index.vue` 与 `views/finance/refund-post-attempts/index.vue`。
|
||||
|
||||
### D3 权限码
|
||||
|
||||
后端没有这些权限码,前端新增常量文件 `src/config/constants/trafficRecords.ts`:
|
||||
|
||||
| 用途 | 权限码 |
|
||||
|---|---|
|
||||
| 采集成功记录页 | `traffic_collection_record:page` |
|
||||
| 采集失败记录页 | `traffic_collection_failure:page` |
|
||||
| 卡日流量页 | `card_daily_usage:page` |
|
||||
| 套餐日流量详单页 | `package_usage_daily_record:page` |
|
||||
| 冲正流水页 | `package_traffic_adjustment:page` |
|
||||
| 退款后处理尝试记录页 | `refund_post_process_attempt:page` |
|
||||
| 资产详情 · 采集记录(卡 / 设备绑定卡) | `iot_info:traffic_collections`、`device_info:traffic_collections` |
|
||||
| 资产详情 · 冲正记录 | `package_usage:traffic_adjustments` |
|
||||
| 资产详情 · 人工冲正 | `package_usage:traffic_adjust` |
|
||||
| 退款 · 人工重试 | `refund:asset_post_retry` |
|
||||
|
||||
- 菜单用 `meta.permissions`,按钮用 `hasAuth`。
|
||||
- 前端还要按账号类型隐藏后端会拒绝的入口:企业账号不显示资产详情的采集记录和冲正;非平台账号不显示人工重试和尝试记录页。
|
||||
- 清单交后端写入测试库 `tb_permission` 并挂到对应菜单。权限码命名最终以写入测试库的数据为准。
|
||||
|
||||
### D4 时间范围与默认值
|
||||
|
||||
- 所有列表默认最近 1 天(日期类默认最近 7 天)。
|
||||
- 选择器限制跨度 ≤ 31 天,提交前再校验一次。
|
||||
- 时间参数按上海时间 `YYYY-MM-DD HH:mm:ss` 传,后端同时接受 RFC3339。
|
||||
- 结束时间按"不含"处理,日期类按"含首尾"处理,筛选栏旁用文字说明。
|
||||
|
||||
### D5 枚举中文名
|
||||
|
||||
在 `src/config/constants/trafficRecords.ts` 统一维护以下中文名,未知值原样显示:
|
||||
|
||||
| 枚举 | 中文名 |
|
||||
|---|---|
|
||||
| `decision` | written 已写入、baseline 建立基线、queue_not_full 采集队列未满、slope_negative 读数回落、out_of_order 采集乱序、settled_existing 已结算 |
|
||||
| `result` | ok 正常、failed 失败、rate_limited 限流、invalid_payload 响应无效、no_reading 无读数、invalid_reading 读数无效 |
|
||||
| `source` | polling 轮询、manual_sync 人工同步、business_event 业务事件 |
|
||||
| 退款后处理 `step` | load 加载、invalidate 失效套餐、activate 激活、check_main 检查主套餐、stop 停机、mark 标记完成、done 完成 |
|
||||
| 退款后处理 `trigger` | outbox 自动、manual 人工 |
|
||||
| `asset_post_status` | 0 未开始、1 待重试、2 已完成、3 需人工处理 |
|
||||
|
||||
### D6 原始数据展示
|
||||
|
||||
- 表格行展开后,用只读 JSON 块(`<pre>` 格式化)展示 `queue_points`、`raw_request`、`raw_response`。
|
||||
- `raw_response` 为空、`raw_response_text` 有值时,显示文本并标注"已截断"。
|
||||
- 每块提供"复制"按钮,方便贴给网关方排查。
|
||||
|
||||
### D7 人工冲正交互
|
||||
|
||||
- 表单只让运营填正整数加方向,由前端换算 `delta_mb` 的正负,避免运营手输负号出错。
|
||||
- 确认框展示当前已用量(取套餐行的已用量)、调整量与预计结果。
|
||||
- 退回不能使结果小于 0。
|
||||
- 成功后刷新套餐列表,并展示返回的 `before_usage_mb → after_usage_mb`。
|
||||
- 提示文案说明"提交后系统会按规则重新判断停复机",前端不调用任何停复机接口。
|
||||
|
||||
### D8 人工重试交互
|
||||
|
||||
- 按钮只在"需人工处理 + 平台账号 + 有权限"时出现。
|
||||
- 成功时接口返回最新退款详情,直接替换当前行或详情数据。
|
||||
- 失败时显示后端 `msg`,并重新拉取该退款单,更新尝试次数和最后错误。
|
||||
|
||||
### D9 同步结果(可选)
|
||||
|
||||
- `AssetService.refreshAsset` 的响应类型增加 `traffic_refresh`。
|
||||
- `useAssetInfo.refreshAsset` 在成功提示里附加一行入账结果,或用 `ElNotification` 展示。
|
||||
- 不改同步按钮的限流和刷新逻辑。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[权限码未入库,非超级管理员看不到新功能]** → 测试先用超级管理员验证。按 D3 清单补测试库后,再用普通运营账号验证。
|
||||
- **[人工冲正、人工重试会触发后端停复机判断]** → 测试环境资产已全部在网关只读表,停复机调用会被拦截,不会改上游卡状态。验证只看停复机指令和拦截日志,不看上游卡状态。
|
||||
- **[采集记录字段多,表格过宽]** → 默认只显示关键列,其余列通过 `ArtTableHeader` 列设置打开;原始数据放展开行。
|
||||
- **[采集记录只保留当月加最近 2 个整月,资产详情采集记录保留 90 天]** → 筛选栏说明保留期,超出保留期查询为空属正常。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 在 `iteration/8-11` 实现。本地 `build:development`(接测试后端)通过类型检查与构建。
|
||||
2. 后端在测试库补 D3 权限码,并分配给测试角色。
|
||||
3. 推送 `iteration/8-11` 部署测试环境。管理后台 CI 要求推送前在本机构建镜像并推送到 `registry.boss160.cn`,需先 `docker login`;部署时间由你安排。
|
||||
4. 用超级管理员和普通运营账号分别验证。
|
||||
5. 回退:回滚对应提交重新部署;新增页面不影响现有页面。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 权限码命名(D3)是否沿用此清单,还是由后端统一定名后回填前端常量?默认先用此清单。
|
||||
- 记录查询菜单是否放在"资产管理 → 记录管理",还是放到"轮询管理"下与停复机记录并列?默认放"记录管理"。
|
||||
@@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
后端 8-11 迭代在 11-05 新增了流量采集记录分表与记录查询接口、套餐流量人工冲正、退款资产后处理逐次尝试与重试上限,10-29 起资产刷新接口还会返回本次读数是否入账(`traffic_refresh`)。管理后台目前都没有接上:运营看不到采集成功/失败记录和卡日流量,无法人工冲正套餐流量;退款后处理失败到达重试上限后,后台既看不到状态也不能人工重试。依据 `junhong_cmp_fiber/docs/2026-11-06-测试环境8-11新功能前端对接与权限现状.md` 第 2.9、2.11、2.12、5.2 节补齐管理后台。
|
||||
|
||||
本变更基于 `iteration/8-11` 分支,只部署测试环境。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 6 个记录查询列表页:运营商流量采集成功记录、采集失败记录、卡日流量、套餐日流量详单、套餐流量冲正流水、退款资产后处理尝试记录。
|
||||
- 资产详情新增"采集记录"(按卡,可包含失败记录)。
|
||||
- 资产详情套餐列表新增"冲正记录"查看与"人工冲正"(正数补扣、负数退回,原因必填)。
|
||||
- 退款列表、详情展示资产后处理状态、已尝试次数、最后错误、下次自动重试时间;列表支持按后处理状态筛选;"需人工处理"的退款单提供"人工重试"按钮与尝试记录入口。
|
||||
- 可选:资产"同步"成功后展示本次读数、读数结果、是否写入、写入判断、增量。
|
||||
- 新增上述页面与按钮的前端权限码常量,并整理清单供测试库补权限数据。
|
||||
- 不改停复机逻辑:人工冲正、人工重试会由后端按规则触发停复机判断,前端只调用接口;测试环境资产已全部在网关只读表中,不会真实停复机。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `traffic-record-query`:采集成功/失败记录、卡日流量、套餐日流量详单、冲正流水 4 类全局查询页,以及资产详情中的卡采集记录。
|
||||
- `package-traffic-adjustment`:资产详情中按套餐查看冲正流水与提交人工冲正。
|
||||
- `refund-asset-post-processing`:退款后处理状态展示与筛选、人工重试、后处理尝试记录查询页。
|
||||
- `asset-refresh-traffic-result`:资产同步后展示 `traffic_refresh` 入账结果(可选)。
|
||||
|
||||
### Modified Capabilities
|
||||
(无。本仓库 `openspec/specs/` 下没有已有规格。)
|
||||
|
||||
## Impact
|
||||
|
||||
- 接口模块:新增 `src/api/modules/trafficRecords.ts`;`refund.ts` 增加人工重试与尝试记录;`asset.ts` 增加采集记录、冲正查询与提交,`AssetRefreshResponse` 类型增加 `traffic_refresh`。
|
||||
- 类型:`src/types/api/refund.ts` 的 `Refund` 与 `RefundQueryParams` 增加 `asset_post_*` 字段;新增记录查询类型。
|
||||
- 页面:新增记录查询页(`src/views/asset-management/record-management/*`、`src/views/finance/refund-post-attempts`);修改 `src/views/finance/refund/index.vue`、`detail.vue`、`src/views/asset-management/asset-information`(采集记录、冲正)。
|
||||
- 路由与权限:`src/router/routes/asyncRoutes.ts`、`routesAlias.ts`,新增权限常量文件;菜单与按钮权限码需在测试库 `tb_permission` 补数据,否则只有超级管理员可见。
|
||||
- 后端接口(8-11 已提供,不改):`/api/admin/traffic-collections`、`/traffic-collection-failures`、`/card-daily-usages`、`/package-usage-daily-records`、`/package-traffic-adjustments`、`/refund-post-process-attempts`、`/assets/{identifier}/traffic-collections`、`/assets/{identifier}/packages/{package_usage_id}/traffic-adjustments`(GET/POST)、`POST /refunds/{id}/asset-post-processing/retry`、`POST /assets/{identifier}/refresh`。
|
||||
@@ -0,0 +1,13 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 同步后展示流量入账结果
|
||||
|
||||
资产详情点击"同步"成功后,若响应包含 `traffic_refresh`,系统 SHALL 在成功提示中或提示下方展示本次流量入账结果:读数结果(中文名称)、本次运营商读数 `reading_mb`(为空时显示"未返回读数")、是否写入 `written`、写入判断 `decision`(中文名称)、本次增量 `delta_mb`。响应不含 `traffic_refresh` 时 MUST 保持现有"刷新成功"提示不变。本需求为可选项,不实现不影响现有功能。
|
||||
|
||||
#### Scenario: 读数已写入
|
||||
- **WHEN** 同步返回 `traffic_refresh = { result: ok, reading_mb: 1200, written: true, decision: written, delta_mb: 15 }`
|
||||
- **THEN** 页面显示"本次读数 1200 MB,已写入,增量 15 MB"
|
||||
|
||||
#### Scenario: 读数未写入
|
||||
- **WHEN** 同步返回 `written = false`、`decision = queue_not_full`
|
||||
- **THEN** 页面显示"本次读数未写入:采集队列未满"
|
||||
@@ -0,0 +1,25 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 查看套餐冲正流水
|
||||
|
||||
资产详情套餐列表中每笔套餐 SHALL 提供"冲正记录"入口,弹窗调用 `GET /api/admin/assets/{identifier}/packages/{package_usage_id}/traffic-adjustments` 分页展示该套餐的冲正流水:冲正时间、调整量、调整前后已用量、调整前后套餐状态、原因、操作人、关联流量事件 ID 与采集 ID。企业账号 MUST NOT 显示该入口。
|
||||
|
||||
#### Scenario: 查看冲正记录
|
||||
- **WHEN** 运营在资产详情某笔套餐上点击"冲正记录"
|
||||
- **THEN** 弹窗按时间倒序显示该套餐的冲正流水,无记录时显示空状态
|
||||
|
||||
### Requirement: 提交人工冲正
|
||||
|
||||
有冲正按钮权限的账号 SHALL 能在资产详情套餐上提交人工冲正,调用 `POST /api/admin/assets/{identifier}/packages/{package_usage_id}/traffic-adjustments`。表单字段:调整方向(补扣 / 退回)、调整量 MB(正整数,提交时按方向转为正数或负数 `delta_mb`)、原因(必填,去首尾空格后不能为空)、关联采集 ID 与关联流量事件 ID(选填)。提交前 SHALL 二次确认,确认框展示当前已用量、调整量与预计调整后已用量,并提示"提交后系统会按规则重新判断停复机"。退回量大于当前已用量时 SHALL 提示调整后不能小于 0 并阻止提交。提交中 MUST 防重复。成功后 SHALL 提示调整前后已用量,并刷新套餐列表与冲正记录。
|
||||
|
||||
#### Scenario: 补扣
|
||||
- **WHEN** 运营对已用 500 MB 的套餐选择补扣 100 MB、填写原因并确认
|
||||
- **THEN** 系统提交 `delta_mb = 100`,成功后提示已用量 500 MB → 600 MB,套餐列表刷新
|
||||
|
||||
#### Scenario: 退回超过已用量
|
||||
- **WHEN** 运营对已用 50 MB 的套餐选择退回 80 MB
|
||||
- **THEN** 页面提示调整后已用量不能小于 0,不提交
|
||||
|
||||
#### Scenario: 原因为空
|
||||
- **WHEN** 运营未填写原因直接提交
|
||||
- **THEN** 表单提示原因必填,不发请求
|
||||
@@ -0,0 +1,45 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 退款后处理状态展示
|
||||
|
||||
退款列表与退款详情 SHALL 展示资产后处理状态 `asset_post_status`(0 未开始、1 待重试、2 已完成、3 需人工处理),列表用标签展示,"需人工处理"用警示色。详情 SHALL 另外展示已尝试次数 `asset_post_attempts`(注明自动重试上限 10 次)、最后一次错误 `asset_post_last_error`、下次自动重试时间 `asset_post_next_at`(仅待重试时有值)。字段缺失时显示"-",不影响现有展示。
|
||||
|
||||
#### Scenario: 待重试
|
||||
- **WHEN** 一笔退款 `asset_post_status = 1`、已尝试 3 次、下次重试时间为 10:30
|
||||
- **THEN** 详情显示"待重试"、已尝试 3/10 次、最后错误与下次重试时间 10:30
|
||||
|
||||
### Requirement: 按后处理状态筛选
|
||||
|
||||
退款列表 SHALL 增加"资产后处理状态"筛选项,选中后请求带 `asset_post_status`;清空时不传该参数。
|
||||
|
||||
#### Scenario: 筛选需人工处理
|
||||
- **WHEN** 运营选择"需人工处理"并查询
|
||||
- **THEN** 请求带 `asset_post_status = 3`,列表只显示需人工处理的退款单
|
||||
|
||||
### Requirement: 人工重试后处理
|
||||
|
||||
退款单 `asset_post_status = 3` 且当前账号为平台账号或超级管理员、并有人工重试按钮权限时,退款列表操作列与详情页 SHALL 显示"人工重试"按钮;其他状态或账号类型 MUST NOT 显示。点击后 SHALL 二次确认,提示"重试会重新执行退款后的资产处理(可能包括停机),确认继续?"。调用 `POST /api/admin/refunds/{id}/asset-post-processing/retry`,提交中 MUST 防重复。成功时 SHALL 用返回的最新退款详情刷新当前行或详情;失败时 SHALL 显示后端错误信息(如"资产后处理仍未完成:…"),并重新加载该退款单以展示最新尝试次数与错误。
|
||||
|
||||
#### Scenario: 重试成功
|
||||
- **WHEN** 运营对需人工处理的退款单点击"人工重试"并确认,后端返回成功
|
||||
- **THEN** 页面提示重试成功,该退款单后处理状态显示"已完成",按钮消失
|
||||
|
||||
#### Scenario: 重试仍失败
|
||||
- **WHEN** 后端返回"资产后处理仍未完成:停机失败"
|
||||
- **THEN** 页面显示该错误,重新加载后仍显示"需人工处理"和最新错误
|
||||
|
||||
#### Scenario: 非需人工处理
|
||||
- **WHEN** 退款单后处理状态为"待重试"
|
||||
- **THEN** 不显示"人工重试"按钮
|
||||
|
||||
### Requirement: 后处理尝试记录查询
|
||||
|
||||
系统 SHALL 提供"退款后处理尝试记录"列表页,调用 `GET /api/admin/refund-post-process-attempts`,仅平台账号与超级管理员可用。筛选条件:时间范围(必填,跨度不超过 31 天)、退款单 ID 或单号、资产标识、处理步骤 `step`、结果 `result`、错误码、触发方式 `trigger`(自动 / 人工)。列表 SHALL 展示记录时间、退款单号、资产类型与 ICCID、第几次尝试、步骤、结果、错误码与错误信息、触发方式、耗时;步骤、结果、触发方式显示中文名称。退款详情 SHALL 提供"后处理尝试记录"入口,打开该列表并自动带上退款单 ID。
|
||||
|
||||
#### Scenario: 从退款详情查看
|
||||
- **WHEN** 运营在退款详情点击"后处理尝试记录"
|
||||
- **THEN** 打开尝试记录列表,已按该退款单 ID 筛选,时间范围为最近 31 天
|
||||
|
||||
#### Scenario: 时间范围必填
|
||||
- **WHEN** 运营清空时间范围后查询
|
||||
- **THEN** 页面提示请选择时间范围,不发请求
|
||||
@@ -0,0 +1,65 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 采集成功记录查询
|
||||
|
||||
系统 SHALL 提供"流量采集成功记录"列表页,调用 `GET /api/admin/traffic-collections`。筛选条件:资产标识、时间范围(`start_time`~`end_time`,结束时间不含)、运营商账户、写入判断 `decision`、来源 `source`、场景 `scene`、是否有增量 `has_delta`、是否触发重置日防旧值 `stale_guarded`。列表 SHALL 展示采集时间、ICCID 或卡 ID、运营商账户、来源、场景、运营商原始读数、本月累计、本次增量、写入判断、防旧值、网关 code 与 trace_id、网关尝试次数,并可展开查看采集队列 `queue_points`、原始请求 `raw_request`、原始响应 `raw_response` / `raw_response_text`(截断时标注)。写入判断、来源、读数结果 SHALL 显示中文名称。分页每页默认 20、最大 100。
|
||||
|
||||
#### Scenario: 默认查询
|
||||
- **WHEN** 运营打开采集成功记录页
|
||||
- **THEN** 默认时间范围为最近 1 天,按采集时间倒序展示第一页
|
||||
|
||||
#### Scenario: 查看原始数据
|
||||
- **WHEN** 运营展开一条 `raw_truncated = true` 的记录
|
||||
- **THEN** 页面显示 `raw_response_text` 并标注"原始响应已截断"
|
||||
|
||||
### Requirement: 采集失败记录查询
|
||||
|
||||
系统 SHALL 提供"流量采集失败记录"列表页,调用 `GET /api/admin/traffic-collection-failures`。筛选条件:资产标识、时间范围、运营商账户、失败类型 `result`、错误类别 `error_category`、上游返回码 `provider_code`、来源、场景。列表 SHALL 展示采集时间、ICCID、运营商账户、失败类型、错误类别、HTTP 状态、上游返回码与信息、网关 code/msg/trace_id、耗时、尝试次数,并可展开原始请求与响应。
|
||||
|
||||
#### Scenario: 按失败类型筛选
|
||||
- **WHEN** 运营选择失败类型"限流"并查询
|
||||
- **THEN** 列表只显示 `result = rate_limited` 的记录
|
||||
|
||||
### Requirement: 卡日流量查询
|
||||
|
||||
系统 SHALL 提供"卡日流量"列表页,调用 `GET /api/admin/card-daily-usages`。筛选条件:资产标识、日期范围(`start_date`~`end_date`,含首尾)、运营商账户、当日用量下限 `min_usage_mb`。列表 SHALL 展示日期、ICCID、运营商账户、当日用量 `settled_mb`、当日结束时本月累计、最后一次运营商原始读数、当天写入次数 `obs_count`、是否触发防旧值、更新时间。
|
||||
|
||||
#### Scenario: 排查异常大流量
|
||||
- **WHEN** 运营输入当日用量下限 1024 MB 并查询
|
||||
- **THEN** 列表只显示当日用量不低于 1024 MB 的卡日记录
|
||||
|
||||
### Requirement: 套餐日流量详单查询
|
||||
|
||||
系统 SHALL 提供"套餐日流量详单"列表页,调用 `GET /api/admin/package-usage-daily-records`。筛选条件:资产标识、日期范围、套餐使用记录 ID、套餐 ID、运营商账户。列表 SHALL 展示日期、套餐使用记录 ID、套餐 ID、卡 ID 或设备 ID、当日流量、截止当日累计、更新时间。
|
||||
|
||||
#### Scenario: 按套餐使用记录查询
|
||||
- **WHEN** 运营输入套餐使用记录 ID 并查询
|
||||
- **THEN** 列表只显示该套餐的日流量
|
||||
|
||||
### Requirement: 冲正流水查询
|
||||
|
||||
系统 SHALL 提供"套餐流量冲正流水"列表页,调用 `GET /api/admin/package-traffic-adjustments`。筛选条件:资产标识、时间范围、套餐使用记录 ID、操作人账号 ID、调整方向 `direction`(补扣 / 退回)。列表 SHALL 展示冲正时间、套餐使用记录 ID、卡 ID 或设备 ID、调整量(正数补扣、负数退回)、调整前后已用量、调整前后套餐状态、原因、操作人、关联流量事件 ID 与采集 ID。
|
||||
|
||||
#### Scenario: 只看退回
|
||||
- **WHEN** 运营选择调整方向"退回"
|
||||
- **THEN** 列表只显示调整量为负数的流水
|
||||
|
||||
### Requirement: 查询范围与账号限制
|
||||
|
||||
所有记录查询页的时间或日期跨度 MUST NOT 超过 31 天,超出时 SHALL 在提交前提示并阻止查询。不填资产标识查询全部只允许平台账号与超级管理员:其他账号类型 SHALL 要求填写资产标识后才能查询。后端返回无权限时 SHALL 显示后端提示。
|
||||
|
||||
#### Scenario: 跨度超限
|
||||
- **WHEN** 运营选择 2026-10-01 至 2026-11-06 并查询
|
||||
- **THEN** 页面提示时间跨度不能超过 31 天,不发请求
|
||||
|
||||
#### Scenario: 代理账号不填资产
|
||||
- **WHEN** 代理账号不填资产标识直接查询
|
||||
- **THEN** 页面提示请先输入资产标识
|
||||
|
||||
### Requirement: 资产详情采集记录
|
||||
|
||||
资产详情 SHALL 为卡(含设备绑定的卡)提供"采集记录"入口,弹窗调用 `GET /api/admin/assets/{identifier}/traffic-collections`,默认最近 1 天、每页 50、不含失败记录;可勾选"包含失败记录"(`include_failures = true`),并可按写入判断、来源、场景筛选。列表 SHALL 用 `record_kind` 区分成功与失败记录,成功记录展示读数、累计、增量、写入判断,失败记录展示失败类型、错误类别与上游返回;均可展开原始数据。企业账号 MUST NOT 显示该入口。
|
||||
|
||||
#### Scenario: 包含失败记录
|
||||
- **WHEN** 运营在卡详情打开采集记录并勾选"包含失败记录"
|
||||
- **THEN** 列表同时显示成功与失败记录,失败记录有明显标识
|
||||
@@ -0,0 +1,46 @@
|
||||
## 1. 类型、接口与常量
|
||||
|
||||
- [x] 1.1 新增记录查询类型(采集成功/失败记录、卡日流量、套餐日流量详单、冲正流水、后处理尝试记录、`traffic_refresh`)
|
||||
- [x] 1.2 `types/api/refund.ts`:`Refund` 增加 `asset_post_status`、`asset_post_attempts`、`asset_post_last_error`、`asset_post_next_at`;`RefundQueryParams` 增加 `asset_post_status`
|
||||
- [x] 1.3 新增 `api/modules/trafficRecords.ts`(6 个全局查询接口),直接按路径引用(未加入 modules/index.ts)
|
||||
- [x] 1.4 资产采集记录、套餐冲正查询与提交(放在 `trafficRecords.ts` 的 TrafficRecordsService,未改 asset.ts);`AssetRefreshResponse` 增加 `traffic_refresh`
|
||||
- [x] 1.5 `api/modules/refund.ts` 增加人工重试接口
|
||||
- [x] 1.6 新增 `config/constants/trafficRecords.ts`:权限码(design D3)、枚举中文名(D5)、31 天跨度校验工具
|
||||
|
||||
## 2. 可复用记录组件
|
||||
|
||||
- [x] 2.1 原始数据展开组件:格式化 JSON、截断标注、复制
|
||||
- [x] 2.2 `TrafficCollectionRecords.vue`(成功 / 失败两种模式,支持固定 identifier、include_failures)
|
||||
- [x] 2.3 `CardDailyUsages.vue`
|
||||
- [x] 2.4 `PackageUsageDailyRecords.vue`
|
||||
- [x] 2.5 `PackageTrafficAdjustments.vue`(支持固定 identifier + package_usage_id)
|
||||
- [x] 2.6 `RefundPostProcessAttempts.vue`(支持固定 refund_id,时间范围必填)
|
||||
- [x] 2.7 组件统一处理:默认时间范围、31 天校验、非平台账号必须填资产标识、分页
|
||||
|
||||
## 3. 页面与路由
|
||||
|
||||
- [x] 3.1 新增 5 个记录查询页(资产管理 → 记录管理)与退款后处理尝试记录页(财务管理),注册 `routesAlias.ts` 与 `asyncRoutes.ts`,配置 `meta.permissions`
|
||||
- [x] 3.2 菜单多语言文案(`locales`)(新菜单沿用停复机记录等页面的写法,标题直接用中文,未加 locales 键)
|
||||
|
||||
## 4. 资产详情
|
||||
|
||||
- [x] 4.1 `BasicInfoCard` 为卡与设备绑定卡增加"采集记录"按钮(权限 + 非企业账号),弹窗嵌入 `TrafficCollectionRecords`
|
||||
- [x] 4.2 `PackageListCard` 每笔套餐增加"冲正记录",弹窗嵌入 `PackageTrafficAdjustments`
|
||||
- [x] 4.3 新增人工冲正弹窗:方向 + 正整数 MB + 原因必填 + 选填关联 ID;二次确认展示预计结果;退回不得小于 0;提交防重复;成功后刷新套餐列表
|
||||
- [x] 4.4 (可选)同步成功后展示 `traffic_refresh` 入账结果
|
||||
|
||||
## 5. 退款
|
||||
|
||||
- [x] 5.1 退款列表增加后处理状态列与筛选项
|
||||
- [x] 5.2 退款详情增加后处理状态、尝试次数(/10)、最后错误、下次重试时间
|
||||
- [x] 5.3 列表操作列与详情增加"人工重试"(需人工处理 + 平台账号 + 权限),二次确认、防重复,成功替换数据、失败显示错误并重新加载
|
||||
- [x] 5.4 退款详情增加"后处理尝试记录"入口,按退款单 ID 打开尝试记录
|
||||
|
||||
## 6. 验证与发布
|
||||
|
||||
- [x] 6.1 `vue-tsc --noEmit` 与 `build:development` 通过(另新增 scripts/tests/trafficRecords.test.mjs;既有 5 个测试文件在未改动基线上即失败,agentRecharge 4 个缺 esbuild,均与本变更无关)
|
||||
- [ ] 6.2 整理 D3 权限码清单给后端,在测试库 `tb_permission` 补数据并分配测试角色
|
||||
- [ ] 6.3 超级管理员验证:6 个列表页筛选与原始数据、资产详情采集记录、冲正记录与人工冲正、退款后处理展示与筛选、人工重试
|
||||
- [ ] 6.4 普通运营账号验证菜单与按钮权限;代理账号验证必须填资产标识、看不到人工重试
|
||||
- [ ] 6.5 人工冲正、人工重试后确认停复机调用被网关只读拦截(worker 日志),不以上游卡状态为准
|
||||
- [ ] 6.6 推送 `iteration/8-11` 部署测试环境(先 `docker login registry.boss160.cn`,时间由你安排)
|
||||
@@ -104,6 +104,15 @@ function mountBasicInfo(t, userType, permissions = []) {
|
||||
getExpiryEstimateClass: () => ''
|
||||
},
|
||||
'@/config/constants/audit': { AUDIT_PERMISSIONS: {} },
|
||||
'@/config/constants/trafficRecords': {
|
||||
TRAFFIC_RECORD_PERMISSIONS: {
|
||||
assetCollections: {
|
||||
card: 'iot_info:traffic_collections',
|
||||
device: 'device_info:traffic_collections'
|
||||
}
|
||||
}
|
||||
},
|
||||
'@/utils/business/trafficRecords': { isEnterpriseAccount: (type) => type === 4 },
|
||||
'@/utils/business/auditNavigation': { resolveAuditResourceTarget: () => null },
|
||||
'@/components/business/audit/investigationController': { openAuditInvestigation: () => {} }
|
||||
}).default
|
||||
|
||||
@@ -83,7 +83,8 @@ function harness(t, overrides = {}) {
|
||||
},
|
||||
'@/api/modules': { AssetService: api },
|
||||
'@/utils/business/apiRateLimit': { FrontendRateLimitError: class extends Error {} },
|
||||
'@/store/modules/user': { useUserStore: () => user }
|
||||
'@/store/modules/user': { useUserStore: () => user },
|
||||
'@/utils/business/trafficRecords': { describeTrafficRefresh: () => '' }
|
||||
}
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiledSource)(
|
||||
|
||||
257
scripts/tests/iotCardRecallLineInput.test.mjs
Normal file
257
scripts/tests/iotCardRecallLineInput.test.mjs
Normal file
@@ -0,0 +1,257 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import { compileScript, parse } from '@vue/compiler-sfc'
|
||||
|
||||
function evaluate(source, imports = {}, globals = {}) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(
|
||||
(name) => {
|
||||
assert.ok(imports[name], `Unexpected dependency: ${name}`)
|
||||
return imports[name]
|
||||
},
|
||||
module,
|
||||
module.exports,
|
||||
...Object.values(globals)
|
||||
)
|
||||
return module.exports
|
||||
}
|
||||
|
||||
const read = (path) => readFileSync(path, 'utf8')
|
||||
const cardTypes = evaluate(read('src/types/api/card.ts'))
|
||||
const { descriptor } = parse(read('src/views/asset-management/iot-card-management/index.vue'))
|
||||
const script = compileScript(descriptor, { id: 'iot-card-recall-lines' }).content
|
||||
|
||||
function harness() {
|
||||
const writes = []
|
||||
const warnings = []
|
||||
const errors = []
|
||||
let refreshes = 0
|
||||
let response = {
|
||||
code: 0,
|
||||
data: { allocation_no: 'RECALL-1', total_count: 2, success_count: 1, fail_count: 1 }
|
||||
}
|
||||
class BaseService {
|
||||
static async post(url, data) {
|
||||
writes.push({ url, data: structuredClone(vue.toRaw(data)) })
|
||||
return response
|
||||
}
|
||||
}
|
||||
const { CardService } = evaluate(read('src/api/modules/card.ts'), {
|
||||
'../BaseService': { BaseService }
|
||||
})
|
||||
CardService.getStandaloneIotCards = async () => {
|
||||
refreshes += 1
|
||||
return { code: 0, data: { items: [], total: 0 } }
|
||||
}
|
||||
const runtimeVue = { ...vue, onMounted() {} }
|
||||
const imports = {
|
||||
vue: runtimeVue,
|
||||
'vue-router': { useRouter: () => ({ push() {} }) },
|
||||
'@/router/routesAlias': { RoutesAlias: {} },
|
||||
'@/config/constants/audit': { AUDIT_PERMISSIONS: {} },
|
||||
'@/utils/business/auditNavigation': { resolveAuditResourceTarget() {} },
|
||||
'@/components/business/audit/investigationController': { openAuditInvestigation() {} },
|
||||
'@/api/modules': {
|
||||
CardService,
|
||||
CarrierService: { getCarriers: async () => ({ code: 0, data: { items: [] } }) }
|
||||
},
|
||||
'element-plus': {
|
||||
ElMessage: {
|
||||
warning: (message) => warnings.push(message),
|
||||
error: (message) => errors.push(message)
|
||||
}
|
||||
},
|
||||
'@element-plus/icons-vue': {},
|
||||
'@/composables/useCheckedColumns': {
|
||||
useCheckedColumns: () => ({ columnChecks: vue.ref([]), columns: vue.ref([]) })
|
||||
},
|
||||
'@/composables/useAuth': { useAuth: () => ({ hasAuth: () => true }) },
|
||||
'@/store/modules/user': { useUserStore: () => ({}) },
|
||||
pinia: { storeToRefs: () => ({ info: vue.ref({ user_type: 1 }) }) },
|
||||
'@/utils/business/format': {},
|
||||
'@/utils/business/expiryEstimate': {},
|
||||
'@/utils/business/apiRateLimit': {},
|
||||
'@/types/api/card': cardTypes
|
||||
}
|
||||
for (const name of [
|
||||
'@/components/device/RealnamePolicyDialog.vue',
|
||||
'@/components/business/BatchRealnamePolicyDialog.vue',
|
||||
'@/components/business/UpdateRealnameStatusDialog.vue',
|
||||
'@/components/business/ExportTaskCreateDialog.vue'
|
||||
]) {
|
||||
imports[name] = { default: {} }
|
||||
}
|
||||
const state = evaluate(script, imports, runtimeVue).default.setup({}, { expose() {} })
|
||||
const validationErrors = []
|
||||
state.recallFormRef.value = {
|
||||
// Run the component's own validators for the fields mounted in the active mode.
|
||||
async validate(callback) {
|
||||
validationErrors.length = 0
|
||||
const fields =
|
||||
state.recallForm.selection_type === 'lines'
|
||||
? ['iccids_text']
|
||||
: state.recallForm.selection_type === 'range'
|
||||
? ['iccid_start', 'iccid_end']
|
||||
: []
|
||||
for (const field of fields) {
|
||||
for (const rule of state.recallRules[field]) {
|
||||
rule.validator(rule, state.recallForm[field], (error) => {
|
||||
if (error) validationErrors.push(error.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
await callback(validationErrors.length === 0)
|
||||
},
|
||||
resetFields() {
|
||||
// Emulate Element Plus restoring its initial selection when reopening.
|
||||
state.recallForm.selection_type = 'list'
|
||||
state.recallForm.iccids_text = ''
|
||||
},
|
||||
clearValidate() {
|
||||
validationErrors.length = 0
|
||||
}
|
||||
}
|
||||
return {
|
||||
state,
|
||||
writes,
|
||||
warnings,
|
||||
errors,
|
||||
validationErrors,
|
||||
refreshes: () => refreshes,
|
||||
setResponse(value) {
|
||||
response = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('unselected entry defaults to line input; reopening resets input without losing default', () => {
|
||||
const { state } = harness()
|
||||
state.showRecallDialog()
|
||||
assert.equal(state.recallDialogVisible.value, true)
|
||||
assert.equal(state.recallForm.selection_type, 'lines')
|
||||
state.recallForm.iccids_text = 'OLD-CARD'
|
||||
state.handleRecallDialogClose()
|
||||
assert.equal(state.recallForm.iccids_text, '')
|
||||
state.showRecallDialog()
|
||||
assert.equal(state.recallForm.selection_type, 'lines')
|
||||
assert.equal(state.recallForm.iccids_text, '')
|
||||
state.selectedCards.value = [{ iccid: 'SELECTED-CARD' }]
|
||||
state.showRecallDialog()
|
||||
assert.equal(state.recallForm.selection_type, 'list')
|
||||
})
|
||||
|
||||
for (const ending of ['\n', '\r\n']) {
|
||||
test(`line mode trims and skips blank lines, preserves strings, and reuses list API (${JSON.stringify(ending)})`, async () => {
|
||||
const { state, writes, refreshes } = harness()
|
||||
state.selectedCards.value = [{ iccid: 'MUST-NOT-BE-SENT' }]
|
||||
state.showRecallDialog()
|
||||
Object.assign(state.recallForm, {
|
||||
selection_type: 'lines',
|
||||
iccids_text: [' 089860012345678901234 ', '', '\t', '89860012345678901235F'].join(ending),
|
||||
iccid_start: 'OLD-START',
|
||||
iccid_end: 'OLD-END',
|
||||
carrier_id: 7,
|
||||
batch_no: 'OLD-BATCH',
|
||||
remark: '回收测试'
|
||||
})
|
||||
await state.handleRecall()
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
url: '/api/admin/iot-cards/standalone/recall',
|
||||
data: {
|
||||
selection_type: 'list',
|
||||
iccids: ['089860012345678901234', '89860012345678901235F'],
|
||||
remark: '回收测试'
|
||||
}
|
||||
}
|
||||
])
|
||||
assert.equal(state.recallDialogVisible.value, false)
|
||||
assert.equal(state.resultDialogVisible.value, true)
|
||||
assert.equal(state.resultTitle.value, '批量回收结果')
|
||||
assert.equal(state.allocationResult.value.success_count, 1)
|
||||
assert.equal(state.allocationResult.value.fail_count, 1)
|
||||
assert.deepEqual(state.selectedCards.value, [])
|
||||
assert.equal(state.recallLoading.value, false)
|
||||
assert.equal(refreshes(), 1)
|
||||
})
|
||||
}
|
||||
|
||||
for (const text of ['', ' \r\n \t\n']) {
|
||||
test(`empty input is rejected without a request (${JSON.stringify(text)})`, async () => {
|
||||
const { state, writes, validationErrors } = harness()
|
||||
state.showRecallDialog()
|
||||
state.recallForm.iccids_text = text
|
||||
await state.handleRecall()
|
||||
assert.deepEqual(writes, [])
|
||||
assert.deepEqual(validationErrors, ['请输入ICCID,一行一个'])
|
||||
assert.equal(state.recallDialogVisible.value, true)
|
||||
assert.equal(state.recallLoading.value, false)
|
||||
})
|
||||
}
|
||||
|
||||
for (const [mode, expected] of [
|
||||
['list', { iccids: ['SELECTED-CARD'] }],
|
||||
['range', { iccid_start: '89860000000000000000', iccid_end: '89860000000000000999' }],
|
||||
['filter', { carrier_id: 7, batch_no: 'BATCH-1' }]
|
||||
]) {
|
||||
test(`switching from line input to ${mode} preserves its existing parameters`, async () => {
|
||||
const { state, writes } = harness()
|
||||
state.selectedCards.value = [{ iccid: 'SELECTED-CARD' }]
|
||||
state.showRecallDialog()
|
||||
Object.assign(state.recallForm, {
|
||||
selection_type: 'lines',
|
||||
iccids_text: 'MANUAL-CARD',
|
||||
iccid_start: '89860000000000000000',
|
||||
iccid_end: '89860000000000000999',
|
||||
carrier_id: 7,
|
||||
batch_no: 'BATCH-1'
|
||||
})
|
||||
state.recallForm.selection_type = mode
|
||||
assert.equal(state.recallRangeCountText.value, '1,000')
|
||||
await state.handleRecall()
|
||||
assert.deepEqual(writes[0].data, { selection_type: mode, remark: '', ...expected })
|
||||
})
|
||||
}
|
||||
|
||||
test('list mode still rejects an empty table selection', async () => {
|
||||
const { state, writes, warnings } = harness()
|
||||
state.showRecallDialog()
|
||||
state.recallForm.selection_type = 'list'
|
||||
await state.handleRecall()
|
||||
assert.deepEqual(writes, [])
|
||||
assert.deepEqual(warnings, ['请先选择要回收的卡'])
|
||||
})
|
||||
|
||||
test('failed requests retain manual input for retry and do not refresh the table', async () => {
|
||||
const { state, errors, setResponse, refreshes } = harness()
|
||||
state.showRecallDialog()
|
||||
state.recallForm.iccids_text = 'RETRY-CARD'
|
||||
setResponse({ code: 1, msg: '回收失败' })
|
||||
await state.handleRecall()
|
||||
assert.deepEqual(errors, ['回收失败'])
|
||||
assert.equal(state.recallForm.iccids_text, 'RETRY-CARD')
|
||||
assert.equal(state.recallDialogVisible.value, true)
|
||||
assert.equal(state.recallLoading.value, false)
|
||||
assert.equal(refreshes(), 0)
|
||||
})
|
||||
|
||||
test('template keeps recall permission, enables its entry, and binds the labeled textarea', () => {
|
||||
const source = descriptor.template.content
|
||||
const entry = source.match(/<ElButton\b[^>]*@click="showRecallDialog"[^>]*>/)[0]
|
||||
assert.match(entry, /v-permission="'iot_card:batch_recycle'"/)
|
||||
assert.doesNotMatch(entry, /:disabled/)
|
||||
const dialog = source.match(/<ElDialog\s+v-model="recallDialogVisible"[\s\S]*?<\/ElDialog>/)[0]
|
||||
assert.match(dialog, /<ElRadio label="lines">一行一个ICCID<\/ElRadio>/)
|
||||
assert.match(
|
||||
dialog,
|
||||
/v-if="recallForm\.selection_type === 'lines'"\s+label="ICCID"\s+prop="iccids_text"/
|
||||
)
|
||||
assert.match(dialog, /v-model="recallForm\.iccids_text"\s+type="textarea"/)
|
||||
assert.match(dialog, /placeholder="请输入ICCID,一行一个"/)
|
||||
})
|
||||
146
scripts/tests/trafficRecords.test.mjs
Normal file
146
scripts/tests/trafficRecords.test.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
|
||||
function evaluate(path, imports = {}) {
|
||||
const compiled = ts.transpileModule(readFileSync(path, 'utf8'), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
(name) => {
|
||||
assert.ok(imports[name], `Unexpected dependency: ${name}`)
|
||||
return imports[name]
|
||||
},
|
||||
module,
|
||||
module.exports
|
||||
)
|
||||
return module.exports
|
||||
}
|
||||
|
||||
const constants = evaluate('src/config/constants/trafficRecords.ts')
|
||||
const utils = evaluate('src/utils/business/trafficRecords.ts', {
|
||||
'@/config/constants/trafficRecords': constants
|
||||
})
|
||||
|
||||
test('时间范围:结束时间不含,跨度不超过 31 天', () => {
|
||||
assert.deepEqual(utils.toTimeQuery(['2026-11-01 00:00:00', '2026-11-02 00:00:00']), {
|
||||
start_time: '2026-11-01 00:00:00',
|
||||
end_time: '2026-11-02 00:00:00'
|
||||
})
|
||||
assert.doesNotThrow(() => utils.toTimeQuery(['2026-10-01 00:00:00', '2026-11-01 00:00:00']))
|
||||
assert.throws(
|
||||
() => utils.toTimeQuery(['2026-10-01 00:00:00', '2026-11-06 00:00:00']),
|
||||
/时间跨度不能超过 31 天/
|
||||
)
|
||||
assert.throws(
|
||||
() => utils.toTimeQuery(['2026-11-02 00:00:00', '2026-11-01 00:00:00']),
|
||||
/结束时间必须晚于开始时间/
|
||||
)
|
||||
assert.throws(() => utils.toTimeQuery(null), /请选择完整的时间范围/)
|
||||
assert.deepEqual(utils.toTimeQuery(null, false), {})
|
||||
})
|
||||
|
||||
test('日期范围:含首尾,最多 31 天', () => {
|
||||
assert.deepEqual(utils.toDateQuery(['2026-10-01', '2026-10-31']), {
|
||||
start_date: '2026-10-01',
|
||||
end_date: '2026-10-31'
|
||||
})
|
||||
assert.throws(() => utils.toDateQuery(['2026-10-01', '2026-11-01']), /日期跨度不能超过 31 天/)
|
||||
assert.throws(() => utils.toDateQuery(['2026-10-02', '2026-10-01']), /结束日期不得早于开始日期/)
|
||||
})
|
||||
|
||||
test('非平台账号必须填资产标识', () => {
|
||||
assert.doesNotThrow(() => utils.requireIdentifierForNonPlatform('', 1))
|
||||
assert.doesNotThrow(() => utils.requireIdentifierForNonPlatform('', 2))
|
||||
assert.throws(() => utils.requireIdentifierForNonPlatform(' ', 3), /请先输入资产标识/)
|
||||
assert.doesNotThrow(() => utils.requireIdentifierForNonPlatform('898600', 3))
|
||||
assert.equal(utils.isEnterpriseAccount(4), true)
|
||||
assert.equal(utils.isPlatformAccount(3), false)
|
||||
})
|
||||
|
||||
test('筛选值处理', () => {
|
||||
assert.deepEqual(utils.compactQuery({ a: '', b: 0, c: null, d: undefined, e: false, f: 'x' }), {
|
||||
b: 0,
|
||||
e: false,
|
||||
f: 'x'
|
||||
})
|
||||
assert.equal(utils.optionalPositiveInt('', 'ID'), undefined)
|
||||
assert.equal(utils.optionalPositiveInt('12', 'ID'), 12)
|
||||
assert.throws(() => utils.optionalPositiveInt('-1', '运营商账户 ID'), /运营商账户 ID须为正整数/)
|
||||
assert.equal(utils.optionalBoolean('true'), true)
|
||||
assert.equal(utils.optionalBoolean('false'), false)
|
||||
assert.equal(utils.optionalBoolean(''), undefined)
|
||||
assert.equal(utils.formatMb(12), '12 MB')
|
||||
assert.equal(utils.formatMb(1.256), '1.26 MB')
|
||||
assert.equal(utils.formatMb(null), '-')
|
||||
})
|
||||
|
||||
test('资产同步后的流量入账结果文案', () => {
|
||||
assert.equal(utils.describeTrafficRefresh(undefined), '')
|
||||
assert.equal(
|
||||
utils.describeTrafficRefresh({
|
||||
result: 'ok',
|
||||
reading_mb: 1200,
|
||||
written: true,
|
||||
decision: 'written',
|
||||
delta_mb: 15
|
||||
}),
|
||||
'本次读数 1200 MB,已写入,增量 15 MB'
|
||||
)
|
||||
assert.equal(
|
||||
utils.describeTrafficRefresh({
|
||||
result: 'ok',
|
||||
reading_mb: 1200,
|
||||
written: false,
|
||||
decision: 'queue_not_full'
|
||||
}),
|
||||
'本次读数 1200 MB,未写入:采集队列未满'
|
||||
)
|
||||
assert.equal(utils.describeTrafficRefresh({ result: 'rate_limited' }), '流量读数限流,未写入')
|
||||
})
|
||||
|
||||
test('退款后处理状态与权限码常量', () => {
|
||||
assert.equal(constants.REFUND_ASSET_POST_STATUS.manualRequired, 3)
|
||||
assert.equal(constants.REFUND_ASSET_POST_STATUS_LABELS[3], '需人工处理')
|
||||
assert.equal(constants.REFUND_ASSET_POST_MAX_ATTEMPTS, 10)
|
||||
assert.equal(constants.TRAFFIC_RECORD_PERMISSIONS.refundAssetPostRetry, 'refund:asset_post_retry')
|
||||
assert.equal(constants.labelOf(constants.TRAFFIC_DECISION_LABELS, 'out_of_order'), '采集乱序')
|
||||
assert.equal(constants.labelOf(constants.TRAFFIC_DECISION_LABELS, 'new_value'), 'new_value')
|
||||
assert.equal(constants.labelOf(constants.TRAFFIC_DECISION_LABELS, ''), '-')
|
||||
})
|
||||
|
||||
test('卡 / 设备展示优先用 ICCID、设备号,没有时才显示内部 ID', () => {
|
||||
assert.equal(
|
||||
utils.assetDisplay({ iot_card_id: 367218, iccid: '89860624660030813727' }),
|
||||
'89860624660030813727'
|
||||
)
|
||||
assert.equal(
|
||||
utils.assetDisplay({
|
||||
device_id: 49323,
|
||||
device_virtual_no: '60003081372',
|
||||
device_imei: '869555083099518'
|
||||
}),
|
||||
'60003081372(IMEI 869555083099518)'
|
||||
)
|
||||
assert.equal(
|
||||
utils.assetDisplay({
|
||||
device_id: 1,
|
||||
device_virtual_no: '862639078983683',
|
||||
device_imei: '862639078983683'
|
||||
}),
|
||||
'862639078983683'
|
||||
)
|
||||
assert.equal(
|
||||
utils.assetDisplay({ device_id: 1, device_imei: '869555083099518' }),
|
||||
'869555083099518'
|
||||
)
|
||||
assert.equal(utils.assetDisplay({ iot_card_id: 367218 }), '卡ID 367218')
|
||||
assert.equal(utils.assetDisplay({ device_id: 49323 }), '设备ID 49323')
|
||||
assert.equal(utils.assetDisplay({}), '-')
|
||||
assert.equal(utils.nameOrId('移动账户A', 3), '移动账户A')
|
||||
assert.equal(utils.nameOrId('', 3), 'ID 3')
|
||||
assert.equal(utils.nameOrId('', 7, '账号ID'), '账号ID 7')
|
||||
assert.equal(utils.nameOrId(undefined, 0), '-')
|
||||
})
|
||||
@@ -58,6 +58,13 @@ export class PackageSilenceService extends BaseService {
|
||||
static order(id: number): Promise<BaseResponse<SilenceExtensionOrderResponse>> {
|
||||
return this.get(`${root}/orders/${id}`, undefined, quiet)
|
||||
}
|
||||
/** 作废待支付延期订单(作废前后端先查单,已支付则按支付成功办理并拒绝作废) */
|
||||
static closeOrder(
|
||||
id: number,
|
||||
reason?: string
|
||||
): Promise<BaseResponse<SilenceExtensionOrderResponse>> {
|
||||
return this.post(`${root}/orders/${id}/close`, { reason }, quiet)
|
||||
}
|
||||
static batches(params: SilenceBatchQuery): Promise<BaseResponse<SilenceBatchPageResult>> {
|
||||
return this.get(`${root}/batches`, params, quiet)
|
||||
}
|
||||
|
||||
@@ -94,4 +94,13 @@ export class RefundService extends BaseService {
|
||||
static recoverApproval(id: number): Promise<BaseResponse<Refund>> {
|
||||
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/recover-approval`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工重试退款资产后处理(仅平台账号;仅"需人工处理"的退款单)
|
||||
* 成功返回最新退款详情;仍失败时后端返回错误并保留"需人工处理"
|
||||
* @param id 退款申请ID
|
||||
*/
|
||||
static retryAssetPostProcessing(id: number): Promise<BaseResponse<Refund>> {
|
||||
return this.post<BaseResponse<Refund>>(`/api/admin/refunds/${id}/asset-post-processing/retry`)
|
||||
}
|
||||
}
|
||||
|
||||
100
src/api/modules/trafficRecords.ts
Normal file
100
src/api/modules/trafficRecords.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 流量记录查询、套餐人工冲正、退款资产后处理尝试记录
|
||||
* 后端 Iteration/8-11:记录查询接口不传资产标识时查全部,仅平台账号;资产详情接口企业账号无权调用。
|
||||
*/
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import type { BaseResponse } from '@/types/api/common'
|
||||
import type {
|
||||
RecordPage,
|
||||
TrafficCollectionQuery,
|
||||
TrafficCollectionFailureQuery,
|
||||
TrafficCollectionRecord,
|
||||
AssetTrafficCollectionQuery,
|
||||
CardDailyUsageQuery,
|
||||
CardDailyUsage,
|
||||
PackageUsageDailyRecordQuery,
|
||||
PackageUsageDailyRecord,
|
||||
PackageTrafficAdjustmentQuery,
|
||||
PackageTrafficAdjustment,
|
||||
AdjustPackageTrafficRequest,
|
||||
RefundPostProcessAttemptQuery,
|
||||
RefundPostProcessAttempt,
|
||||
RecordTimeQuery
|
||||
} from '@/types/api/trafficRecords'
|
||||
|
||||
type Page<T> = Promise<BaseResponse<RecordPage<T>>>
|
||||
|
||||
const assetPath = (identifier: string) => `/api/admin/assets/${encodeURIComponent(identifier)}`
|
||||
|
||||
export class TrafficRecordsService extends BaseService {
|
||||
/** 运营商流量采集成功记录 */
|
||||
static getCollections(params: TrafficCollectionQuery): Page<TrafficCollectionRecord> {
|
||||
return this.get('/api/admin/traffic-collections', params)
|
||||
}
|
||||
|
||||
/** 运营商流量采集失败记录 */
|
||||
static getCollectionFailures(
|
||||
params: TrafficCollectionFailureQuery
|
||||
): Page<TrafficCollectionRecord> {
|
||||
return this.get('/api/admin/traffic-collection-failures', params)
|
||||
}
|
||||
|
||||
/** 卡日流量记录(卡日快照) */
|
||||
static getCardDailyUsages(params: CardDailyUsageQuery): Page<CardDailyUsage> {
|
||||
return this.get('/api/admin/card-daily-usages', params)
|
||||
}
|
||||
|
||||
/** 套餐日流量详单 */
|
||||
static getPackageUsageDailyRecords(
|
||||
params: PackageUsageDailyRecordQuery
|
||||
): Page<PackageUsageDailyRecord> {
|
||||
return this.get('/api/admin/package-usage-daily-records', params)
|
||||
}
|
||||
|
||||
/** 套餐流量冲正流水 */
|
||||
static getTrafficAdjustments(
|
||||
params: PackageTrafficAdjustmentQuery
|
||||
): Page<PackageTrafficAdjustment> {
|
||||
return this.get('/api/admin/package-traffic-adjustments', params)
|
||||
}
|
||||
|
||||
/** 退款资产后处理尝试记录(仅平台账号,时间范围必填) */
|
||||
static getRefundPostProcessAttempts(
|
||||
params: RefundPostProcessAttemptQuery
|
||||
): Page<RefundPostProcessAttempt> {
|
||||
return this.get('/api/admin/refund-post-process-attempts', params)
|
||||
}
|
||||
|
||||
/** 资产详情:卡流量采集记录 */
|
||||
static getAssetCollections(
|
||||
identifier: string,
|
||||
params: AssetTrafficCollectionQuery
|
||||
): Page<TrafficCollectionRecord> {
|
||||
return this.get(`${assetPath(identifier)}/traffic-collections`, params)
|
||||
}
|
||||
|
||||
/** 资产详情:某笔套餐的冲正流水 */
|
||||
static getAssetPackageAdjustments(
|
||||
identifier: string,
|
||||
packageUsageId: number,
|
||||
params: RecordTimeQuery
|
||||
): Page<PackageTrafficAdjustment> {
|
||||
return this.get(
|
||||
`${assetPath(identifier)}/packages/${packageUsageId}/traffic-adjustments`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
/** 资产详情:人工冲正套餐流量(正数补扣、负数退回) */
|
||||
static adjustAssetPackageTraffic(
|
||||
identifier: string,
|
||||
packageUsageId: number,
|
||||
data: AdjustPackageTrafficRequest
|
||||
): Promise<BaseResponse<PackageTrafficAdjustment>> {
|
||||
return this.post(
|
||||
`${assetPath(identifier)}/packages/${packageUsageId}/traffic-adjustments`,
|
||||
data
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@
|
||||
>支付截止:{{ data.expire_at ? formatDateTime(data.expire_at) : '-' }}</p
|
||||
></div
|
||||
>
|
||||
<div v-if="canCloseOrder" class="silence-order-actions">
|
||||
<ElButton type="danger" plain :loading="handling" @click="closeOrder">作废订单</ElButton>
|
||||
<span class="silence-order-tip">作废后同一套餐可以重新下单;已支付的订单不能作废。</span>
|
||||
</div>
|
||||
<ElAlert
|
||||
v-if="kind === 'orders' && data.status === 'pending_approval'"
|
||||
title="审批中,尚未生效。请以订单完成状态及实际截止日为准。"
|
||||
@@ -209,6 +213,47 @@
|
||||
void load()
|
||||
}
|
||||
)
|
||||
// 作废待支付订单:需要发起延期的权限(与生成二维码相同)
|
||||
const canCloseOrder = computed(
|
||||
() =>
|
||||
props.kind === 'orders' &&
|
||||
data.value?.status === 'pending_payment' &&
|
||||
data.value?.payment_method !== 'offline' &&
|
||||
allowed.value &&
|
||||
hasAuth(P.extend)
|
||||
)
|
||||
async function closeOrder() {
|
||||
if (handling.value || !data.value) return
|
||||
const scope = pollingScope(user),
|
||||
path = route.fullPath,
|
||||
id = silenceId(Number(data.value.id))
|
||||
handling.value = true
|
||||
try {
|
||||
requireGatewayAccess(canCloseOrder.value)
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
'作废后该订单不能再支付,同一套餐可以重新下单。作废前会先向支付渠道确认,已支付的会按支付成功办理延期。可填写作废原因(选填)。',
|
||||
'作废延期订单',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认作废',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '作废原因(选填)'
|
||||
}
|
||||
)
|
||||
requireGatewayAccess(
|
||||
active && canCloseOrder.value && scope === pollingScope(user) && path === route.fullPath
|
||||
)
|
||||
silenceData(await Service.closeOrder(id, value?.trim() || undefined))
|
||||
if (active && scope === pollingScope(user) && path === route.fullPath) await load()
|
||||
} catch (e) {
|
||||
if (active && e !== 'cancel' && e !== 'close') {
|
||||
actionError.value = gatewayError(e)
|
||||
await load()
|
||||
}
|
||||
} finally {
|
||||
handling.value = false
|
||||
}
|
||||
}
|
||||
async function handle(row: Record<string, any>, action: 'retry' | 'close') {
|
||||
if (handling.value) return
|
||||
const scope = pollingScope(user),
|
||||
@@ -281,4 +326,16 @@
|
||||
margin: 4px;
|
||||
}
|
||||
}
|
||||
.silence-order-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.silence-order-tip {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="true"
|
||||
append-to-body
|
||||
title="办理沉默期延期"
|
||||
width="min(760px, 96vw)"
|
||||
destroy-on-close
|
||||
@@ -31,13 +32,43 @@
|
||||
value="special"
|
||||
label="特殊延期" /></ElSelect
|
||||
></ElFormItem>
|
||||
<ElFormItem v-if="mode === 'offline'" label="收款凭证" required
|
||||
><div class="silence-receipt"
|
||||
><VoucherUpload
|
||||
v-model="vouchers"
|
||||
key="vouchers-offline"
|
||||
:max-count="5"
|
||||
@uploading-change="(value) => (uploadingReceipt = value)"
|
||||
/><p class="silence-ocr-tip"
|
||||
>先上传收款凭证,系统会自动识别交易单号,可在下方核对修改。</p
|
||||
></div
|
||||
></ElFormItem
|
||||
>
|
||||
<template v-if="usageId">
|
||||
<ElFormItem label="套餐记录"
|
||||
>{{ usageId }}
|
||||
<ElFormItem label="套餐"
|
||||
>{{ packageLabel }}
|
||||
<ElButton link :loading="eligibilityLoading" @click="loadEligibility"
|
||||
>刷新资格</ElButton
|
||||
></ElFormItem
|
||||
>
|
||||
<ElFormItem v-if="eligibility" label="订购时间">{{
|
||||
eligibility.purchased_at ? formatDateTime(eligibility.purchased_at) : '-'
|
||||
}}</ElFormItem>
|
||||
<ElFormItem v-if="eligibility" label="到期时间">{{
|
||||
eligibility.expires_at
|
||||
? formatDateTime(eligibility.expires_at)
|
||||
: '未生效(实名激活后开始计算)'
|
||||
}}</ElFormItem>
|
||||
<ElFormItem v-if="eligibility" label="沉默期截止"
|
||||
><span class="silence-deadline">{{
|
||||
eligibility.silence_deadline_at
|
||||
? formatDateTime(eligibility.silence_deadline_at)
|
||||
: eligibility.original_deadline || '-'
|
||||
}}</span
|
||||
><span v-if="eligibility.overdue_days" class="silence-overdue"
|
||||
>(已过期 {{ eligibility.overdue_days }} 天)</span
|
||||
></ElFormItem
|
||||
>
|
||||
<ElFormItem v-if="eligibility" label="延期资格"
|
||||
><span
|
||||
>{{
|
||||
@@ -110,7 +141,15 @@
|
||||
:value="item.id"
|
||||
:label="item.name" /></ElSelect
|
||||
></ElFormItem>
|
||||
<ElFormItem label="交易单号" required><ElInput v-model="tradeNo" /></ElFormItem>
|
||||
<ElFormItem label="交易单号" required
|
||||
><ElInput
|
||||
v-model="tradeNo"
|
||||
:placeholder="ocrLoading ? '正在识别凭证中的交易单号…' : '请输入或核对交易单号'"
|
||||
@input="tradeNoTouched = true"
|
||||
/><span v-if="ocrMessage" :class="['silence-ocr-tip', ocrTone]">{{
|
||||
ocrMessage
|
||||
}}</span></ElFormItem
|
||||
>
|
||||
<ElFormItem v-if="mode === 'offline'" label="实收金额(元)" required
|
||||
><ElInput v-model="paidYuan" inputmode="decimal"
|
||||
/></ElFormItem>
|
||||
@@ -121,7 +160,7 @@
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
/></ElFormItem>
|
||||
<ElFormItem label="收款凭证" required
|
||||
<ElFormItem v-if="mode !== 'offline'" label="收款凭证" required
|
||||
><VoucherUpload
|
||||
v-model="vouchers"
|
||||
:key="`vouchers-${mode}`"
|
||||
@@ -183,6 +222,7 @@
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
|
||||
import { EmployeeCollectionService } from '@/api/modules/employeeCollection'
|
||||
import { AgentRechargeService } from '@/api/modules/agentRecharge'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
|
||||
@@ -209,8 +249,25 @@
|
||||
} from '@/types/api/packageSilenceContract'
|
||||
import VoucherUpload from '@/components/business/VoucherUpload.vue'
|
||||
import SilenceTable from './SilenceTable.vue'
|
||||
const props = defineProps<{ usageId?: number; allowed?: boolean; batchMode?: boolean }>()
|
||||
// allowed 未传时视为允许:Vue 会把未传的布尔 prop 当成 false,不设默认值时资产详情入口一律报"无权执行此操作"
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
usageId?: number
|
||||
allowed?: boolean
|
||||
batchMode?: boolean
|
||||
packageName?: string
|
||||
assetIdentifier?: string
|
||||
}>(),
|
||||
{ allowed: true, batchMode: false }
|
||||
)
|
||||
const emit = defineEmits<{ close: []; changed: [] }>()
|
||||
// 展示套餐名称(及资产),不展示内部套餐记录 ID;调用方未传名称时才退回显示 ID
|
||||
const packageLabel = computed(() => {
|
||||
const name = eligibility.value?.package_name?.trim() || props.packageName?.trim()
|
||||
const asset = eligibility.value?.asset_identifier || props.assetIdentifier
|
||||
if (!name) return props.usageId ? `套餐记录 ${props.usageId}` : '-'
|
||||
return asset ? `${name}(${asset})` : name
|
||||
})
|
||||
const { hasAuth } = useAuth(),
|
||||
user = useUserStore()
|
||||
const mode = ref<'online' | 'offline' | 'special'>(
|
||||
@@ -288,6 +345,62 @@
|
||||
quote.value.forUsage === props.usageId &&
|
||||
Number.isSafeInteger(quote.value.amount))
|
||||
)
|
||||
// 线下登记:上传第一张收款凭证后自动识别交易单号(复用代理充值的凭证识别,只预填、需人工核对)
|
||||
const ocrLoading = ref(false),
|
||||
ocrMessage = ref(''),
|
||||
ocrTone = ref<'info' | 'success' | 'warning'>('info'),
|
||||
tradeNoTouched = ref(false)
|
||||
let ocrSequence = 0,
|
||||
ocrKey = ''
|
||||
async function recognizeTradeNo(key: string) {
|
||||
if (!key || key === ocrKey) return
|
||||
ocrKey = key
|
||||
const current = ++ocrSequence
|
||||
ocrLoading.value = true
|
||||
ocrTone.value = 'info'
|
||||
ocrMessage.value = '正在识别交易单号…'
|
||||
try {
|
||||
const res = await AgentRechargeService.recognizePaymentVoucher({ payment_voucher_key: key })
|
||||
if (current !== ocrSequence) return
|
||||
const transactionNo = res.code === 0 ? res.data?.external_transaction_no?.trim() : ''
|
||||
if (!transactionNo) {
|
||||
ocrTone.value = 'warning'
|
||||
ocrMessage.value = '未识别到交易单号,请手动填写'
|
||||
return
|
||||
}
|
||||
if (!tradeNoTouched.value || !tradeNo.value.trim()) {
|
||||
tradeNo.value = transactionNo
|
||||
tradeNoTouched.value = false
|
||||
}
|
||||
ocrTone.value = 'success'
|
||||
ocrMessage.value = `已识别交易单号 ${transactionNo},请对照凭证核对`
|
||||
} catch {
|
||||
if (current !== ocrSequence) return
|
||||
ocrTone.value = 'warning'
|
||||
ocrMessage.value = '识别失败,请手动填写交易单号'
|
||||
} finally {
|
||||
if (current === ocrSequence) ocrLoading.value = false
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [mode.value, vouchers.value[0]] as const,
|
||||
([currentMode, firstKey]) => {
|
||||
if (currentMode === 'offline' && firstKey) void recognizeTradeNo(firstKey)
|
||||
if (!firstKey) {
|
||||
ocrKey = ''
|
||||
ocrMessage.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
// 线下登记的实收金额必须等于试算金额:试算后自动带入,仍可修改
|
||||
watch(
|
||||
() => quote.value?.amount,
|
||||
(amount) => {
|
||||
if (mode.value === 'offline' && Number.isSafeInteger(amount) && !paidYuan.value) {
|
||||
paidYuan.value = (Number(amount) / 100).toFixed(2)
|
||||
}
|
||||
}
|
||||
)
|
||||
let active = true,
|
||||
version = 0,
|
||||
timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -314,6 +427,11 @@
|
||||
basis.value = ''
|
||||
methodId.value = undefined
|
||||
tradeNo.value = ''
|
||||
tradeNoTouched.value = false
|
||||
ocrSequence++
|
||||
ocrKey = ''
|
||||
ocrLoading.value = false
|
||||
ocrMessage.value = ''
|
||||
paidYuan.value = ''
|
||||
paidAt.value = ''
|
||||
batchItems.value = []
|
||||
@@ -592,4 +710,28 @@
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.silence-receipt {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.silence-ocr-tip {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
&.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.silence-overdue {
|
||||
margin-left: 4px;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
v-if="row.silence.extendable && (hasAuth(P.extend) || hasAuth(P.offline))"
|
||||
link
|
||||
type="primary"
|
||||
@click="extendId = row.package_usage_id"
|
||||
@click="openExtension(row)"
|
||||
>延期</ElButton
|
||||
>
|
||||
</template>
|
||||
@@ -140,6 +140,8 @@
|
||||
<SilenceExtendDialog
|
||||
v-if="extendId || batchVisible"
|
||||
:usage-id="extendId"
|
||||
:package-name="extendRow?.package_name"
|
||||
:asset-identifier="extendRow?.asset_identifier"
|
||||
:batch-mode="batchVisible"
|
||||
:allowed="allowed"
|
||||
@close="closeExtension"
|
||||
@@ -232,6 +234,7 @@
|
||||
changing = ref(false)
|
||||
const recordsId = ref<number>(),
|
||||
extendId = ref<number>(),
|
||||
extendRow = ref<Record<string, any>>(),
|
||||
batchVisible = ref(false),
|
||||
policyVisible = ref(false),
|
||||
policyId = ref<number>(),
|
||||
@@ -533,6 +536,10 @@
|
||||
actionError.value = gatewayError(e)
|
||||
}
|
||||
}
|
||||
function openExtension(row: Record<string, any>) {
|
||||
extendRow.value = row
|
||||
extendId.value = row.package_usage_id
|
||||
}
|
||||
async function matchPolicy() {
|
||||
if (!canManagePolicy.value || !matchPackage.value || matching.value) return
|
||||
const scope = pollingScope(user),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
append-to-body
|
||||
:model-value="true"
|
||||
:title="id ? '编辑延期政策' : '新增延期政策'"
|
||||
width="min(780px, 96vw)"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="true"
|
||||
append-to-body
|
||||
:title="firstPurchase ? '首次购买套餐记录' : '沉默期流水'"
|
||||
width="min(1100px, 96vw)"
|
||||
destroy-on-close
|
||||
@@ -16,12 +17,16 @@
|
||||
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
|
||||
import { silenceData, silenceId } from '@/utils/business/silenceExtension'
|
||||
import SilenceTable from './SilenceTable.vue'
|
||||
const props = defineProps<{
|
||||
usageId?: number
|
||||
assetId?: number
|
||||
assetType?: 'card' | 'device'
|
||||
allowed?: boolean
|
||||
}>()
|
||||
// allowed 未传时视为允许(Vue 会把未传的布尔 prop 当成 false)
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
usageId?: number
|
||||
assetId?: number
|
||||
assetType?: 'card' | 'device'
|
||||
allowed?: boolean
|
||||
}>(),
|
||||
{ allowed: true }
|
||||
)
|
||||
defineEmits<{ close: [] }>()
|
||||
const firstPurchase = computed(() => !!props.assetType)
|
||||
const { data, error, loading, load } = useSchedulerRequest<object[]>(
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
<SilenceExtendDialog
|
||||
v-if="extendVisible && usageId"
|
||||
:usage-id="usageId"
|
||||
:package-name="packageName"
|
||||
:asset-identifier="assetIdentifier"
|
||||
@close="closeExtension"
|
||||
@changed="dirty = true"
|
||||
/>
|
||||
@@ -56,7 +58,12 @@
|
||||
import type { PackageSilenceInfo } from '@/types/api/packageSilence'
|
||||
import { silenceStatusLabel } from '@/utils/business/packageSilence'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
const props = defineProps<{ silence?: PackageSilenceInfo | null; usageId?: number }>()
|
||||
const props = defineProps<{
|
||||
silence?: PackageSilenceInfo | null
|
||||
usageId?: number
|
||||
packageName?: string
|
||||
assetIdentifier?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const { hasAuth } = useAuth()
|
||||
const recordsVisible = ref(false),
|
||||
|
||||
144
src/components/business/traffic-records/CardDailyUsages.vue
Normal file
144
src/components/business/traffic-records/CardDailyUsages.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ArtSearchBar
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
label-width="110px"
|
||||
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<ElCard shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
hint="数据来自卡日快照(每卡每天一条);日期含首尾,跨度不超过 31 天。不填资产标识查全部仅平台账号可用。"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable v-loading="loading" :data="rows" row-key="id" border empty-text="暂无卡日流量">
|
||||
<ElTableColumn label="日期" prop="date" width="120" />
|
||||
<ElTableColumn label="ICCID" min-width="190">
|
||||
<template #default="{ row }">{{ assetDisplay(row) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="运营商账户" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ nameOrId(row.carrier_name, row.carrier_id) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="当日用量" min-width="110">
|
||||
<template #default="{ row }">{{ formatMb(row.settled_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="当日结束本月累计" min-width="140">
|
||||
<template #default="{ row }">{{ formatMb(row.month_used_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="最后一次运营商读数" min-width="150">
|
||||
<template #default="{ row }">{{ formatMb(row.vendor_used_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="写入次数" prop="obs_count" width="90" />
|
||||
<ElTableColumn label="防旧值" width="80">
|
||||
<template #default="{ row }">{{ row.stale_guarded ? '是' : '否' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="更新时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useCarrierFilterItem, useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { CardDailyUsage } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
compactQuery,
|
||||
assetDisplay,
|
||||
formatMb,
|
||||
nameOrId,
|
||||
recentDateRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toDateQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
carrier_id: undefined as number | undefined,
|
||||
min_usage_mb: '',
|
||||
range: recentDateRange(7) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize } =
|
||||
useRecordList<CardDailyUsage>((page) => {
|
||||
const identifier = filters.identifier.trim()
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
const minUsage = filters.min_usage_mb === '' ? undefined : Number(filters.min_usage_mb)
|
||||
if (minUsage !== undefined && (!Number.isFinite(minUsage) || minUsage < 0)) {
|
||||
throw new Error('当日用量下限须为非负数')
|
||||
}
|
||||
return TrafficRecordsService.getCardDailyUsages(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toDateQuery(filters.range),
|
||||
identifier,
|
||||
carrier_id: filters.carrier_id,
|
||||
min_usage_mb: minUsage
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const { item: carrierItem } = useCarrierFilterItem()
|
||||
const searchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / MSISDN / IMEI / SN' }
|
||||
},
|
||||
{
|
||||
label: '日期',
|
||||
prop: 'range',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
clearable: false
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '当日用量≥(MB)',
|
||||
prop: 'min_usage_mb',
|
||||
type: 'input',
|
||||
config: { clearable: true, placeholder: '排查异常大流量' }
|
||||
},
|
||||
carrierItem.value
|
||||
])
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ArtSearchBar
|
||||
v-if="!isFixed"
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
:show-expand="true"
|
||||
label-width="120px"
|
||||
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<component :is="isFixed ? 'div' : ElCard" shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
:hint="hint"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
border
|
||||
:max-height="isFixed ? 460 : undefined"
|
||||
empty-text="暂无冲正记录"
|
||||
>
|
||||
<ElTableColumn label="冲正时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
v-if="!isFixed"
|
||||
label="ICCID / 设备号"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ assetDisplay(row) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="!isFixed" label="套餐" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.package_name || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="调整量" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.delta_mb > 0 ? 'delta-deduct' : 'delta-refund'">
|
||||
{{ row.delta_mb > 0 ? '补扣' : '退回' }} {{ formatMb(Math.abs(row.delta_mb)) }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="已用量变化" min-width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatMb(row.before_usage_mb) }} → {{ formatMb(row.after_usage_mb) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="套餐状态变化" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ statusName(row.before_status) }} → {{ statusName(row.after_status) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="原因" prop="reason" min-width="200" show-overflow-tooltip />
|
||||
<ElTableColumn label="操作人" min-width="120">
|
||||
<template #default="{ row }">{{
|
||||
nameOrId(row.operator_name, row.operator_id, '账号ID')
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
label="关联采集 ID"
|
||||
prop="ref_observation_id"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
label="关联流量事件 ID"
|
||||
prop="ref_event_id"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { ElCard } from 'element-plus'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { PackageTrafficAdjustment } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { ADJUSTMENT_DIRECTION_OPTIONS } from '@/config/constants/trafficRecords'
|
||||
import {
|
||||
compactQuery,
|
||||
assetDisplay,
|
||||
formatMb,
|
||||
nameOrId,
|
||||
recentTimeRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toTimeQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
/** 传入 identifier + packageUsageId 时为资产详情里的单笔套餐冲正记录 */
|
||||
const props = defineProps<{ identifier?: string; packageUsageId?: number }>()
|
||||
const isFixed = computed(() => !!props.identifier && !!props.packageUsageId)
|
||||
const hint = computed(() =>
|
||||
isFixed.value
|
||||
? '按时间倒序展示该套餐的全部冲正记录。'
|
||||
: '时间跨度不超过 31 天,结束时间不含。补扣为正数、退回为负数。不填资产标识查全部仅平台账号可用。'
|
||||
)
|
||||
|
||||
const PACKAGE_STATUS_NAMES: Record<number, string> = {
|
||||
0: '待生效',
|
||||
1: '生效中',
|
||||
2: '已用完',
|
||||
3: '已过期',
|
||||
4: '已失效'
|
||||
}
|
||||
const statusName = (status?: number) =>
|
||||
status === undefined || status === null ? '-' : PACKAGE_STATUS_NAMES[status] || String(status)
|
||||
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
operator_id: undefined as number | undefined,
|
||||
direction: '',
|
||||
range: recentTimeRange(7) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear } =
|
||||
useRecordList<PackageTrafficAdjustment>((page) => {
|
||||
if (isFixed.value) {
|
||||
return TrafficRecordsService.getAssetPackageAdjustments(
|
||||
props.identifier!,
|
||||
props.packageUsageId!,
|
||||
page
|
||||
)
|
||||
}
|
||||
const identifier = filters.identifier.trim()
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
return TrafficRecordsService.getTrafficAdjustments(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toTimeQuery(filters.range),
|
||||
identifier,
|
||||
operator_id: filters.operator_id || undefined,
|
||||
direction: filters.direction as 'deduct' | 'refund' | ''
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / MSISDN / IMEI / SN' }
|
||||
},
|
||||
{
|
||||
label: '冲正时间',
|
||||
prop: 'range',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
clearable: false
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '操作人',
|
||||
prop: 'operator_id',
|
||||
type: 'business-select',
|
||||
config: { entity: 'account', placeholder: '输入账号用户名搜索' }
|
||||
},
|
||||
{
|
||||
label: '调整方向',
|
||||
prop: 'direction',
|
||||
type: 'select',
|
||||
options: ADJUSTMENT_DIRECTION_OPTIONS,
|
||||
config: { clearable: true }
|
||||
}
|
||||
]
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.identifier, props.packageUsageId],
|
||||
() => {
|
||||
clear()
|
||||
pagination.page = 1
|
||||
void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ reload: load })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.delta-deduct {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.delta-refund {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ArtSearchBar
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
:show-expand="true"
|
||||
label-width="120px"
|
||||
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<ElCard shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
hint="日期含首尾,跨度不超过 31 天;设备标识同时匹配设备套餐与其绑定卡的套餐。不填资产标识查全部仅平台账号可用。"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable v-loading="loading" :data="rows" row-key="id" border empty-text="暂无套餐日流量">
|
||||
<ElTableColumn label="日期" prop="date" width="120" />
|
||||
<ElTableColumn label="ICCID / 设备号" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ assetDisplay(row) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="套餐" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.package_name || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="当日流量" min-width="110">
|
||||
<template #default="{ row }">{{ formatMb(row.daily_usage_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="截止当日累计" min-width="120">
|
||||
<template #default="{ row }">{{ formatMb(row.cumulative_usage_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="更新时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useCarrierFilterItem, useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { PackageUsageDailyRecord } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
compactQuery,
|
||||
assetDisplay,
|
||||
formatMb,
|
||||
recentDateRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toDateQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
package_id: undefined as number | undefined,
|
||||
carrier_id: undefined as number | undefined,
|
||||
range: recentDateRange(7) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize } =
|
||||
useRecordList<PackageUsageDailyRecord>((page) => {
|
||||
const identifier = filters.identifier.trim()
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
return TrafficRecordsService.getPackageUsageDailyRecords(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toDateQuery(filters.range),
|
||||
identifier,
|
||||
package_id: filters.package_id || undefined,
|
||||
carrier_id: filters.carrier_id
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const { item: carrierItem } = useCarrierFilterItem()
|
||||
const searchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / MSISDN / IMEI / SN' }
|
||||
},
|
||||
{
|
||||
label: '日期',
|
||||
prop: 'range',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
clearable: false
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '套餐',
|
||||
prop: 'package_id',
|
||||
type: 'business-select',
|
||||
config: { entity: 'package', placeholder: '输入套餐名称搜索' }
|
||||
},
|
||||
carrierItem.value
|
||||
])
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
78
src/components/business/traffic-records/RawDataPanel.vue
Normal file
78
src/components/business/traffic-records/RawDataPanel.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="raw-data-panel">
|
||||
<div v-for="block in visibleBlocks" :key="block.title" class="raw-data-block">
|
||||
<div class="raw-data-title">
|
||||
<span>{{ block.title }}</span>
|
||||
<ElTag v-if="block.truncated" size="small" type="warning">已截断</ElTag>
|
||||
<ElButton link type="primary" size="small" @click="copy(block.text)">复制</ElButton>
|
||||
</div>
|
||||
<pre class="raw-data-content">{{ block.text }}</pre>
|
||||
</div>
|
||||
<div v-if="!visibleBlocks.length" class="raw-data-empty">无原始数据</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { RawDataBlock } from '@/types/api/trafficRecords'
|
||||
|
||||
const props = defineProps<{ blocks: RawDataBlock[] }>()
|
||||
|
||||
const toText = (value: unknown) => {
|
||||
if (typeof value === 'string') return value
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleBlocks = computed(() =>
|
||||
props.blocks
|
||||
.filter((block) => block.value !== undefined && block.value !== null && block.value !== '')
|
||||
.map((block) => ({ ...block, text: toText(block.value) }))
|
||||
)
|
||||
|
||||
async function copy(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.raw-data-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.raw-data-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.raw-data-content {
|
||||
max-height: 320px;
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.raw-data-empty {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
65
src/components/business/traffic-records/RecordTableShell.vue
Normal file
65
src/components/business/traffic-records/RecordTableShell.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="record-shell">
|
||||
<div v-if="hint" class="record-hint">{{ hint }}</div>
|
||||
<ElAlert
|
||||
v-if="error"
|
||||
class="record-error"
|
||||
type="error"
|
||||
:title="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<slot />
|
||||
<div class="record-pagination">
|
||||
<ElPagination
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="pageSizes"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="(value: number) => emit('page-change', value)"
|
||||
@size-change="(value: number) => emit('size-change', value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
error?: string
|
||||
hint?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
pageSizes?: number[]
|
||||
}>(),
|
||||
{ error: '', hint: '', pageSizes: () => [20, 50, 100] }
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
(e: 'page-change', page: number): void
|
||||
(e: 'size-change', size: number): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.record-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-hint {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.record-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ElAlert
|
||||
v-if="!allowed"
|
||||
type="warning"
|
||||
title="退款资产后处理尝试记录仅平台账号可查看"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<template v-else>
|
||||
<ArtSearchBar
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
:show-expand="true"
|
||||
label-width="110px"
|
||||
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<ElCard shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
hint="时间范围必填,跨度不超过 31 天,结束时间不含;按时间倒序。"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable v-loading="loading" :data="rows" row-key="id" border empty-text="暂无尝试记录">
|
||||
<ElTableColumn label="记录时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="退款单号" min-width="180">
|
||||
<template #default="{ row }">{{ row.refund_no || row.refund_id }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="资产" min-width="200">
|
||||
<template #default="{ row }">
|
||||
{{ row.asset_type === 'device' ? '设备' : '单卡' }}
|
||||
{{
|
||||
row.asset_type === 'device'
|
||||
? assetDisplay({
|
||||
device_id: row.asset_id,
|
||||
device_virtual_no: row.device_virtual_no,
|
||||
device_imei: row.device_imei
|
||||
})
|
||||
: assetDisplay({ iot_card_id: row.asset_id, iccid: row.iccid })
|
||||
}}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="第几次" prop="attempt" width="80" />
|
||||
<ElTableColumn label="步骤" width="110">
|
||||
<template #default="{ row }">{{
|
||||
labelOf(REFUND_POST_STEP_LABELS, row.step)
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.result === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ labelOf(REFUND_POST_RESULT_LABELS, row.result) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="错误码" prop="error_code" min-width="120" show-overflow-tooltip />
|
||||
<ElTableColumn
|
||||
label="错误信息"
|
||||
prop="error_message"
|
||||
min-width="220"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn label="触发方式" width="90">
|
||||
<template #default="{ row }">{{
|
||||
labelOf(REFUND_POST_TRIGGER_LABELS, row.trigger)
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="耗时(ms)" prop="duration_ms" width="90" />
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</ElCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { RefundPostProcessAttempt } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
REFUND_POST_RESULT_LABELS,
|
||||
REFUND_POST_RESULT_OPTIONS,
|
||||
REFUND_POST_STEP_LABELS,
|
||||
REFUND_POST_STEP_OPTIONS,
|
||||
REFUND_POST_TRIGGER_LABELS,
|
||||
REFUND_POST_TRIGGER_OPTIONS,
|
||||
RECORD_MAX_SPAN_DAYS,
|
||||
labelOf
|
||||
} from '@/config/constants/trafficRecords'
|
||||
import {
|
||||
compactQuery,
|
||||
assetDisplay,
|
||||
isPlatformAccount,
|
||||
recentTimeRange,
|
||||
toTimeQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
/** refundId 来自退款详情入口:自动按该退款单筛选,时间范围取最近 31 天 */
|
||||
const props = defineProps<{ refundId?: number }>()
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const allowed = computed(() => isPlatformAccount(userInfo.value.user_type))
|
||||
|
||||
const emptyFilters = () => ({
|
||||
refund_no: '',
|
||||
identifier: '',
|
||||
step: '',
|
||||
result: '',
|
||||
error_code: '',
|
||||
trigger: '',
|
||||
range: recentTimeRange(props.refundId ? RECORD_MAX_SPAN_DAYS : 7) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear } =
|
||||
useRecordList<RefundPostProcessAttempt>((page) =>
|
||||
TrafficRecordsService.getRefundPostProcessAttempts(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toTimeQuery(filters.range, true),
|
||||
// 从退款详情进入时固定按该退款单筛选,不让用户手填内部 ID
|
||||
refund_id: props.refundId || undefined,
|
||||
refund_no: filters.refund_no.trim(),
|
||||
identifier: filters.identifier.trim(),
|
||||
step: filters.step,
|
||||
result: filters.result,
|
||||
error_code: filters.error_code.trim(),
|
||||
trigger: filters.trigger
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const select = (label: string, prop: string, options: SearchFormItem['options']) => ({
|
||||
label,
|
||||
prop,
|
||||
type: 'select' as const,
|
||||
options,
|
||||
config: { clearable: true }
|
||||
})
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '记录时间',
|
||||
prop: 'range',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
clearable: true
|
||||
}
|
||||
},
|
||||
{ label: '退款单号', prop: 'refund_no', type: 'input', config: { clearable: true } },
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / IMEI / SN' }
|
||||
},
|
||||
select('处理步骤', 'step', REFUND_POST_STEP_OPTIONS),
|
||||
select('结果', 'result', REFUND_POST_RESULT_OPTIONS),
|
||||
{ label: '错误码', prop: 'error_code', type: 'input', config: { clearable: true } },
|
||||
select('触发方式', 'trigger', REFUND_POST_TRIGGER_OPTIONS)
|
||||
]
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.refundId, allowed.value],
|
||||
() => {
|
||||
clear()
|
||||
Object.assign(filters, emptyFilters())
|
||||
pagination.page = 1
|
||||
if (allowed.value) void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
192
src/components/business/traffic-records/TrafficAdjustDialog.vue
Normal file
192
src/components/business/traffic-records/TrafficAdjustDialog.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="modelValue"
|
||||
title="人工冲正"
|
||||
width="min(560px, 96vw)"
|
||||
destroy-on-close
|
||||
@update:model-value="(value: boolean) => emit('update:modelValue', value)"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<ElFormItem label="套餐名称">
|
||||
<span>{{ packageName || '-' }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="当前已用量">
|
||||
<span>{{ formatMb(currentUsageMb) }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="调整方向" prop="direction">
|
||||
<ElRadioGroup v-model="form.direction">
|
||||
<ElRadio value="deduct">补扣(增加已用量)</ElRadio>
|
||||
<ElRadio value="refund">退回(减少已用量)</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="调整量(MB)" prop="amount">
|
||||
<ElInputNumber
|
||||
v-model="form.amount"
|
||||
:min="1"
|
||||
:max="form.direction === 'refund' ? Math.max(currentUsageMb, 1) : undefined"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
step-strictly
|
||||
placeholder="正整数"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="调整后已用量">
|
||||
<span :class="{ 'adjust-invalid': expectedAfter !== null && expectedAfter < 0 }">
|
||||
{{ expectedAfter === null ? '-' : formatMb(expectedAfter) }}
|
||||
</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="原因" prop="reason">
|
||||
<ElInput
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="必填,说明冲正依据"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="关联采集 ID" prop="ref_observation_id">
|
||||
<ElInput v-model="form.ref_observation_id" maxlength="128" clearable placeholder="选填" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="关联流量事件 ID" prop="ref_event_id">
|
||||
<ElInput v-model="form.ref_event_id" maxlength="128" clearable placeholder="选填" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<ElAlert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="提交后系统会按规则重新判断停复机,前端不会直接停机或复机。"
|
||||
/>
|
||||
<template #footer>
|
||||
<ElButton @click="emit('update:modelValue', false)">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="submit">提交冲正</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { errorMessage, formatMb } from '@/utils/business/trafficRecords'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
identifier: string
|
||||
packageUsageId?: number
|
||||
packageName?: string
|
||||
currentUsageMb: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const emptyForm = () => ({
|
||||
direction: 'deduct' as 'deduct' | 'refund',
|
||||
amount: undefined as number | undefined,
|
||||
reason: '',
|
||||
ref_observation_id: '',
|
||||
ref_event_id: ''
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const deltaMb = computed(() => {
|
||||
if (!form.amount) return null
|
||||
return form.direction === 'deduct' ? form.amount : -form.amount
|
||||
})
|
||||
const expectedAfter = computed(() =>
|
||||
deltaMb.value === null ? null : props.currentUsageMb + deltaMb.value
|
||||
)
|
||||
|
||||
const rules: FormRules = {
|
||||
direction: [{ required: true, message: '请选择调整方向', trigger: 'change' }],
|
||||
amount: [
|
||||
{ required: true, message: '请输入调整量', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (!Number.isInteger(value) || value <= 0) return callback(new Error('调整量须为正整数'))
|
||||
if (form.direction === 'refund' && value > props.currentUsageMb) {
|
||||
return callback(new Error('调整后已用量不能小于 0'))
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
reason: [
|
||||
{
|
||||
validator: (_rule, value: string, callback) =>
|
||||
value?.trim() ? callback() : callback(new Error('原因必填')),
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, emptyForm())
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value || !formRef.value) return
|
||||
if (!props.identifier || !props.packageUsageId) {
|
||||
ElMessage.error('缺少资产标识或套餐使用记录ID')
|
||||
return
|
||||
}
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid || deltaMb.value === null || expectedAfter.value === null) return
|
||||
if (expectedAfter.value < 0) {
|
||||
ElMessage.error('调整后已用量不能小于 0')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`当前已用量 ${formatMb(props.currentUsageMb)},${
|
||||
form.direction === 'deduct' ? '补扣' : '退回'
|
||||
} ${formatMb(form.amount)},调整后 ${formatMb(expectedAfter.value)}。提交后系统会按规则重新判断停复机,确认提交?`,
|
||||
'确认人工冲正',
|
||||
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const response = await TrafficRecordsService.adjustAssetPackageTraffic(
|
||||
props.identifier,
|
||||
props.packageUsageId,
|
||||
{
|
||||
delta_mb: deltaMb.value,
|
||||
reason: form.reason.trim(),
|
||||
ref_observation_id: form.ref_observation_id.trim() || undefined,
|
||||
ref_event_id: form.ref_event_id.trim() || undefined
|
||||
}
|
||||
)
|
||||
const result = response.data
|
||||
ElMessage.success(
|
||||
result
|
||||
? `冲正成功:已用量 ${formatMb(result.before_usage_mb)} → ${formatMb(result.after_usage_mb)}`
|
||||
: '冲正成功'
|
||||
)
|
||||
emit('update:modelValue', false)
|
||||
emit('success')
|
||||
} catch (cause) {
|
||||
// 请求层已弹出后端错误;这里只兜底非 HTTP 错误
|
||||
if (!(cause as { response?: unknown })?.response) ElMessage.error(errorMessage(cause))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.adjust-invalid {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ArtSearchBar
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
:show-expand="searchItems.length >= 4"
|
||||
label-width="110px"
|
||||
@update:filter="updateFilters"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<ElCard shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
:hint="hint"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
:page-sizes="pageSizes"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
border
|
||||
:max-height="isAsset ? 520 : undefined"
|
||||
:empty-text="emptyText"
|
||||
>
|
||||
<ElTableColumn type="expand" width="44">
|
||||
<template #default="{ row }">
|
||||
<RawDataPanel :blocks="rawBlocks(row)" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="采集时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.observed_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="isAsset" label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.record_kind === 'failure' ? 'danger' : 'success'" size="small">
|
||||
{{ row.record_kind === 'failure' ? '失败' : '成功' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="!isAsset" label="ICCID" min-width="190">
|
||||
<template #default="{ row }">{{ assetDisplay(row) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="!isAsset" label="运营商账户" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ nameOrId(row.carrier_name, row.carrier_id) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="来源" width="90">
|
||||
<template #default="{ row }">{{ labelOf(TRAFFIC_SOURCE_LABELS, row.source) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="场景" prop="scene" min-width="120" show-overflow-tooltip />
|
||||
<template v-if="mode !== 'failure'">
|
||||
<ElTableColumn label="运营商读数" min-width="110">
|
||||
<template #default="{ row }">{{
|
||||
successValue(row, formatMb(row.vendor_used_mb))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="本月累计" min-width="110">
|
||||
<template #default="{ row }">{{
|
||||
successValue(row, formatMb(row.month_used_mb))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="本次增量" min-width="100">
|
||||
<template #default="{ row }">{{
|
||||
successValue(row, formatMb(row.delta_mb))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="写入判断" min-width="110">
|
||||
<template #default="{ row }">
|
||||
{{ successValue(row, labelOf(TRAFFIC_DECISION_LABELS, row.decision)) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="防旧值" width="80">
|
||||
<template #default="{ row }">{{
|
||||
successValue(row, row.stale_guarded ? '是' : '否')
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
<template v-if="mode !== 'success'">
|
||||
<ElTableColumn label="失败类型" min-width="100">
|
||||
<template #default="{ row }">{{
|
||||
failureValue(row, labelOf(TRAFFIC_RESULT_LABELS, row.result))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="错误类别" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ failureValue(row, labelOf(TRAFFIC_ERROR_CATEGORY_LABELS, row.error_category)) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="上游返回" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ failureValue(row, joinText(row.provider_code, row.provider_message)) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="HTTP 状态" width="90">
|
||||
<template #default="{ row }">{{
|
||||
failureValue(row, display(row.http_status))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="耗时(ms)" width="90">
|
||||
<template #default="{ row }">{{
|
||||
failureValue(row, display(row.duration_ms))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
<ElTableColumn label="网关 code / msg" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{
|
||||
joinText(row.gateway_code, row.gateway_msg)
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
label="网关 trace_id"
|
||||
prop="gateway_trace_id"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn label="网关尝试" width="80">
|
||||
<template #default="{ row }">{{ display(row.gateway_attempts) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
label="采集 ID"
|
||||
prop="observation_id"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useCarrierFilterItem, useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { RawDataBlock, TrafficCollectionRecord } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
TRAFFIC_DECISION_LABELS,
|
||||
TRAFFIC_DECISION_OPTIONS,
|
||||
TRAFFIC_ERROR_CATEGORY_LABELS,
|
||||
TRAFFIC_ERROR_CATEGORY_OPTIONS,
|
||||
TRAFFIC_FAILURE_RESULT_OPTIONS,
|
||||
TRAFFIC_RESULT_LABELS,
|
||||
TRAFFIC_SOURCE_LABELS,
|
||||
TRAFFIC_SOURCE_OPTIONS,
|
||||
BOOLEAN_FILTER_OPTIONS,
|
||||
labelOf
|
||||
} from '@/config/constants/trafficRecords'
|
||||
import {
|
||||
compactQuery,
|
||||
assetDisplay,
|
||||
formatMb,
|
||||
nameOrId,
|
||||
optionalBoolean,
|
||||
recentTimeRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toTimeQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RawDataPanel from './RawDataPanel.vue'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
/**
|
||||
* success:全局采集成功记录;failure:全局采集失败记录;
|
||||
* asset:资产详情的卡采集记录(固定 identifier,可包含失败记录)。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{ mode?: 'success' | 'failure' | 'asset'; identifier?: string }>(),
|
||||
{ mode: 'success', identifier: '' }
|
||||
)
|
||||
|
||||
const isAsset = computed(() => props.mode === 'asset')
|
||||
const { item: carrierItem } = useCarrierFilterItem(props.mode !== 'asset')
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const pageSizes = computed(() => (isAsset.value ? [50, 100] : [20, 50, 100]))
|
||||
const emptyText = computed(() => (props.mode === 'failure' ? '暂无采集失败记录' : '暂无采集记录'))
|
||||
const hint = computed(() =>
|
||||
isAsset.value
|
||||
? '仅采集时刻有套餐的卡有记录,保留 90 天;时间跨度不超过 31 天,结束时间不含。'
|
||||
: '时间跨度不超过 31 天,结束时间不含;在线保留当月加最近 2 个整月。不填资产标识查全部仅平台账号可用。'
|
||||
)
|
||||
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
carrier_id: undefined as number | undefined,
|
||||
decision: '',
|
||||
source: '',
|
||||
scene: '',
|
||||
has_delta: '',
|
||||
stale_guarded: '',
|
||||
result: '',
|
||||
error_category: '',
|
||||
provider_code: '',
|
||||
include_failures: '',
|
||||
range: recentTimeRange(1) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear } =
|
||||
useRecordList<TrafficCollectionRecord>(
|
||||
(page) => {
|
||||
const time = toTimeQuery(filters.range)
|
||||
if (isAsset.value) {
|
||||
return TrafficRecordsService.getAssetCollections(
|
||||
props.identifier,
|
||||
compactQuery({
|
||||
...page,
|
||||
...time,
|
||||
decision: filters.decision,
|
||||
source: filters.source,
|
||||
scene: filters.scene.trim(),
|
||||
include_failures: optionalBoolean(filters.include_failures)
|
||||
})
|
||||
)
|
||||
}
|
||||
const identifier = filters.identifier.trim()
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
const common = {
|
||||
...page,
|
||||
...time,
|
||||
identifier,
|
||||
carrier_id: filters.carrier_id,
|
||||
source: filters.source,
|
||||
scene: filters.scene.trim()
|
||||
}
|
||||
if (props.mode === 'failure') {
|
||||
return TrafficRecordsService.getCollectionFailures(
|
||||
compactQuery({
|
||||
...common,
|
||||
result: filters.result,
|
||||
error_category: filters.error_category,
|
||||
provider_code: filters.provider_code.trim()
|
||||
})
|
||||
)
|
||||
}
|
||||
return TrafficRecordsService.getCollections(
|
||||
compactQuery({
|
||||
...common,
|
||||
decision: filters.decision,
|
||||
has_delta: optionalBoolean(filters.has_delta),
|
||||
stale_guarded: optionalBoolean(filters.stale_guarded)
|
||||
})
|
||||
)
|
||||
},
|
||||
isAsset.value ? 50 : 20
|
||||
)
|
||||
|
||||
const rangeItem: SearchFormItem = {
|
||||
label: '采集时间',
|
||||
prop: 'range',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
clearable: false
|
||||
}
|
||||
}
|
||||
const select = (label: string, prop: string, options: unknown[]): SearchFormItem => ({
|
||||
label,
|
||||
prop,
|
||||
type: 'select',
|
||||
options: options as SearchFormItem['options'],
|
||||
config: { clearable: true }
|
||||
})
|
||||
const input = (label: string, prop: string, placeholder = ''): SearchFormItem => ({
|
||||
label,
|
||||
prop,
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder }
|
||||
})
|
||||
|
||||
const searchItems = computed<SearchFormItem[]>(() => {
|
||||
if (isAsset.value) {
|
||||
return [
|
||||
rangeItem,
|
||||
select('写入判断', 'decision', TRAFFIC_DECISION_OPTIONS),
|
||||
select('来源', 'source', TRAFFIC_SOURCE_OPTIONS),
|
||||
input('场景', 'scene'),
|
||||
select('包含失败记录', 'include_failures', BOOLEAN_FILTER_OPTIONS)
|
||||
]
|
||||
}
|
||||
const common = [
|
||||
input('资产标识', 'identifier', 'ICCID / 虚拟号 / MSISDN / IMEI / SN'),
|
||||
rangeItem,
|
||||
carrierItem.value,
|
||||
select('来源', 'source', TRAFFIC_SOURCE_OPTIONS),
|
||||
input('场景', 'scene')
|
||||
]
|
||||
if (props.mode === 'failure') {
|
||||
return [
|
||||
...common,
|
||||
select('失败类型', 'result', TRAFFIC_FAILURE_RESULT_OPTIONS),
|
||||
select('错误类别', 'error_category', TRAFFIC_ERROR_CATEGORY_OPTIONS),
|
||||
input('上游返回码', 'provider_code')
|
||||
]
|
||||
}
|
||||
return [
|
||||
...common,
|
||||
select('写入判断', 'decision', TRAFFIC_DECISION_OPTIONS),
|
||||
select('有增量', 'has_delta', BOOLEAN_FILTER_OPTIONS),
|
||||
select('触发防旧值', 'stale_guarded', BOOLEAN_FILTER_OPTIONS)
|
||||
]
|
||||
})
|
||||
|
||||
function updateFilters(value: Record<string, unknown>) {
|
||||
Object.assign(filters, value)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
const isFailure = (row: TrafficCollectionRecord) =>
|
||||
props.mode === 'failure' || row.record_kind === 'failure'
|
||||
const successValue = (row: TrafficCollectionRecord, text: string) => (isFailure(row) ? '-' : text)
|
||||
const failureValue = (row: TrafficCollectionRecord, text: string) => (isFailure(row) ? text : '-')
|
||||
|
||||
const display = (value: unknown) =>
|
||||
value === undefined || value === null || value === '' ? '-' : String(value)
|
||||
const joinText = (...values: unknown[]) =>
|
||||
values.filter((value) => value !== undefined && value !== null && value !== '').join(' / ') ||
|
||||
'-'
|
||||
|
||||
function rawBlocks(row: TrafficCollectionRecord): RawDataBlock[] {
|
||||
const response: RawDataBlock =
|
||||
row.raw_response !== undefined && row.raw_response !== null
|
||||
? { title: '原始响应', value: row.raw_response, truncated: row.raw_truncated }
|
||||
: { title: '原始响应(文本)', value: row.raw_response_text, truncated: row.raw_truncated }
|
||||
return [
|
||||
{ title: '采集队列', value: row.queue_points },
|
||||
{ title: '原始请求', value: row.raw_request },
|
||||
response,
|
||||
{ title: '流量事件 ID', value: row.event_id }
|
||||
]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.mode, props.identifier],
|
||||
() => {
|
||||
clear()
|
||||
Object.assign(filters, emptyFilters())
|
||||
pagination.page = 1
|
||||
pagination.page_size = isAsset.value ? 50 : 20
|
||||
if (isAsset.value && !props.identifier) return
|
||||
void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ reload: load })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
91
src/composables/useRecordList.ts
Normal file
91
src/composables/useRecordList.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue'
|
||||
import { useGatewayCarriers } from '@/composables/useGateway'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { BaseResponse } from '@/types/api/common'
|
||||
import type { RecordPage } from '@/types/api/trafficRecords'
|
||||
import { errorMessage } from '@/utils/business/trafficRecords'
|
||||
|
||||
/**
|
||||
* 记录查询列表:分页、并发请求只取最后一次结果、错误就地展示。
|
||||
* request 内抛出的校验错误(如跨度超限)不发请求,直接显示在 error。
|
||||
*/
|
||||
export function useRecordList<T>(
|
||||
request: (page: { page: number; page_size: number }) => Promise<BaseResponse<RecordPage<T>>>,
|
||||
defaultPageSize = 20
|
||||
) {
|
||||
const rows = shallowRef<T[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const pagination = reactive({ page: 1, page_size: defaultPageSize })
|
||||
let sequence = 0
|
||||
|
||||
async function load() {
|
||||
const current = ++sequence
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await request({ ...pagination })
|
||||
if (current !== sequence) return
|
||||
const data = response.data
|
||||
rows.value = Array.isArray(data?.items) ? data.items : []
|
||||
total.value = Number(data?.total) || 0
|
||||
} catch (cause) {
|
||||
if (current !== sequence) return
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
error.value = errorMessage(cause)
|
||||
} finally {
|
||||
if (current === sequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
pagination.page = 1
|
||||
return load()
|
||||
}
|
||||
|
||||
function changePage(page: number) {
|
||||
pagination.page = page
|
||||
return load()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
return load()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
++sequence
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
error.value = ''
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onBeforeUnmount(clear)
|
||||
|
||||
return { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear }
|
||||
}
|
||||
|
||||
/** 运营商账户筛选:按名称远程搜索,提交的是账户 ID(复用网关页面的运营商候选)。 */
|
||||
export function useCarrierFilterItem(loadOnMount = true) {
|
||||
const carriers = useGatewayCarriers(loadOnMount)
|
||||
const item = computed<SearchFormItem>(() => ({
|
||||
label: '运营商账户',
|
||||
prop: 'carrier_id',
|
||||
type: 'select',
|
||||
options: carriers.options.value,
|
||||
onChange: ({ val }: { val: unknown }) => carriers.select(val),
|
||||
config: {
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
remote: true,
|
||||
remoteMethod: carriers.search,
|
||||
loading: carriers.loading.value,
|
||||
placeholder: '输入运营商名称搜索'
|
||||
}
|
||||
}))
|
||||
return { item, carriers }
|
||||
}
|
||||
144
src/config/constants/trafficRecords.ts
Normal file
144
src/config/constants/trafficRecords.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 流量记录查询、套餐人工冲正、退款资产后处理(后端 Iteration/8-11)
|
||||
* 后端这些接口只按账号类型校验,没有权限码;以下权限码用于前端菜单与按钮,需在 tb_permission 补数据。
|
||||
*/
|
||||
|
||||
export const TRAFFIC_RECORD_PERMISSIONS = {
|
||||
collections: { page: 'traffic_collection_record:page' },
|
||||
collectionFailures: { page: 'traffic_collection_failure:page' },
|
||||
cardDailyUsages: { page: 'card_daily_usage:page' },
|
||||
packageUsageDailyRecords: { page: 'package_usage_daily_record:page' },
|
||||
trafficAdjustments: { page: 'package_traffic_adjustment:page' },
|
||||
refundPostAttempts: { page: 'refund_post_process_attempt:page' },
|
||||
assetCollections: {
|
||||
card: 'iot_info:traffic_collections',
|
||||
device: 'device_info:traffic_collections'
|
||||
},
|
||||
packageAdjustments: 'package_usage:traffic_adjustments',
|
||||
packageAdjust: 'package_usage:traffic_adjust',
|
||||
refundAssetPostRetry: 'refund:asset_post_retry'
|
||||
} as const
|
||||
|
||||
export interface RecordOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const toOptions = (map: Record<string, string>): RecordOption[] =>
|
||||
Object.entries(map).map(([value, label]) => ({ value, label }))
|
||||
|
||||
/** 写入判断 */
|
||||
export const TRAFFIC_DECISION_LABELS: Record<string, string> = {
|
||||
written: '已写入',
|
||||
baseline: '建立基线',
|
||||
queue_not_full: '采集队列未满',
|
||||
slope_negative: '读数回落',
|
||||
out_of_order: '采集乱序',
|
||||
settled_existing: '已结算'
|
||||
}
|
||||
|
||||
/** 读数结果 / 失败类型 */
|
||||
export const TRAFFIC_RESULT_LABELS: Record<string, string> = {
|
||||
ok: '正常',
|
||||
failed: '失败',
|
||||
rate_limited: '限流',
|
||||
invalid_payload: '响应无效',
|
||||
no_reading: '无读数',
|
||||
invalid_reading: '读数无效'
|
||||
}
|
||||
|
||||
/** 采集来源 */
|
||||
export const TRAFFIC_SOURCE_LABELS: Record<string, string> = {
|
||||
polling: '轮询',
|
||||
manual_sync: '人工同步',
|
||||
business_event: '业务事件'
|
||||
}
|
||||
|
||||
/** 错误类别(采集失败) */
|
||||
export const TRAFFIC_ERROR_CATEGORY_LABELS: Record<string, string> = {
|
||||
timeout: '超时',
|
||||
network: '网络错误',
|
||||
http: 'HTTP 错误',
|
||||
invalid_response: '响应无效',
|
||||
upstream: '上游错误'
|
||||
}
|
||||
|
||||
/** 冲正方向 */
|
||||
export const ADJUSTMENT_DIRECTION_LABELS: Record<string, string> = {
|
||||
deduct: '补扣',
|
||||
refund: '退回'
|
||||
}
|
||||
|
||||
/** 退款资产后处理步骤 */
|
||||
export const REFUND_POST_STEP_LABELS: Record<string, string> = {
|
||||
load: '加载',
|
||||
invalidate: '失效套餐',
|
||||
activate: '激活',
|
||||
check_main: '检查主套餐',
|
||||
stop: '停机',
|
||||
mark: '标记完成',
|
||||
done: '完成'
|
||||
}
|
||||
|
||||
export const REFUND_POST_RESULT_LABELS: Record<string, string> = {
|
||||
success: '成功',
|
||||
failed: '失败'
|
||||
}
|
||||
|
||||
export const REFUND_POST_TRIGGER_LABELS: Record<string, string> = {
|
||||
outbox: '自动',
|
||||
manual: '人工'
|
||||
}
|
||||
|
||||
/** 退款资产后处理状态 */
|
||||
export const REFUND_ASSET_POST_STATUS = {
|
||||
notStarted: 0,
|
||||
retrying: 1,
|
||||
completed: 2,
|
||||
manualRequired: 3
|
||||
} as const
|
||||
|
||||
export const REFUND_ASSET_POST_STATUS_LABELS: Record<number, string> = {
|
||||
0: '未开始',
|
||||
1: '待重试',
|
||||
2: '已完成',
|
||||
3: '需人工处理'
|
||||
}
|
||||
|
||||
export const REFUND_ASSET_POST_STATUS_TAGS: Record<
|
||||
number,
|
||||
'info' | 'warning' | 'success' | 'danger'
|
||||
> = {
|
||||
0: 'info',
|
||||
1: 'warning',
|
||||
2: 'success',
|
||||
3: 'danger'
|
||||
}
|
||||
|
||||
/** 自动重试上限(后端固定 10 次) */
|
||||
export const REFUND_ASSET_POST_MAX_ATTEMPTS = 10
|
||||
|
||||
export const TRAFFIC_DECISION_OPTIONS = toOptions(TRAFFIC_DECISION_LABELS)
|
||||
export const TRAFFIC_SOURCE_OPTIONS = toOptions(TRAFFIC_SOURCE_LABELS)
|
||||
export const TRAFFIC_FAILURE_RESULT_OPTIONS = toOptions(TRAFFIC_RESULT_LABELS).filter(
|
||||
(option) => option.value !== 'ok'
|
||||
)
|
||||
export const TRAFFIC_ERROR_CATEGORY_OPTIONS = toOptions(TRAFFIC_ERROR_CATEGORY_LABELS)
|
||||
export const ADJUSTMENT_DIRECTION_OPTIONS = toOptions(ADJUSTMENT_DIRECTION_LABELS)
|
||||
export const REFUND_POST_STEP_OPTIONS = toOptions(REFUND_POST_STEP_LABELS)
|
||||
export const REFUND_POST_RESULT_OPTIONS = toOptions(REFUND_POST_RESULT_LABELS)
|
||||
export const REFUND_POST_TRIGGER_OPTIONS = toOptions(REFUND_POST_TRIGGER_LABELS)
|
||||
export const REFUND_ASSET_POST_STATUS_OPTIONS = Object.entries(REFUND_ASSET_POST_STATUS_LABELS).map(
|
||||
([value, label]) => ({ value: Number(value), label })
|
||||
)
|
||||
|
||||
export const BOOLEAN_FILTER_OPTIONS = [
|
||||
{ label: '是', value: 'true' },
|
||||
{ label: '否', value: 'false' }
|
||||
]
|
||||
|
||||
/** 记录查询最长跨度(天) */
|
||||
export const RECORD_MAX_SPAN_DAYS = 31
|
||||
|
||||
export const labelOf = (map: Record<string, string>, value?: string | null): string =>
|
||||
value ? map[value] || value : '-'
|
||||
@@ -5,6 +5,7 @@ import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
|
||||
import { AUDIT_PERMISSIONS } from '@/config/constants/audit'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
|
||||
import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway'
|
||||
import { TRAFFIC_RECORD_PERMISSIONS } from '@/config/constants/trafficRecords'
|
||||
import { POLLING_CONFIG_PERMISSIONS } from '@/config/constants/pollingConfig'
|
||||
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
|
||||
import { SILENCE_PAGES, PACKAGE_SILENCE_PERMISSIONS } from '@/config/constants/packageSilence'
|
||||
@@ -574,6 +575,56 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
title: 'menus.assetManagement.authorizationRecords',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'traffic-collections',
|
||||
name: 'TrafficCollectionRecordsPage',
|
||||
component: RoutesAlias.TrafficCollectionRecords,
|
||||
meta: {
|
||||
title: '流量采集成功记录',
|
||||
keepAlive: true,
|
||||
permissions: [TRAFFIC_RECORD_PERMISSIONS.collections.page]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'traffic-collection-failures',
|
||||
name: 'TrafficCollectionFailuresPage',
|
||||
component: RoutesAlias.TrafficCollectionFailures,
|
||||
meta: {
|
||||
title: '流量采集失败记录',
|
||||
keepAlive: true,
|
||||
permissions: [TRAFFIC_RECORD_PERMISSIONS.collectionFailures.page]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'card-daily-usages',
|
||||
name: 'CardDailyUsagesPage',
|
||||
component: RoutesAlias.CardDailyUsages,
|
||||
meta: {
|
||||
title: '卡日流量',
|
||||
keepAlive: true,
|
||||
permissions: [TRAFFIC_RECORD_PERMISSIONS.cardDailyUsages.page]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'package-usage-daily-records',
|
||||
name: 'PackageUsageDailyRecordsPage',
|
||||
component: RoutesAlias.PackageUsageDailyRecords,
|
||||
meta: {
|
||||
title: '套餐日流量详单',
|
||||
keepAlive: true,
|
||||
permissions: [TRAFFIC_RECORD_PERMISSIONS.packageUsageDailyRecords.page]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'package-traffic-adjustments',
|
||||
name: 'PackageTrafficAdjustmentsPage',
|
||||
component: RoutesAlias.PackageTrafficAdjustments,
|
||||
meta: {
|
||||
title: '套餐流量冲正流水',
|
||||
keepAlive: true,
|
||||
permissions: [TRAFFIC_RECORD_PERMISSIONS.trafficAdjustments.page]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1078,6 +1129,18 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
permissions: [AUGUST_PERMISSIONS.refund.page]
|
||||
}
|
||||
},
|
||||
// 退款资产后处理尝试记录
|
||||
{
|
||||
path: 'refund-post-attempts',
|
||||
name: 'RefundPostProcessAttemptsPage',
|
||||
component: RoutesAlias.RefundPostProcessAttempts,
|
||||
meta: {
|
||||
title: '退款后处理尝试记录',
|
||||
keepAlive: true,
|
||||
allowedUserTypes: [1, 2],
|
||||
permissions: [TRAFFIC_RECORD_PERMISSIONS.refundPostAttempts.page]
|
||||
}
|
||||
},
|
||||
// 退款详情
|
||||
{
|
||||
path: 'refund/detail/:id',
|
||||
|
||||
@@ -71,6 +71,11 @@ export enum RoutesAlias {
|
||||
AuthorizationRecords = '/asset-management/record-management/authorization-records', // 授权记录
|
||||
AssetAssign = '/asset-management/record-management/asset-assign', // 分配记录
|
||||
AssetAssignDetail = '/asset-management/record-management/asset-assign/detail', // 分配记录详情
|
||||
TrafficCollectionRecords = '/asset-management/record-management/traffic-collections', // 流量采集成功记录
|
||||
TrafficCollectionFailures = '/asset-management/record-management/traffic-collection-failures', // 流量采集失败记录
|
||||
CardDailyUsages = '/asset-management/record-management/card-daily-usages', // 卡日流量
|
||||
PackageUsageDailyRecords = '/asset-management/record-management/package-usage-daily-records', // 套餐日流量详单
|
||||
PackageTrafficAdjustments = '/asset-management/record-management/package-traffic-adjustments', // 套餐流量冲正流水
|
||||
|
||||
// 任务管理
|
||||
IotCardTask = '/asset-management/task-management/iot-card-task', // IoT卡任务
|
||||
@@ -119,6 +124,7 @@ export enum RoutesAlias {
|
||||
AgentRechargeDetail = '/finance/agent-recharge/detail', // 代理充值详情
|
||||
RefundManagement = '/finance/refund', // 退款管理
|
||||
RefundDetail = '/finance/refund/detail', // 退款详情
|
||||
RefundPostProcessAttempts = '/finance/refund-post-attempts', // 退款资产后处理尝试记录
|
||||
AgentFundOverview = '/finance/agent-fund-overview', // 代理商资金概况
|
||||
EmployeeCollectionBills = '/finance/employee-collection/bills', // 员工代收款账单
|
||||
EmployeeCollectionBillDetail = '/finance/employee-collection/bills/detail', // 员工代收款账单详情
|
||||
|
||||
@@ -334,7 +334,10 @@ export interface AssetRealtimeStatusResponse {
|
||||
* 资产刷新响应(结构与 realtime-status 完全相同)
|
||||
* 对应接口:POST /api/admin/assets/:asset_type/:id/refresh
|
||||
*/
|
||||
export type AssetRefreshResponse = AssetRealtimeStatusResponse
|
||||
export type AssetRefreshResponse = AssetRealtimeStatusResponse & {
|
||||
/** 本次刷新流量读数的入账结果(后端 8-11 新增,可能缺失) */
|
||||
traffic_refresh?: import('./trafficRecords').AssetTrafficRefreshResult | null
|
||||
}
|
||||
|
||||
// ========== 资产套餐查询响应 ==========
|
||||
|
||||
|
||||
@@ -122,6 +122,18 @@ export interface SilenceExtensionResponse {
|
||||
}
|
||||
|
||||
export interface SilenceEligibilityResponse {
|
||||
/** 套餐名称 */
|
||||
package_name?: string
|
||||
/** 资产标识 */
|
||||
asset_identifier?: string
|
||||
/** 套餐订购时间(购买支付成功时间) */
|
||||
purchased_at?: string
|
||||
/** 套餐生效时间(未实名激活时为空) */
|
||||
activated_at?: string
|
||||
/** 套餐到期时间(未实名激活时为空) */
|
||||
expires_at?: string
|
||||
/** 沉默期截止时间 */
|
||||
silence_deadline_at?: string
|
||||
/** 能否办理付费延期 */
|
||||
can_extend?: boolean
|
||||
/** 已累计延期天数 */
|
||||
|
||||
@@ -133,6 +133,10 @@ export interface Refund {
|
||||
reject_reason: string
|
||||
remark: string
|
||||
asset_reset: boolean
|
||||
asset_post_status?: number // 资产后处理状态 (0:未开始, 1:待重试, 2:已完成, 3:需人工处理)
|
||||
asset_post_attempts?: number // 资产后处理已尝试次数(自动重试上限 10 次)
|
||||
asset_post_last_error?: string | null // 资产后处理最后一次错误
|
||||
asset_post_next_at?: string | null // 资产后处理下次自动重试时间
|
||||
commission_deducted: boolean
|
||||
submitter_name?: string | null // 提交人名称
|
||||
submitter_id?: number | null
|
||||
@@ -170,6 +174,7 @@ export interface RefundQueryParams {
|
||||
shop_id?: number
|
||||
shop_name?: string // 店铺名称(模糊查询)
|
||||
asset_identifier?: string // 资产标识(ICCID 或 设备虚拟号,精确匹配)
|
||||
asset_post_status?: number // 资产后处理状态
|
||||
dateRange?: string[]
|
||||
}
|
||||
|
||||
|
||||
214
src/types/api/trafficRecords.ts
Normal file
214
src/types/api/trafficRecords.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 流量记录查询、人工冲正、退款资产后处理尝试记录(后端 Iteration/8-11,11-05 新增)
|
||||
*/
|
||||
|
||||
/** 记录查询分页结果:items + page + page_size + total */
|
||||
export interface RecordPage<T> {
|
||||
items: T[]
|
||||
page?: number
|
||||
page_size?: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 时间范围查询(结束时间不含,跨度不超过 31 天) */
|
||||
export interface RecordTimeQuery {
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
/** 日期范围查询(含首尾,跨度不超过 31 天) */
|
||||
export interface RecordDateQuery {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface TrafficCollectionQuery extends RecordTimeQuery {
|
||||
identifier?: string
|
||||
carrier_id?: number
|
||||
decision?: string
|
||||
source?: string
|
||||
scene?: string
|
||||
has_delta?: boolean
|
||||
stale_guarded?: boolean
|
||||
}
|
||||
|
||||
export interface TrafficCollectionFailureQuery extends RecordTimeQuery {
|
||||
identifier?: string
|
||||
carrier_id?: number
|
||||
result?: string
|
||||
error_category?: string
|
||||
provider_code?: string
|
||||
source?: string
|
||||
scene?: string
|
||||
}
|
||||
|
||||
export interface AssetTrafficCollectionQuery extends RecordTimeQuery {
|
||||
decision?: string
|
||||
source?: string
|
||||
scene?: string
|
||||
include_failures?: boolean
|
||||
}
|
||||
|
||||
/** 采集成功 / 失败记录(资产详情接口两种混排,用 record_kind 区分) */
|
||||
export interface TrafficCollectionRecord {
|
||||
id: number
|
||||
record_kind?: 'success' | 'failure'
|
||||
observation_id?: string
|
||||
observed_at?: string
|
||||
iot_card_id?: number
|
||||
iccid?: string
|
||||
carrier_id?: number
|
||||
carrier_name?: string
|
||||
source?: string
|
||||
scene?: string
|
||||
result?: string
|
||||
decision?: string
|
||||
vendor_used_mb?: number | null
|
||||
month_used_mb?: number | null
|
||||
delta_mb?: number | null
|
||||
stale_guarded?: boolean
|
||||
event_id?: string
|
||||
queue_points?: unknown
|
||||
error_category?: string
|
||||
provider_code?: string
|
||||
provider_message?: string
|
||||
http_status?: number | null
|
||||
duration_ms?: number | null
|
||||
gateway_code?: number | null
|
||||
gateway_msg?: string
|
||||
gateway_trace_id?: string
|
||||
gateway_attempts?: number | null
|
||||
raw_request?: unknown
|
||||
raw_response?: unknown
|
||||
raw_response_text?: string
|
||||
raw_truncated?: boolean
|
||||
}
|
||||
|
||||
export interface CardDailyUsageQuery extends RecordDateQuery {
|
||||
identifier?: string
|
||||
carrier_id?: number
|
||||
min_usage_mb?: number
|
||||
}
|
||||
|
||||
export interface CardDailyUsage {
|
||||
id: number
|
||||
date: string
|
||||
iot_card_id: number
|
||||
iccid?: string
|
||||
carrier_id?: number
|
||||
carrier_name?: string
|
||||
settled_mb: number
|
||||
month_used_mb: number
|
||||
vendor_used_mb: number
|
||||
obs_count: number
|
||||
stale_guarded: boolean
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface PackageUsageDailyRecordQuery extends RecordDateQuery {
|
||||
identifier?: string
|
||||
package_usage_id?: number
|
||||
package_id?: number
|
||||
carrier_id?: number
|
||||
}
|
||||
|
||||
export interface PackageUsageDailyRecord {
|
||||
id: number
|
||||
date: string
|
||||
package_usage_id: number
|
||||
package_id: number
|
||||
package_name?: string
|
||||
iot_card_id: number
|
||||
iccid?: string
|
||||
device_id: number
|
||||
device_virtual_no?: string
|
||||
device_imei?: string
|
||||
daily_usage_mb: number
|
||||
cumulative_usage_mb: number
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface PackageTrafficAdjustmentQuery extends RecordTimeQuery {
|
||||
identifier?: string
|
||||
package_usage_id?: number
|
||||
operator_id?: number
|
||||
direction?: 'deduct' | 'refund' | ''
|
||||
}
|
||||
|
||||
export interface PackageTrafficAdjustment {
|
||||
id: number
|
||||
created_at: string
|
||||
package_usage_id: number
|
||||
package_name?: string
|
||||
iot_card_id?: number
|
||||
iccid?: string
|
||||
device_id?: number
|
||||
device_virtual_no?: string
|
||||
device_imei?: string
|
||||
delta_mb: number
|
||||
before_usage_mb: number
|
||||
after_usage_mb: number
|
||||
before_status?: number
|
||||
after_status?: number
|
||||
reason: string
|
||||
operator_id?: number
|
||||
operator_name?: string
|
||||
ref_event_id?: string
|
||||
ref_observation_id?: string
|
||||
}
|
||||
|
||||
export interface AdjustPackageTrafficRequest {
|
||||
delta_mb: number
|
||||
reason: string
|
||||
ref_observation_id?: string
|
||||
ref_event_id?: string
|
||||
}
|
||||
|
||||
export interface RefundPostProcessAttemptQuery extends RecordTimeQuery {
|
||||
identifier?: string
|
||||
refund_id?: number
|
||||
refund_no?: string
|
||||
step?: string
|
||||
result?: string
|
||||
error_code?: string
|
||||
trigger?: string
|
||||
}
|
||||
|
||||
export interface RefundPostProcessAttempt {
|
||||
id: number
|
||||
created_at: string
|
||||
refund_id: number
|
||||
refund_no?: string
|
||||
asset_type?: string
|
||||
asset_id?: number
|
||||
iccid?: string
|
||||
device_virtual_no?: string
|
||||
device_imei?: string
|
||||
attempt: number
|
||||
step: string
|
||||
result: string
|
||||
error_code?: string
|
||||
error_message?: string
|
||||
trigger: string
|
||||
duration_ms?: number
|
||||
}
|
||||
|
||||
/** 资产同步接口返回的本次流量入账结果 */
|
||||
export interface AssetTrafficRefreshResult {
|
||||
result?: string
|
||||
reading_mb?: number | null
|
||||
written?: boolean
|
||||
decision?: string
|
||||
delta_mb?: number | null
|
||||
}
|
||||
|
||||
/** 原始数据展开块 */
|
||||
export interface RawDataBlock {
|
||||
title: string
|
||||
value: unknown
|
||||
truncated?: boolean
|
||||
}
|
||||
14
src/types/components.d.ts
vendored
14
src/types/components.d.ts
vendored
@@ -80,6 +80,7 @@ declare module 'vue' {
|
||||
BatchRealnamePolicyDialog: typeof import('./../components/business/BatchRealnamePolicyDialog.vue')['default']
|
||||
BoxStyleSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default']
|
||||
BusinessEntitySelect: typeof import('./../components/business/BusinessEntitySelect.vue')['default']
|
||||
CardDailyUsages: typeof import('./../components/business/traffic-records/CardDailyUsages.vue')['default']
|
||||
CardOperationDialog: typeof import('./../components/business/CardOperationDialog.vue')['default']
|
||||
CardStatusTag: typeof import('./../components/business/CardStatusTag.vue')['default']
|
||||
ChangePasswordDialog: typeof import('./../components/core/layouts/art-header-bar/ChangePasswordDialog.vue')['default']
|
||||
@@ -97,9 +98,11 @@ declare module 'vue' {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCalendar: typeof import('element-plus/es')['ElCalendar']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
@@ -127,15 +130,19 @@ declare module 'vue' {
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElResult: typeof import('element-plus/es')['ElResult']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
@@ -158,12 +165,17 @@ declare module 'vue' {
|
||||
OperatorSelect: typeof import('./../components/business/OperatorSelect.vue')['default']
|
||||
PackageSelector: typeof import('./../components/business/PackageSelector.vue')['default']
|
||||
PackageSilenceFields: typeof import('./../components/business/package-silence/PackageSilenceFields.vue')['default']
|
||||
PackageTrafficAdjustments: typeof import('./../components/business/traffic-records/PackageTrafficAdjustments.vue')['default']
|
||||
PackageUsageDailyRecords: typeof import('./../components/business/traffic-records/PackageUsageDailyRecords.vue')['default']
|
||||
PaymentVoucherDialog: typeof import('./../components/business/PaymentVoucherDialog.vue')['default']
|
||||
PollingBatchDialog: typeof import('./../components/business/polling/PollingBatchDialog.vue')['default']
|
||||
PollingImportDialog: typeof import('./../components/business/polling/PollingImportDialog.vue')['default']
|
||||
PollingStateDialog: typeof import('./../components/business/polling/PollingStateDialog.vue')['default']
|
||||
RawDataPanel: typeof import('./../components/business/traffic-records/RawDataPanel.vue')['default']
|
||||
RealnamePolicyDialog: typeof import('./../components/device/RealnamePolicyDialog.vue')['default']
|
||||
RecordTableShell: typeof import('./../components/business/traffic-records/RecordTableShell.vue')['default']
|
||||
RefundApprovalDialog: typeof import('./../components/business/RefundApprovalDialog.vue')['default']
|
||||
RefundPostProcessAttempts: typeof import('./../components/business/traffic-records/RefundPostProcessAttempts.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SchedulerDetail: typeof import('./../components/business/polling/SchedulerDetail.vue')['default']
|
||||
@@ -189,6 +201,8 @@ declare module 'vue' {
|
||||
SwitchCardDialog: typeof import('./../components/device/SwitchCardDialog.vue')['default']
|
||||
TableContextMenuHint: typeof import('./../components/core/others/TableContextMenuHint.vue')['default']
|
||||
ThemeSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ThemeSettings.vue')['default']
|
||||
TrafficAdjustDialog: typeof import('./../components/business/traffic-records/TrafficAdjustDialog.vue')['default']
|
||||
TrafficCollectionRecords: typeof import('./../components/business/traffic-records/TrafficCollectionRecords.vue')['default']
|
||||
UpdateRealnameStatusDialog: typeof import('./../components/business/UpdateRealnameStatusDialog.vue')['default']
|
||||
VoucherUpload: typeof import('./../components/business/VoucherUpload.vue')['default']
|
||||
}
|
||||
|
||||
174
src/utils/business/trafficRecords.ts
Normal file
174
src/utils/business/trafficRecords.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
RECORD_MAX_SPAN_DAYS,
|
||||
TRAFFIC_DECISION_LABELS,
|
||||
TRAFFIC_RESULT_LABELS
|
||||
} from '@/config/constants/trafficRecords'
|
||||
import type { AssetTrafficRefreshResult } from '@/types/api/trafficRecords'
|
||||
|
||||
/** 账号类型:1 超级管理员 2 平台 3 代理 4 企业 */
|
||||
export const USER_TYPE_SUPER_ADMIN = 1
|
||||
export const USER_TYPE_PLATFORM = 2
|
||||
export const USER_TYPE_ENTERPRISE = 4
|
||||
|
||||
export const isPlatformAccount = (userType?: number) =>
|
||||
userType === USER_TYPE_SUPER_ADMIN || userType === USER_TYPE_PLATFORM
|
||||
|
||||
export const isEnterpriseAccount = (userType?: number) => userType === USER_TYPE_ENTERPRISE
|
||||
|
||||
const DAY_MS = 86400000
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
|
||||
/** 上海时间展示格式,与后端接受的 "2006-01-02 15:04:05" 一致 */
|
||||
export function formatRecordDateTime(date: Date): string {
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
)
|
||||
}
|
||||
|
||||
export function formatRecordDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
/** 最近 N 天的时间范围(结束时间为当前时刻) */
|
||||
export function recentTimeRange(days = 1): [string, string] {
|
||||
const end = new Date()
|
||||
return [formatRecordDateTime(new Date(end.getTime() - days * DAY_MS)), formatRecordDateTime(end)]
|
||||
}
|
||||
|
||||
/** 最近 N 天的日期范围(含今天) */
|
||||
export function recentDateRange(days = 7): [string, string] {
|
||||
const end = new Date()
|
||||
return [formatRecordDate(new Date(end.getTime() - (days - 1) * DAY_MS)), formatRecordDate(end)]
|
||||
}
|
||||
|
||||
const parseTime = (value: string) => new Date(value.replace(' ', 'T')).getTime()
|
||||
|
||||
function assertRange(range: unknown): asserts range is [string, string] {
|
||||
if (!Array.isArray(range) || range.length !== 2 || range.some((value) => !value)) {
|
||||
throw new Error('请选择完整的时间范围')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间范围转查询参数(结束时间不含)。required=false 且未选择时返回空对象。
|
||||
* 跨度超过 31 天直接报错,不发请求。
|
||||
*/
|
||||
export function toTimeQuery(range: unknown, required = true) {
|
||||
if (!required && (!Array.isArray(range) || range.length === 0 || range.every((v) => !v))) {
|
||||
return {}
|
||||
}
|
||||
assertRange(range)
|
||||
const start = parseTime(range[0])
|
||||
const end = parseTime(range[1])
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
||||
throw new Error('时间范围无效,结束时间必须晚于开始时间')
|
||||
}
|
||||
if (end - start > RECORD_MAX_SPAN_DAYS * DAY_MS) {
|
||||
throw new Error(`时间跨度不能超过 ${RECORD_MAX_SPAN_DAYS} 天`)
|
||||
}
|
||||
return { start_time: range[0], end_time: range[1] }
|
||||
}
|
||||
|
||||
/** 日期范围转查询参数(含首尾) */
|
||||
export function toDateQuery(range: unknown) {
|
||||
assertRange(range)
|
||||
const start = parseTime(`${range[0]} 00:00:00`)
|
||||
const end = parseTime(`${range[1]} 00:00:00`)
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
|
||||
throw new Error('日期范围无效,结束日期不得早于开始日期')
|
||||
}
|
||||
// 含首尾:1 日至 31 日共 31 天
|
||||
if ((end - start) / DAY_MS + 1 > RECORD_MAX_SPAN_DAYS) {
|
||||
throw new Error(`日期跨度不能超过 ${RECORD_MAX_SPAN_DAYS} 天`)
|
||||
}
|
||||
return { start_date: range[0], end_date: range[1] }
|
||||
}
|
||||
|
||||
/** 非平台账号必须填资产标识才能查询(后端不传资产标识查全部仅平台账号可用) */
|
||||
export function requireIdentifierForNonPlatform(identifier: string | undefined, userType?: number) {
|
||||
if (!isPlatformAccount(userType) && !identifier?.trim()) {
|
||||
throw new Error('请先输入资产标识')
|
||||
}
|
||||
}
|
||||
|
||||
/** 去掉空值,避免把空字符串当筛选条件 */
|
||||
export function compactQuery<T extends object>(query: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(query).filter(
|
||||
([, value]) => value !== '' && value !== null && value !== undefined
|
||||
)
|
||||
) as Partial<T>
|
||||
}
|
||||
|
||||
/** 选填的正整数输入 */
|
||||
export function optionalPositiveInt(value: unknown, label: string): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
const number = Number(value)
|
||||
if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`${label}须为正整数`)
|
||||
return number
|
||||
}
|
||||
|
||||
/** 选填的布尔筛选(下拉值为 'true' / 'false') */
|
||||
export function optionalBoolean(value: unknown): boolean | undefined {
|
||||
if (value === 'true' || value === true) return true
|
||||
if (value === 'false' || value === false) return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 流量数值展示(MB,最多两位小数) */
|
||||
export function formatMb(value: unknown): string {
|
||||
if (value === undefined || value === null || value === '') return '-'
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return String(value)
|
||||
return `${Number.isInteger(number) ? number : number.toFixed(2)} MB`
|
||||
}
|
||||
|
||||
/** 卡 / 设备展示:优先 ICCID、设备号(IMEI),都没有时才显示内部 ID */
|
||||
export function assetDisplay(row: {
|
||||
iot_card_id?: number | null
|
||||
iccid?: string
|
||||
device_id?: number | null
|
||||
device_virtual_no?: string
|
||||
device_imei?: string
|
||||
}): string {
|
||||
if (row.iccid) return row.iccid
|
||||
if (row.device_virtual_no || row.device_imei) {
|
||||
const no = row.device_virtual_no || row.device_imei
|
||||
return row.device_imei && row.device_imei !== no
|
||||
? `${no}(IMEI ${row.device_imei})`
|
||||
: String(no)
|
||||
}
|
||||
if (row.iot_card_id) return `卡ID ${row.iot_card_id}`
|
||||
if (row.device_id) return `设备ID ${row.device_id}`
|
||||
return '-'
|
||||
}
|
||||
|
||||
/** 名称为空时退回显示 ID */
|
||||
export const nameOrId = (name?: string, id?: number | null, idLabel = 'ID'): string =>
|
||||
name || (id ? `${idLabel} ${id}` : '-')
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
const data = (error as { response?: { data?: { msg?: string; message?: string } } })?.response
|
||||
?.data
|
||||
if (data?.msg || data?.message) return (data.msg || data.message) as string
|
||||
return error instanceof Error ? error.message : '请求失败,请重试'
|
||||
}
|
||||
|
||||
/** 资产同步后的流量入账结果文案;没有 traffic_refresh 时返回空串,保持原提示 */
|
||||
export function describeTrafficRefresh(result?: AssetTrafficRefreshResult | null): string {
|
||||
if (!result) return ''
|
||||
if (result.result && result.result !== 'ok') {
|
||||
return `流量读数${TRAFFIC_RESULT_LABELS[result.result] || result.result},未写入`
|
||||
}
|
||||
const reading =
|
||||
result.reading_mb === undefined || result.reading_mb === null
|
||||
? '未返回读数'
|
||||
: `本次读数 ${formatMb(result.reading_mb)}`
|
||||
if (result.written) {
|
||||
return `${reading},已写入,增量 ${formatMb(result.delta_mb ?? 0)}`
|
||||
}
|
||||
const reason = result.decision ? TRAFFIC_DECISION_LABELS[result.decision] || result.decision : ''
|
||||
return `${reading},未写入${reason ? `:${reason}` : ''}`
|
||||
}
|
||||
@@ -41,6 +41,21 @@
|
||||
"
|
||||
>停复机记录</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-if="
|
||||
cardInfo?.asset_type === 'card' &&
|
||||
canViewTrafficCollections &&
|
||||
hasAuth(TRAFFIC_RECORD_PERMISSIONS.assetCollections.card)
|
||||
"
|
||||
type="primary"
|
||||
link
|
||||
@click="
|
||||
emit('showTrafficCollections', {
|
||||
identifier: cardInfo.iccid || cardInfo.identifier
|
||||
})
|
||||
"
|
||||
>采集记录</ElButton
|
||||
>
|
||||
<ElButton v-if="auditTarget" type="primary" link @click="openAssetAudit">
|
||||
{{ isSubjectActivityView ? '活动记录' : '审计记录' }}
|
||||
</ElButton>
|
||||
@@ -540,6 +555,16 @@
|
||||
"
|
||||
>停复机记录</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-if="
|
||||
bindingCard.iccid &&
|
||||
canViewTrafficCollections &&
|
||||
hasAuth(TRAFFIC_RECORD_PERMISSIONS.assetCollections.device)
|
||||
"
|
||||
type="primary"
|
||||
@click="emit('showTrafficCollections', { identifier: bindingCard.iccid })"
|
||||
>采集记录</ElButton
|
||||
>
|
||||
<ElButton
|
||||
v-if="bindingCard.network_status === 0 && hasAuth('iot_info:start')"
|
||||
type="success"
|
||||
@@ -752,6 +777,8 @@
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { TRAFFIC_RECORD_PERMISSIONS } from '@/config/constants/trafficRecords'
|
||||
import { isEnterpriseAccount } from '@/utils/business/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import SilenceRecordsDialog from '@/components/business/package-silence/SilenceRecordsDialog.vue'
|
||||
const firstPurchaseVisible = ref(false)
|
||||
@@ -903,6 +930,10 @@
|
||||
}
|
||||
)
|
||||
const userStore = useUserStore()
|
||||
// 资产采集记录接口企业账号无权调用
|
||||
const canViewTrafficCollections = computed(
|
||||
() => !isEnterpriseAccount(Number(userStore.info.user_type))
|
||||
)
|
||||
const isSubjectActivityView = computed(() => [3, 4].includes(Number(userStore.info.user_type)))
|
||||
const auditTarget = computed(() => {
|
||||
const userType = Number(userStore.info.user_type)
|
||||
@@ -1003,6 +1034,7 @@
|
||||
// Emits
|
||||
interface Emits {
|
||||
(e: 'showStopResumeRecords', payload: { cardId: number; iccid: string }): void
|
||||
(e: 'showTrafficCollections', payload: { identifier: string }): void
|
||||
(e: 'showPollingState', payload: { cardId: number; iccid: string }): void
|
||||
(e: 'update:pollingEnabled', value: boolean): void
|
||||
(e: 'enableCard'): void
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<SilenceSummary
|
||||
:silence="currentPackage.silence"
|
||||
:usage-id="currentPackage.package_usage_id ?? currentPackage.id"
|
||||
:package-name="currentPackage.package_name"
|
||||
@changed="emit('refresh')"
|
||||
/>
|
||||
<!-- 流量使用情况 - 横向进度条卡片 -->
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
v-if="row.silence"
|
||||
:silence="row.silence"
|
||||
:usage-id="row.package_usage_id ?? row.id"
|
||||
:package-name="row.package_name"
|
||||
@changed="emit('refresh')"
|
||||
/><span v-else>-</span></template
|
||||
></ElTableColumn
|
||||
@@ -285,7 +286,7 @@
|
||||
{{ formatDateTime(scope.row.created_at) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="240" fixed="right">
|
||||
<ElTableColumn label="操作" width="300" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="package-actions">
|
||||
<ElButton
|
||||
@@ -316,6 +317,22 @@
|
||||
>
|
||||
退款详情
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="canViewTrafficAdjustments"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleShowAdjustments(scope.row)"
|
||||
>
|
||||
冲正记录
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="canAdjustTraffic"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleShowAdjust(scope.row)"
|
||||
>
|
||||
人工冲正
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="canUpdateUsedData"
|
||||
type="primary"
|
||||
@@ -359,6 +376,28 @@
|
||||
@success="handleRefundSuccess"
|
||||
/>
|
||||
|
||||
<ElDialog
|
||||
v-model="adjustmentsVisible"
|
||||
:title="`冲正记录:${adjustTarget?.package_name || ''}`"
|
||||
width="min(1200px, 96vw)"
|
||||
destroy-on-close
|
||||
>
|
||||
<PackageTrafficAdjustments
|
||||
v-if="adjustmentsVisible && props.assetIdentifier && adjustTargetId"
|
||||
:identifier="props.assetIdentifier"
|
||||
:package-usage-id="adjustTargetId"
|
||||
/>
|
||||
</ElDialog>
|
||||
|
||||
<TrafficAdjustDialog
|
||||
v-model="adjustVisible"
|
||||
:identifier="props.assetIdentifier || ''"
|
||||
:package-usage-id="adjustTargetId"
|
||||
:package-name="adjustTarget?.package_name"
|
||||
:current-usage-mb="Number(adjustTarget?.real_used_mb || 0)"
|
||||
@success="emit('refresh')"
|
||||
/>
|
||||
|
||||
<ElDialog
|
||||
v-model="usedDataDialogVisible"
|
||||
title="修改已用量"
|
||||
@@ -471,6 +510,10 @@
|
||||
import { canEditRestrictedAsset, canAccessOrderCategory } from '@/utils/business/packageSilence'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import CreateRefundDialog from '@/components/business/CreateRefundDialog.vue'
|
||||
import PackageTrafficAdjustments from '@/components/business/traffic-records/PackageTrafficAdjustments.vue'
|
||||
import TrafficAdjustDialog from '@/components/business/traffic-records/TrafficAdjustDialog.vue'
|
||||
import { TRAFFIC_RECORD_PERMISSIONS } from '@/config/constants/trafficRecords'
|
||||
import { isEnterpriseAccount } from '@/utils/business/trafficRecords'
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const userStore = useUserStore()
|
||||
@@ -498,6 +541,35 @@
|
||||
canUseAdjustmentInCurrentEnv('package:update_expires_at')
|
||||
)
|
||||
|
||||
// 冲正记录与人工冲正:企业账号无权调用(后端按账号类型拒绝),其余按按钮权限
|
||||
const notEnterprise = computed(() => !isEnterpriseAccount(Number(userInfo.value.user_type)))
|
||||
const canViewTrafficAdjustments = computed(
|
||||
() => notEnterprise.value && hasAuth(TRAFFIC_RECORD_PERMISSIONS.packageAdjustments)
|
||||
)
|
||||
const canAdjustTraffic = computed(
|
||||
() => notEnterprise.value && hasAuth(TRAFFIC_RECORD_PERMISSIONS.packageAdjust)
|
||||
)
|
||||
const adjustmentsVisible = ref(false)
|
||||
const adjustVisible = ref(false)
|
||||
const adjustTarget = ref<AssetPackageUsageRecord | null>(null)
|
||||
const adjustTargetId = computed(() =>
|
||||
adjustTarget.value ? getPackageUsageId(adjustTarget.value) : undefined
|
||||
)
|
||||
const handleShowAdjustments = (row: AssetPackageUsageRecord) => {
|
||||
if (!canViewTrafficAdjustments.value) return
|
||||
adjustTarget.value = row
|
||||
adjustmentsVisible.value = true
|
||||
}
|
||||
const handleShowAdjust = (row: AssetPackageUsageRecord) => {
|
||||
if (!canAdjustTraffic.value) return
|
||||
if (getPackageUsageId(row) === undefined) {
|
||||
ElMessage.error('缺少套餐使用记录ID')
|
||||
return
|
||||
}
|
||||
adjustTarget.value = row
|
||||
adjustVisible.value = true
|
||||
}
|
||||
|
||||
// 退款申请对话框
|
||||
const createRefundDialogVisible = ref(false)
|
||||
const currentRefundOrderId = ref<number | undefined>(undefined)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { getCurrentScope, onScopeDispose, ref, watch } from 'vue'
|
||||
import { describeTrafficRefresh } from '@/utils/business/trafficRecords'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AssetService } from '@/api/modules'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
@@ -558,9 +559,14 @@ export function useAssetInfo() {
|
||||
if (!isCurrent() || cardInfo.value.asset_type !== assetType) return false
|
||||
try {
|
||||
// 主动同步网关的接口仅允许由右上角“同步”按钮触发,搜索和普通数据重载不能调用。
|
||||
await AssetService.refreshAsset(identifier)
|
||||
const refreshResponse = await AssetService.refreshAsset(identifier)
|
||||
if (!isCurrent()) return false
|
||||
ElMessage.success('刷新成功')
|
||||
const trafficText = describeTrafficRefresh(refreshResponse?.data?.traffic_refresh)
|
||||
if (trafficText) {
|
||||
ElMessage({ type: 'success', message: `刷新成功;${trafficText}`, duration: 6000 })
|
||||
} else {
|
||||
ElMessage.success('刷新成功')
|
||||
}
|
||||
|
||||
// 绑定/解绑和实名时间由详情接口确认,不能只刷新实时状态而保留过期绑定关系。
|
||||
return Boolean(await fetchAssetDetail(identifier, { preservePackageList: true }))
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
@show-realname-policy="showRealnamePolicyDialog"
|
||||
@show-speed-limit="speedLimitDialogVisible = true"
|
||||
@show-stop-resume-records="showStopResumeRecords"
|
||||
@show-traffic-collections="showTrafficCollections"
|
||||
@show-polling-state="showPollingState"
|
||||
@show-update-realname-status="showUpdateRealnameStatusDialog"
|
||||
@enable-binding-card="handleEnableBindingCard"
|
||||
@@ -125,6 +126,19 @@
|
||||
:permission="stopResumePermission"
|
||||
/>
|
||||
</ElDialog>
|
||||
<ElDialog
|
||||
v-model="trafficCollectionsVisible"
|
||||
:title="`采集记录:${trafficCollectionsIdentifier}`"
|
||||
width="min(1400px, 96vw)"
|
||||
destroy-on-close
|
||||
>
|
||||
<TrafficCollectionRecords
|
||||
v-if="trafficCollectionsVisible && trafficCollectionsIdentifier"
|
||||
:key="trafficCollectionsIdentifier"
|
||||
mode="asset"
|
||||
:identifier="trafficCollectionsIdentifier"
|
||||
/>
|
||||
</ElDialog>
|
||||
<SwitchCardDialog
|
||||
v-model="switchCardDialogVisible"
|
||||
:device-info="cardInfo?.virtual_no || ''"
|
||||
@@ -208,6 +222,7 @@
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { GATEWAY_PERMISSIONS } from '@/config/constants/gateway'
|
||||
import StopResumeRecords from '@/components/business/gateway/StopResumeRecords.vue'
|
||||
import TrafficCollectionRecords from '@/components/business/traffic-records/TrafficCollectionRecords.vue'
|
||||
import PollingStateDialog from '@/components/business/polling/PollingStateDialog.vue'
|
||||
import { SCHEDULER_PERMISSIONS } from '@/config/constants/pollingScheduler'
|
||||
import { canEditRestrictedAsset, canAccessOrderCategory } from '@/utils/business/packageSilence'
|
||||
@@ -344,6 +359,14 @@
|
||||
? GATEWAY_PERMISSIONS.cardHistory
|
||||
: GATEWAY_PERMISSIONS.deviceCardHistory
|
||||
)
|
||||
const trafficCollectionsVisible = ref(false)
|
||||
const trafficCollectionsIdentifier = ref('')
|
||||
function showTrafficCollections(target: { identifier: string }) {
|
||||
const identifier = target.identifier?.trim()
|
||||
if (!identifier) return
|
||||
trafficCollectionsIdentifier.value = identifier
|
||||
trafficCollectionsVisible.value = true
|
||||
}
|
||||
function showStopResumeRecords(target: { cardId: number; iccid: string }) {
|
||||
if (
|
||||
!hasAuth(stopResumePermission.value) ||
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="warning"
|
||||
:disabled="selectedCards.length === 0"
|
||||
@click="showRecallDialog"
|
||||
v-permission="'iot_card:batch_recycle'"
|
||||
>
|
||||
@@ -265,6 +264,7 @@
|
||||
@change="handleCardBatchSelectionTypeChange"
|
||||
>
|
||||
<ElRadio label="list">ICCID列表</ElRadio>
|
||||
<ElRadio label="lines">一行一个ICCID</ElRadio>
|
||||
<ElRadio label="range">号段范围</ElRadio>
|
||||
<ElRadio label="filter">筛选条件</ElRadio>
|
||||
</ElRadioGroup>
|
||||
@@ -277,6 +277,19 @@
|
||||
<div>已选择 {{ selectedCards.length }} 张卡</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="recallForm.selection_type === 'lines'"
|
||||
label="ICCID"
|
||||
prop="iccids_text"
|
||||
>
|
||||
<ElInput
|
||||
v-model="recallForm.iccids_text"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请输入ICCID,一行一个"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem
|
||||
v-if="recallForm.selection_type === CardSelectionType.RANGE"
|
||||
label="起始ICCID"
|
||||
@@ -1693,19 +1706,43 @@
|
||||
})
|
||||
|
||||
// 批量回收表单
|
||||
const recallForm = reactive<Partial<RecallStandaloneCardsRequest>>({
|
||||
const recallForm = reactive<
|
||||
Omit<Partial<RecallStandaloneCardsRequest>, 'selection_type'> & {
|
||||
selection_type: CardSelectionType | 'lines'
|
||||
iccids_text: string
|
||||
}
|
||||
>({
|
||||
selection_type: CardSelectionType.LIST,
|
||||
iccids: [],
|
||||
iccids_text: '',
|
||||
iccid_start: '',
|
||||
iccid_end: '',
|
||||
carrier_id: undefined,
|
||||
batch_no: '',
|
||||
remark: ''
|
||||
})
|
||||
const recallInputIccids = computed(() =>
|
||||
recallForm.iccids_text
|
||||
.split(/\r?\n/)
|
||||
.map((iccid) => iccid.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
// 批量回收表单验证规则
|
||||
const recallRules = reactive<FormRules>({
|
||||
selection_type: [{ required: true, message: '请选择选卡方式', trigger: 'change' }],
|
||||
iccids_text: [
|
||||
{
|
||||
validator: (_rule, _value, callback) => {
|
||||
if (recallForm.selection_type === 'lines' && recallInputIccids.value.length === 0) {
|
||||
callback(new Error('请输入ICCID,一行一个'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
iccid_start: [
|
||||
{
|
||||
required: true,
|
||||
@@ -2594,23 +2631,19 @@
|
||||
|
||||
// 显示批量回收对话框
|
||||
const showRecallDialog = () => {
|
||||
if (selectedCards.value.length === 0) {
|
||||
ElMessage.warning('请先选择要回收的卡')
|
||||
return
|
||||
}
|
||||
recallDialogVisible.value = true
|
||||
recallFormRef.value?.resetFields()
|
||||
Object.assign(recallForm, {
|
||||
selection_type: CardSelectionType.LIST,
|
||||
selection_type: selectedCards.value.length > 0 ? CardSelectionType.LIST : 'lines',
|
||||
iccids: selectedCards.value.map((card) => card.iccid),
|
||||
iccids_text: '',
|
||||
iccid_start: '',
|
||||
iccid_end: '',
|
||||
carrier_id: undefined,
|
||||
batch_no: '',
|
||||
remark: ''
|
||||
})
|
||||
if (recallFormRef.value) {
|
||||
recallFormRef.value.resetFields()
|
||||
}
|
||||
recallFormRef.value?.clearValidate()
|
||||
recallDialogVisible.value = true
|
||||
loadAllocateCarrierOptions()
|
||||
}
|
||||
|
||||
@@ -2626,6 +2659,7 @@
|
||||
if (recallFormRef.value) {
|
||||
recallFormRef.value.resetFields()
|
||||
}
|
||||
recallForm.iccids_text = ''
|
||||
}
|
||||
|
||||
// 执行批量分配
|
||||
@@ -2692,11 +2726,16 @@
|
||||
if (valid) {
|
||||
// 根据选卡方式构建请求参数
|
||||
const params: Partial<RecallStandaloneCardsRequest> = {
|
||||
selection_type: recallForm.selection_type!,
|
||||
selection_type:
|
||||
recallForm.selection_type === 'lines'
|
||||
? CardSelectionType.LIST
|
||||
: recallForm.selection_type,
|
||||
remark: recallForm.remark
|
||||
}
|
||||
|
||||
if (recallForm.selection_type === CardSelectionType.LIST) {
|
||||
if (recallForm.selection_type === 'lines') {
|
||||
params.iccids = recallInputIccids.value
|
||||
} else if (recallForm.selection_type === CardSelectionType.LIST) {
|
||||
params.iccids = selectedCards.value.map((card) => card.iccid)
|
||||
if (params.iccids.length === 0) {
|
||||
ElMessage.warning('请先选择要回收的卡')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ArtTableFullScreen><CardDailyUsages /></ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CardDailyUsages from '@/components/business/traffic-records/CardDailyUsages.vue'
|
||||
defineOptions({ name: 'CardDailyUsagesPage' })
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ArtTableFullScreen><PackageTrafficAdjustments /></ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PackageTrafficAdjustments from '@/components/business/traffic-records/PackageTrafficAdjustments.vue'
|
||||
defineOptions({ name: 'PackageTrafficAdjustmentsPage' })
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ArtTableFullScreen><PackageUsageDailyRecords /></ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PackageUsageDailyRecords from '@/components/business/traffic-records/PackageUsageDailyRecords.vue'
|
||||
defineOptions({ name: 'PackageUsageDailyRecordsPage' })
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ArtTableFullScreen><TrafficCollectionRecords mode="failure" /></ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TrafficCollectionRecords from '@/components/business/traffic-records/TrafficCollectionRecords.vue'
|
||||
defineOptions({ name: 'TrafficCollectionFailuresPage' })
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ArtTableFullScreen><TrafficCollectionRecords mode="success" /></ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TrafficCollectionRecords from '@/components/business/traffic-records/TrafficCollectionRecords.vue'
|
||||
defineOptions({ name: 'TrafficCollectionRecordsPage' })
|
||||
</script>
|
||||
8
src/views/finance/refund-post-attempts/index.vue
Normal file
8
src/views/finance/refund-post-attempts/index.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ArtTableFullScreen><RefundPostProcessAttempts /></ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RefundPostProcessAttempts from '@/components/business/traffic-records/RefundPostProcessAttempts.vue'
|
||||
defineOptions({ name: 'RefundPostProcessAttemptsPage' })
|
||||
</script>
|
||||
@@ -29,6 +29,17 @@
|
||||
>
|
||||
恢复审批
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="refund && canRetryAssetPost"
|
||||
type="warning"
|
||||
:loading="retryAssetPostLoading"
|
||||
@click="handleRetryAssetPost"
|
||||
>
|
||||
人工重试后处理
|
||||
</ElButton>
|
||||
<ElButton v-if="refund && canViewPostAttempts" @click="postAttemptsVisible = true">
|
||||
后处理尝试记录
|
||||
</ElButton>
|
||||
<template v-if="refund && canManualApproveRefund(refund)">
|
||||
<ElButton
|
||||
v-if="hasAuth('refund:approve')"
|
||||
@@ -64,6 +75,15 @@
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElDialog
|
||||
v-model="postAttemptsVisible"
|
||||
title="退款资产后处理尝试记录"
|
||||
width="min(1300px, 96vw)"
|
||||
destroy-on-close
|
||||
>
|
||||
<RefundPostProcessAttempts v-if="postAttemptsVisible && refund" :refund-id="refund.id" />
|
||||
</ElDialog>
|
||||
|
||||
<PaymentVoucherDialog :file-keys="refundVoucherFileKeys" @close="refundVoucherFileKeys = []" />
|
||||
|
||||
<!-- 退款审批操作对话框 -->
|
||||
@@ -214,6 +234,14 @@
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration'
|
||||
import {
|
||||
REFUND_ASSET_POST_MAX_ATTEMPTS,
|
||||
REFUND_ASSET_POST_STATUS,
|
||||
REFUND_ASSET_POST_STATUS_LABELS,
|
||||
REFUND_ASSET_POST_STATUS_TAGS,
|
||||
TRAFFIC_RECORD_PERMISSIONS
|
||||
} from '@/config/constants/trafficRecords'
|
||||
import RefundPostProcessAttempts from '@/components/business/traffic-records/RefundPostProcessAttempts.vue'
|
||||
|
||||
defineOptions({ name: 'RefundDetail' })
|
||||
|
||||
@@ -225,6 +253,53 @@
|
||||
|
||||
const loading = ref(false)
|
||||
const recoverApprovalLoading = ref(false)
|
||||
const retryAssetPostLoading = ref(false)
|
||||
const postAttemptsVisible = ref(false)
|
||||
const isPlatformUser = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
|
||||
const canViewPostAttempts = computed(
|
||||
() => isPlatformUser.value && hasAuth(TRAFFIC_RECORD_PERMISSIONS.refundPostAttempts.page)
|
||||
)
|
||||
// 人工重试:仅"需人工处理" + 平台账号 + 按钮权限
|
||||
const canRetryAssetPost = computed(
|
||||
() =>
|
||||
!!refund.value &&
|
||||
refund.value.asset_post_status === REFUND_ASSET_POST_STATUS.manualRequired &&
|
||||
!refund.value.asset_reset &&
|
||||
isPlatformUser.value &&
|
||||
hasAuth(TRAFFIC_RECORD_PERMISSIONS.refundAssetPostRetry)
|
||||
)
|
||||
const handleRetryAssetPost = async () => {
|
||||
const current = refund.value
|
||||
if (!current || retryAssetPostLoading.value || !canRetryAssetPost.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`重试会重新执行退款单 ${current.refund_no} 的资产后处理(可能包括停机),确认继续?`,
|
||||
'人工重试资产后处理',
|
||||
{ confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
retryAssetPostLoading.value = true
|
||||
try {
|
||||
const res = await RefundService.retryAssetPostProcessing(current.id)
|
||||
if (res.code === 0 && res.data) {
|
||||
refund.value = res.data
|
||||
ElMessage.success('资产后处理重试成功')
|
||||
return
|
||||
}
|
||||
ElMessage.error(res.msg || '资产后处理重试失败')
|
||||
await fetchRefundDetail(current.id)
|
||||
} catch (error) {
|
||||
// HTTP 错误已由请求层提示后端信息;重新加载以展示最新尝试次数与错误
|
||||
if (!(error as { response?: unknown })?.response) {
|
||||
ElMessage.error(getErrorMessage(error, '资产后处理重试失败'))
|
||||
}
|
||||
await fetchRefundDetail(current.id)
|
||||
} finally {
|
||||
retryAssetPostLoading.value = false
|
||||
}
|
||||
}
|
||||
const refund = ref<Refund | null>(null)
|
||||
const refundVoucherFileKeys = ref<string[]>([])
|
||||
const approvalDialogVisible = ref(false)
|
||||
@@ -564,6 +639,34 @@
|
||||
label: '资产重置',
|
||||
formatter: (_, data) => (data.asset_reset ? '是' : '否')
|
||||
},
|
||||
{
|
||||
label: '资产后处理状态',
|
||||
formatter: (_, data) => {
|
||||
const status = data.asset_post_status
|
||||
if (status === undefined || status === null) return '-'
|
||||
return h(
|
||||
ElTag,
|
||||
{ type: REFUND_ASSET_POST_STATUS_TAGS[status] || 'info', size: 'small' },
|
||||
() => REFUND_ASSET_POST_STATUS_LABELS[status] || String(status)
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '后处理尝试次数',
|
||||
formatter: (_, data) =>
|
||||
data.asset_post_attempts === undefined || data.asset_post_attempts === null
|
||||
? '-'
|
||||
: `${data.asset_post_attempts} / ${REFUND_ASSET_POST_MAX_ATTEMPTS}(自动重试上限)`
|
||||
},
|
||||
{
|
||||
label: '后处理最后错误',
|
||||
formatter: (_, data) => data.asset_post_last_error || '-'
|
||||
},
|
||||
{
|
||||
label: '下次自动重试',
|
||||
formatter: (_, data) =>
|
||||
data.asset_post_next_at ? formatDateTime(data.asset_post_next_at) : '-'
|
||||
},
|
||||
{
|
||||
label: '佣金扣除',
|
||||
formatter: (_, data) => (data.commission_deducted ? '是' : '否')
|
||||
|
||||
@@ -196,6 +196,13 @@
|
||||
import { RefundService, ShopService, OrderService } from '@/api/modules'
|
||||
import { ElMessage, ElMessageBox, ElTag, ElButton } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import {
|
||||
REFUND_ASSET_POST_STATUS,
|
||||
REFUND_ASSET_POST_STATUS_LABELS,
|
||||
REFUND_ASSET_POST_STATUS_OPTIONS,
|
||||
REFUND_ASSET_POST_STATUS_TAGS,
|
||||
TRAFFIC_RECORD_PERMISSIONS
|
||||
} from '@/config/constants/trafficRecords'
|
||||
import {
|
||||
RefundStatus,
|
||||
RefundMethod,
|
||||
@@ -280,7 +287,8 @@
|
||||
status: undefined,
|
||||
order_id: undefined,
|
||||
shop_id: undefined,
|
||||
asset_identifier: ''
|
||||
asset_identifier: '',
|
||||
asset_post_status: undefined
|
||||
}
|
||||
|
||||
// 搜索表单
|
||||
@@ -349,6 +357,16 @@
|
||||
config: {
|
||||
clearable: true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '资产后处理',
|
||||
prop: 'asset_post_status',
|
||||
type: 'select',
|
||||
placeholder: '请选择资产后处理状态',
|
||||
options: REFUND_ASSET_POST_STATUS_OPTIONS,
|
||||
config: {
|
||||
clearable: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -372,6 +390,7 @@
|
||||
{ label: '退款方式', prop: 'method' },
|
||||
{ label: '冻结实收金额', prop: 'frozen_actual_received_amount' },
|
||||
{ label: '渠道退款状态', prop: 'channel_refund_status' },
|
||||
{ label: '资产后处理', prop: 'asset_post_status' },
|
||||
{ label: '渠道退款流水号', prop: 'channel_refund_no' },
|
||||
{ label: '渠道退款金额', prop: 'channel_refund_amount' },
|
||||
{ label: '失败分类', prop: 'failure_reason' },
|
||||
@@ -558,6 +577,20 @@
|
||||
width: 130,
|
||||
formatter: (row: Refund) => row.channel_refund_status_name || '-'
|
||||
},
|
||||
{
|
||||
prop: 'asset_post_status',
|
||||
label: '资产后处理',
|
||||
width: 120,
|
||||
formatter: (row: Refund) => {
|
||||
const status = row.asset_post_status
|
||||
if (status === undefined || status === null) return '-'
|
||||
return h(
|
||||
ElTag,
|
||||
{ type: REFUND_ASSET_POST_STATUS_TAGS[status] || 'info', size: 'small' },
|
||||
() => REFUND_ASSET_POST_STATUS_LABELS[status] || String(status)
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'channel_refund_no',
|
||||
label: '渠道退款流水号',
|
||||
@@ -796,7 +829,11 @@
|
||||
status: searchForm.status,
|
||||
order_id: searchForm.order_id,
|
||||
shop_id: searchForm.shop_id,
|
||||
asset_identifier: assetIdentifier || undefined
|
||||
asset_identifier: assetIdentifier || undefined,
|
||||
asset_post_status:
|
||||
searchForm.asset_post_status === undefined || searchForm.asset_post_status === null
|
||||
? undefined
|
||||
: searchForm.asset_post_status
|
||||
}
|
||||
const res = await RefundService.getRefunds(params)
|
||||
if (res.code === 0) {
|
||||
@@ -1052,6 +1089,44 @@
|
||||
})
|
||||
}
|
||||
|
||||
// 退款资产后处理人工重试:仅"需人工处理" + 平台账号 + 按钮权限
|
||||
const retryAssetPostLoading = ref(false)
|
||||
const canRetryAssetPost = (row: Refund) =>
|
||||
row.asset_post_status === REFUND_ASSET_POST_STATUS.manualRequired &&
|
||||
!row.asset_reset &&
|
||||
[1, 2].includes(userStore.getUserInfo.user_type || 0) &&
|
||||
hasAuth(TRAFFIC_RECORD_PERMISSIONS.refundAssetPostRetry)
|
||||
|
||||
const handleRetryAssetPost = async (row: Refund) => {
|
||||
if (retryAssetPostLoading.value || !canRetryAssetPost(row)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`重试会重新执行退款单 ${row.refund_no} 的资产后处理(可能包括停机),确认继续?`,
|
||||
'人工重试资产后处理',
|
||||
{ confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
retryAssetPostLoading.value = true
|
||||
try {
|
||||
const res = await RefundService.retryAssetPostProcessing(row.id)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('资产后处理重试成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '资产后处理重试失败')
|
||||
}
|
||||
} catch (error) {
|
||||
// HTTP 错误已由请求层提示后端信息(如"资产后处理仍未完成:…")
|
||||
if (!(error as { response?: unknown })?.response) {
|
||||
ElMessage.error(getErrorMessage(error, '资产后处理重试失败'))
|
||||
}
|
||||
} finally {
|
||||
retryAssetPostLoading.value = false
|
||||
await getTableData()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取操作按钮
|
||||
const getActions = (row: Refund) => {
|
||||
const actions: any[] = []
|
||||
@@ -1149,6 +1224,14 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (canRetryAssetPost(row)) {
|
||||
actions.push({
|
||||
label: '人工重试',
|
||||
handler: () => handleRetryAssetPost(row),
|
||||
type: 'warning'
|
||||
})
|
||||
}
|
||||
|
||||
if (canResubmit(row) && hasAuth('refund:resubmit')) {
|
||||
actions.push({
|
||||
label: '重新申请',
|
||||
|
||||
@@ -171,7 +171,8 @@
|
||||
<ElDatePicker
|
||||
v-model="form.time_period_started_at"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
placeholder="请选择时间起点"
|
||||
style="width: 100%"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user