From 984db9889c53a9c30a572a9efce5f0c524e1f3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Sat, 7 Nov 2026 13:19:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B5=81=E9=87=8F=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E3=80=81=E5=A5=97=E9=A4=90=E4=BA=BA=E5=B7=A5?= =?UTF-8?q?=E5=86=B2=E6=AD=A3=E4=B8=8E=E9=80=80=E6=AC=BE=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E5=90=8E=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增采集成功/失败记录、卡日流量、套餐日流量详单、冲正流水、退款后处理尝试记录 6 个查询页 - 资产详情新增采集记录、冲正记录与人工冲正,同步后展示本次流量入账结果 - 退款列表与详情展示资产后处理状态,支持按状态筛选、人工重试与查看尝试记录 - 新增前端权限码常量(需在 tb_permission 补数据) --- .../tasks.md | 48 +-- scripts/tests/assetBindingCardRender.test.mjs | 9 + scripts/tests/assetInfoState.test.mjs | 3 +- scripts/tests/trafficRecords.test.mjs | 112 ++++++ src/api/modules/refund.ts | 9 + src/api/modules/trafficRecords.ts | 100 +++++ .../traffic-records/CardDailyUsages.vue | 140 +++++++ .../PackageTrafficAdjustments.vue | 233 +++++++++++ .../PackageUsageDailyRecords.vue | 139 +++++++ .../business/traffic-records/RawDataPanel.vue | 78 ++++ .../traffic-records/RecordTableShell.vue | 65 ++++ .../RefundPostProcessAttempts.vue | 197 ++++++++++ .../traffic-records/TrafficAdjustDialog.vue | 192 +++++++++ .../TrafficCollectionRecords.vue | 367 ++++++++++++++++++ src/composables/useRecordList.ts | 68 ++++ src/config/constants/trafficRecords.ts | 144 +++++++ src/router/routes/asyncRoutes.ts | 63 +++ src/router/routesAlias.ts | 6 + src/types/api/asset.ts | 5 +- src/types/api/refund.ts | 5 + src/types/api/trafficRecords.ts | 201 ++++++++++ src/types/components.d.ts | 14 + src/utils/business/trafficRecords.ts | 150 +++++++ .../components/BasicInfoCard.vue | 32 ++ .../components/PackageListCard.vue | 73 +++- .../composables/useAssetInfo.ts | 10 +- .../asset-information/index.vue | 23 ++ .../card-daily-usages/index.vue | 8 + .../package-traffic-adjustments/index.vue | 8 + .../package-usage-daily-records/index.vue | 8 + .../traffic-collection-failures/index.vue | 8 + .../traffic-collections/index.vue | 8 + .../finance/refund-post-attempts/index.vue | 8 + src/views/finance/refund/detail.vue | 103 +++++ src/views/finance/refund/index.vue | 87 ++++- 35 files changed, 2693 insertions(+), 31 deletions(-) create mode 100644 scripts/tests/trafficRecords.test.mjs create mode 100644 src/api/modules/trafficRecords.ts create mode 100644 src/components/business/traffic-records/CardDailyUsages.vue create mode 100644 src/components/business/traffic-records/PackageTrafficAdjustments.vue create mode 100644 src/components/business/traffic-records/PackageUsageDailyRecords.vue create mode 100644 src/components/business/traffic-records/RawDataPanel.vue create mode 100644 src/components/business/traffic-records/RecordTableShell.vue create mode 100644 src/components/business/traffic-records/RefundPostProcessAttempts.vue create mode 100644 src/components/business/traffic-records/TrafficAdjustDialog.vue create mode 100644 src/components/business/traffic-records/TrafficCollectionRecords.vue create mode 100644 src/composables/useRecordList.ts create mode 100644 src/config/constants/trafficRecords.ts create mode 100644 src/types/api/trafficRecords.ts create mode 100644 src/utils/business/trafficRecords.ts create mode 100644 src/views/asset-management/record-management/card-daily-usages/index.vue create mode 100644 src/views/asset-management/record-management/package-traffic-adjustments/index.vue create mode 100644 src/views/asset-management/record-management/package-usage-daily-records/index.vue create mode 100644 src/views/asset-management/record-management/traffic-collection-failures/index.vue create mode 100644 src/views/asset-management/record-management/traffic-collections/index.vue create mode 100644 src/views/finance/refund-post-attempts/index.vue diff --git a/openspec/changes/add-traffic-records-and-refund-post-processing/tasks.md b/openspec/changes/add-traffic-records-and-refund-post-processing/tasks.md index cba24b5..a734c8c 100644 --- a/openspec/changes/add-traffic-records-and-refund-post-processing/tasks.md +++ b/openspec/changes/add-traffic-records-and-refund-post-processing/tasks.md @@ -1,44 +1,44 @@ ## 1. 类型、接口与常量 -- [ ] 1.1 新增记录查询类型(采集成功/失败记录、卡日流量、套餐日流量详单、冲正流水、后处理尝试记录、`traffic_refresh`) -- [ ] 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` -- [ ] 1.3 新增 `api/modules/trafficRecords.ts`(6 个全局查询接口),在 `api/modules/index.ts` 导出 -- [ ] 1.4 `api/modules/asset.ts` 增加资产采集记录、套餐冲正查询与提交;`AssetRefreshResponse` 增加 `traffic_refresh` -- [ ] 1.5 `api/modules/refund.ts` 增加人工重试接口 -- [ ] 1.6 新增 `config/constants/trafficRecords.ts`:权限码(design D3)、枚举中文名(D5)、31 天跨度校验工具 +- [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. 可复用记录组件 -- [ ] 2.1 原始数据展开组件:格式化 JSON、截断标注、复制 -- [ ] 2.2 `TrafficCollectionRecords.vue`(成功 / 失败两种模式,支持固定 identifier、include_failures) -- [ ] 2.3 `CardDailyUsages.vue` -- [ ] 2.4 `PackageUsageDailyRecords.vue` -- [ ] 2.5 `PackageTrafficAdjustments.vue`(支持固定 identifier + package_usage_id) -- [ ] 2.6 `RefundPostProcessAttempts.vue`(支持固定 refund_id,时间范围必填) -- [ ] 2.7 组件统一处理:默认时间范围、31 天校验、非平台账号必须填资产标识、分页 +- [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. 页面与路由 -- [ ] 3.1 新增 5 个记录查询页(资产管理 → 记录管理)与退款后处理尝试记录页(财务管理),注册 `routesAlias.ts` 与 `asyncRoutes.ts`,配置 `meta.permissions` -- [ ] 3.2 菜单多语言文案(`locales`) +- [x] 3.1 新增 5 个记录查询页(资产管理 → 记录管理)与退款后处理尝试记录页(财务管理),注册 `routesAlias.ts` 与 `asyncRoutes.ts`,配置 `meta.permissions` +- [x] 3.2 菜单多语言文案(`locales`)(新菜单沿用停复机记录等页面的写法,标题直接用中文,未加 locales 键) ## 4. 资产详情 -- [ ] 4.1 `BasicInfoCard` 为卡与设备绑定卡增加"采集记录"按钮(权限 + 非企业账号),弹窗嵌入 `TrafficCollectionRecords` -- [ ] 4.2 `PackageListCard` 每笔套餐增加"冲正记录",弹窗嵌入 `PackageTrafficAdjustments` -- [ ] 4.3 新增人工冲正弹窗:方向 + 正整数 MB + 原因必填 + 选填关联 ID;二次确认展示预计结果;退回不得小于 0;提交防重复;成功后刷新套餐列表 -- [ ] 4.4 (可选)同步成功后展示 `traffic_refresh` 入账结果 +- [x] 4.1 `BasicInfoCard` 为卡与设备绑定卡增加"采集记录"按钮(权限 + 非企业账号),弹窗嵌入 `TrafficCollectionRecords` +- [x] 4.2 `PackageListCard` 每笔套餐增加"冲正记录",弹窗嵌入 `PackageTrafficAdjustments` +- [x] 4.3 新增人工冲正弹窗:方向 + 正整数 MB + 原因必填 + 选填关联 ID;二次确认展示预计结果;退回不得小于 0;提交防重复;成功后刷新套餐列表 +- [x] 4.4 (可选)同步成功后展示 `traffic_refresh` 入账结果 ## 5. 退款 -- [ ] 5.1 退款列表增加后处理状态列与筛选项 -- [ ] 5.2 退款详情增加后处理状态、尝试次数(/10)、最后错误、下次重试时间 -- [ ] 5.3 列表操作列与详情增加"人工重试"(需人工处理 + 平台账号 + 权限),二次确认、防重复,成功替换数据、失败显示错误并重新加载 -- [ ] 5.4 退款详情增加"后处理尝试记录"入口,按退款单 ID 打开尝试记录 +- [x] 5.1 退款列表增加后处理状态列与筛选项 +- [x] 5.2 退款详情增加后处理状态、尝试次数(/10)、最后错误、下次重试时间 +- [x] 5.3 列表操作列与详情增加"人工重试"(需人工处理 + 平台账号 + 权限),二次确认、防重复,成功替换数据、失败显示错误并重新加载 +- [x] 5.4 退款详情增加"后处理尝试记录"入口,按退款单 ID 打开尝试记录 ## 6. 验证与发布 -- [ ] 6.1 `vue-tsc --noEmit` 与 `build:development` 通过 +- [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 普通运营账号验证菜单与按钮权限;代理账号验证必须填资产标识、看不到人工重试 diff --git a/scripts/tests/assetBindingCardRender.test.mjs b/scripts/tests/assetBindingCardRender.test.mjs index 20cecc2..d9dd053 100644 --- a/scripts/tests/assetBindingCardRender.test.mjs +++ b/scripts/tests/assetBindingCardRender.test.mjs @@ -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 diff --git a/scripts/tests/assetInfoState.test.mjs b/scripts/tests/assetInfoState.test.mjs index 90f9ddc..2e5f664 100644 --- a/scripts/tests/assetInfoState.test.mjs +++ b/scripts/tests/assetInfoState.test.mjs @@ -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)( diff --git a/scripts/tests/trafficRecords.test.mjs b/scripts/tests/trafficRecords.test.mjs new file mode 100644 index 0000000..c91b516 --- /dev/null +++ b/scripts/tests/trafficRecords.test.mjs @@ -0,0 +1,112 @@ +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, ''), '-') +}) diff --git a/src/api/modules/refund.ts b/src/api/modules/refund.ts index d037ef3..5a14388 100644 --- a/src/api/modules/refund.ts +++ b/src/api/modules/refund.ts @@ -94,4 +94,13 @@ export class RefundService extends BaseService { static recoverApproval(id: number): Promise> { return this.post>(`/api/admin/refunds/${id}/recover-approval`) } + + /** + * 人工重试退款资产后处理(仅平台账号;仅"需人工处理"的退款单) + * 成功返回最新退款详情;仍失败时后端返回错误并保留"需人工处理" + * @param id 退款申请ID + */ + static retryAssetPostProcessing(id: number): Promise> { + return this.post>(`/api/admin/refunds/${id}/asset-post-processing/retry`) + } } diff --git a/src/api/modules/trafficRecords.ts b/src/api/modules/trafficRecords.ts new file mode 100644 index 0000000..4ab3eb1 --- /dev/null +++ b/src/api/modules/trafficRecords.ts @@ -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 = Promise>> + +const assetPath = (identifier: string) => `/api/admin/assets/${encodeURIComponent(identifier)}` + +export class TrafficRecordsService extends BaseService { + /** 运营商流量采集成功记录 */ + static getCollections(params: TrafficCollectionQuery): Page { + return this.get('/api/admin/traffic-collections', params) + } + + /** 运营商流量采集失败记录 */ + static getCollectionFailures( + params: TrafficCollectionFailureQuery + ): Page { + return this.get('/api/admin/traffic-collection-failures', params) + } + + /** 卡日流量记录(卡日快照) */ + static getCardDailyUsages(params: CardDailyUsageQuery): Page { + return this.get('/api/admin/card-daily-usages', params) + } + + /** 套餐日流量详单 */ + static getPackageUsageDailyRecords( + params: PackageUsageDailyRecordQuery + ): Page { + return this.get('/api/admin/package-usage-daily-records', params) + } + + /** 套餐流量冲正流水 */ + static getTrafficAdjustments( + params: PackageTrafficAdjustmentQuery + ): Page { + return this.get('/api/admin/package-traffic-adjustments', params) + } + + /** 退款资产后处理尝试记录(仅平台账号,时间范围必填) */ + static getRefundPostProcessAttempts( + params: RefundPostProcessAttemptQuery + ): Page { + return this.get('/api/admin/refund-post-process-attempts', params) + } + + /** 资产详情:卡流量采集记录 */ + static getAssetCollections( + identifier: string, + params: AssetTrafficCollectionQuery + ): Page { + return this.get(`${assetPath(identifier)}/traffic-collections`, params) + } + + /** 资产详情:某笔套餐的冲正流水 */ + static getAssetPackageAdjustments( + identifier: string, + packageUsageId: number, + params: RecordTimeQuery + ): Page { + return this.get( + `${assetPath(identifier)}/packages/${packageUsageId}/traffic-adjustments`, + params + ) + } + + /** 资产详情:人工冲正套餐流量(正数补扣、负数退回) */ + static adjustAssetPackageTraffic( + identifier: string, + packageUsageId: number, + data: AdjustPackageTrafficRequest + ): Promise> { + return this.post( + `${assetPath(identifier)}/packages/${packageUsageId}/traffic-adjustments`, + data + ) + } +} diff --git a/src/components/business/traffic-records/CardDailyUsages.vue b/src/components/business/traffic-records/CardDailyUsages.vue new file mode 100644 index 0000000..3511e27 --- /dev/null +++ b/src/components/business/traffic-records/CardDailyUsages.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/src/components/business/traffic-records/PackageTrafficAdjustments.vue b/src/components/business/traffic-records/PackageTrafficAdjustments.vue new file mode 100644 index 0000000..3a57e0a --- /dev/null +++ b/src/components/business/traffic-records/PackageTrafficAdjustments.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/src/components/business/traffic-records/PackageUsageDailyRecords.vue b/src/components/business/traffic-records/PackageUsageDailyRecords.vue new file mode 100644 index 0000000..9dbc3ab --- /dev/null +++ b/src/components/business/traffic-records/PackageUsageDailyRecords.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/src/components/business/traffic-records/RawDataPanel.vue b/src/components/business/traffic-records/RawDataPanel.vue new file mode 100644 index 0000000..eea911b --- /dev/null +++ b/src/components/business/traffic-records/RawDataPanel.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/components/business/traffic-records/RecordTableShell.vue b/src/components/business/traffic-records/RecordTableShell.vue new file mode 100644 index 0000000..0e0becd --- /dev/null +++ b/src/components/business/traffic-records/RecordTableShell.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/components/business/traffic-records/RefundPostProcessAttempts.vue b/src/components/business/traffic-records/RefundPostProcessAttempts.vue new file mode 100644 index 0000000..5865e80 --- /dev/null +++ b/src/components/business/traffic-records/RefundPostProcessAttempts.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/src/components/business/traffic-records/TrafficAdjustDialog.vue b/src/components/business/traffic-records/TrafficAdjustDialog.vue new file mode 100644 index 0000000..c8a0483 --- /dev/null +++ b/src/components/business/traffic-records/TrafficAdjustDialog.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/src/components/business/traffic-records/TrafficCollectionRecords.vue b/src/components/business/traffic-records/TrafficCollectionRecords.vue new file mode 100644 index 0000000..348db31 --- /dev/null +++ b/src/components/business/traffic-records/TrafficCollectionRecords.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/src/composables/useRecordList.ts b/src/composables/useRecordList.ts new file mode 100644 index 0000000..7cc2943 --- /dev/null +++ b/src/composables/useRecordList.ts @@ -0,0 +1,68 @@ +import { onBeforeUnmount, reactive, ref, shallowRef } from 'vue' +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( + request: (page: { page: number; page_size: number }) => Promise>>, + defaultPageSize = 20 +) { + const rows = shallowRef([]) + 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 } +} diff --git a/src/config/constants/trafficRecords.ts b/src/config/constants/trafficRecords.ts new file mode 100644 index 0000000..425e3ae --- /dev/null +++ b/src/config/constants/trafficRecords.ts @@ -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): RecordOption[] => + Object.entries(map).map(([value, label]) => ({ value, label })) + +/** 写入判断 */ +export const TRAFFIC_DECISION_LABELS: Record = { + written: '已写入', + baseline: '建立基线', + queue_not_full: '采集队列未满', + slope_negative: '读数回落', + out_of_order: '采集乱序', + settled_existing: '已结算' +} + +/** 读数结果 / 失败类型 */ +export const TRAFFIC_RESULT_LABELS: Record = { + ok: '正常', + failed: '失败', + rate_limited: '限流', + invalid_payload: '响应无效', + no_reading: '无读数', + invalid_reading: '读数无效' +} + +/** 采集来源 */ +export const TRAFFIC_SOURCE_LABELS: Record = { + polling: '轮询', + manual_sync: '人工同步', + business_event: '业务事件' +} + +/** 错误类别(采集失败) */ +export const TRAFFIC_ERROR_CATEGORY_LABELS: Record = { + timeout: '超时', + network: '网络错误', + http: 'HTTP 错误', + invalid_response: '响应无效', + upstream: '上游错误' +} + +/** 冲正方向 */ +export const ADJUSTMENT_DIRECTION_LABELS: Record = { + deduct: '补扣', + refund: '退回' +} + +/** 退款资产后处理步骤 */ +export const REFUND_POST_STEP_LABELS: Record = { + load: '加载', + invalidate: '失效套餐', + activate: '激活', + check_main: '检查主套餐', + stop: '停机', + mark: '标记完成', + done: '完成' +} + +export const REFUND_POST_RESULT_LABELS: Record = { + success: '成功', + failed: '失败' +} + +export const REFUND_POST_TRIGGER_LABELS: Record = { + 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 = { + 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, value?: string | null): string => + value ? map[value] || value : '-' diff --git a/src/router/routes/asyncRoutes.ts b/src/router/routes/asyncRoutes.ts index 70cca53..a85fec7 100644 --- a/src/router/routes/asyncRoutes.ts +++ b/src/router/routes/asyncRoutes.ts @@ -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', diff --git a/src/router/routesAlias.ts b/src/router/routesAlias.ts index 891b686..d5f83a9 100644 --- a/src/router/routesAlias.ts +++ b/src/router/routesAlias.ts @@ -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', // 员工代收款账单详情 diff --git a/src/types/api/asset.ts b/src/types/api/asset.ts index 819dd31..cdcae4c 100644 --- a/src/types/api/asset.ts +++ b/src/types/api/asset.ts @@ -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 +} // ========== 资产套餐查询响应 ========== diff --git a/src/types/api/refund.ts b/src/types/api/refund.ts index d8d0fee..71c103a 100644 --- a/src/types/api/refund.ts +++ b/src/types/api/refund.ts @@ -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[] } diff --git a/src/types/api/trafficRecords.ts b/src/types/api/trafficRecords.ts new file mode 100644 index 0000000..e358db2 --- /dev/null +++ b/src/types/api/trafficRecords.ts @@ -0,0 +1,201 @@ +/** + * 流量记录查询、人工冲正、退款资产后处理尝试记录(后端 Iteration/8-11,11-05 新增) + */ + +/** 记录查询分页结果:items + page + page_size + total */ +export interface RecordPage { + 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 + 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 + 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 + iot_card_id: number + device_id: number + 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 + iot_card_id?: number + device_id?: number + delta_mb: number + before_usage_mb: number + after_usage_mb: number + before_status?: number + after_status?: number + reason: string + operator_id?: number + 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 + 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 +} diff --git a/src/types/components.d.ts b/src/types/components.d.ts index ee96384..f5d1bb8 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -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'] } diff --git a/src/utils/business/trafficRecords.ts b/src/utils/business/trafficRecords.ts new file mode 100644 index 0000000..89f67b4 --- /dev/null +++ b/src/utils/business/trafficRecords.ts @@ -0,0 +1,150 @@ +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(query: T): Partial { + return Object.fromEntries( + Object.entries(query).filter( + ([, value]) => value !== '' && value !== null && value !== undefined + ) + ) as Partial +} + +/** 选填的正整数输入 */ +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` +} + +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}` : ''}` +} diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index f96ae8f..b887cf1 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -41,6 +41,21 @@ " >停复机记录 + 采集记录 {{ isSubjectActivityView ? '活动记录' : '审计记录' }} @@ -540,6 +555,16 @@ " >停复机记录 + 采集记录 !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 diff --git a/src/views/asset-management/asset-information/components/PackageListCard.vue b/src/views/asset-management/asset-information/components/PackageListCard.vue index 764431a..4777c5d 100644 --- a/src/views/asset-management/asset-information/components/PackageListCard.vue +++ b/src/views/asset-management/asset-information/components/PackageListCard.vue @@ -285,7 +285,7 @@ {{ formatDateTime(scope.row.created_at) }} - + + + diff --git a/src/views/asset-management/record-management/package-traffic-adjustments/index.vue b/src/views/asset-management/record-management/package-traffic-adjustments/index.vue new file mode 100644 index 0000000..c75632a --- /dev/null +++ b/src/views/asset-management/record-management/package-traffic-adjustments/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/src/views/asset-management/record-management/package-usage-daily-records/index.vue b/src/views/asset-management/record-management/package-usage-daily-records/index.vue new file mode 100644 index 0000000..f329e8d --- /dev/null +++ b/src/views/asset-management/record-management/package-usage-daily-records/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/src/views/asset-management/record-management/traffic-collection-failures/index.vue b/src/views/asset-management/record-management/traffic-collection-failures/index.vue new file mode 100644 index 0000000..2505230 --- /dev/null +++ b/src/views/asset-management/record-management/traffic-collection-failures/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/src/views/asset-management/record-management/traffic-collections/index.vue b/src/views/asset-management/record-management/traffic-collections/index.vue new file mode 100644 index 0000000..3551b56 --- /dev/null +++ b/src/views/asset-management/record-management/traffic-collections/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/src/views/finance/refund-post-attempts/index.vue b/src/views/finance/refund-post-attempts/index.vue new file mode 100644 index 0000000..c0ffe9c --- /dev/null +++ b/src/views/finance/refund-post-attempts/index.vue @@ -0,0 +1,8 @@ + + + diff --git a/src/views/finance/refund/detail.vue b/src/views/finance/refund/detail.vue index 5a16ba8..44d62db 100644 --- a/src/views/finance/refund/detail.vue +++ b/src/views/finance/refund/detail.vue @@ -29,6 +29,17 @@ > 恢复审批 + + 人工重试后处理 + + + 后处理尝试记录 +