- 新增采集成功/失败记录、卡日流量、套餐日流量详单、冲正流水、退款后处理尝试记录 6 个查询页 - 资产详情新增采集记录、冲正记录与人工冲正,同步后展示本次流量入账结果 - 退款列表与详情展示资产后处理状态,支持按状态筛选、人工重试与查看尝试记录 - 新增前端权限码常量(需在 tb_permission 补数据)
This commit is contained in:
@@ -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 普通运营账号验证菜单与按钮权限;代理账号验证必须填资产标识、看不到人工重试
|
||||
|
||||
@@ -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)(
|
||||
|
||||
112
scripts/tests/trafficRecords.test.mjs
Normal file
112
scripts/tests/trafficRecords.test.mjs
Normal file
@@ -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, ''), '-')
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
140
src/components/business/traffic-records/CardDailyUsages.vue
Normal file
140
src/components/business/traffic-records/CardDailyUsages.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<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 / 卡 ID" min-width="190">
|
||||
<template #default="{ row }">{{ row.iccid || row.iot_card_id }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="运营商账户" prop="carrier_id" width="100" />
|
||||
<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 { reactive } 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 { CardDailyUsage } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
compactQuery,
|
||||
formatMb,
|
||||
optionalPositiveInt,
|
||||
recentDateRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toDateQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
carrier_id: '',
|
||||
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: optionalPositiveInt(filters.carrier_id, '运营商账户 ID'),
|
||||
min_usage_mb: minUsage
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const searchItems: 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: '运营商账户ID', prop: 'carrier_id', type: 'input', config: { clearable: true } },
|
||||
{
|
||||
label: '当日用量≥(MB)',
|
||||
prop: 'min_usage_mb',
|
||||
type: 'input',
|
||||
config: { clearable: true, placeholder: '排查异常大流量' }
|
||||
}
|
||||
]
|
||||
|
||||
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,233 @@
|
||||
<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="套餐使用记录ID"
|
||||
prop="package_usage_id"
|
||||
min-width="130"
|
||||
/>
|
||||
<ElTableColumn v-if="!isFixed" label="卡ID / 设备ID" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{
|
||||
row.iot_card_id
|
||||
? `卡 ${row.iot_card_id}`
|
||||
: row.device_id
|
||||
? `设备 ${row.device_id}`
|
||||
: '-'
|
||||
}}
|
||||
</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="操作人ID" prop="operator_id" width="100" />
|
||||
<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,
|
||||
formatMb,
|
||||
optionalPositiveInt,
|
||||
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: '',
|
||||
package_usage_id: '',
|
||||
operator_id: '',
|
||||
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()
|
||||
const packageUsageId = optionalPositiveInt(filters.package_usage_id, '套餐使用记录 ID')
|
||||
if (packageUsageId === undefined) {
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
}
|
||||
return TrafficRecordsService.getTrafficAdjustments(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toTimeQuery(filters.range),
|
||||
identifier,
|
||||
package_usage_id: packageUsageId,
|
||||
operator_id: optionalPositiveInt(filters.operator_id, '操作人账号 ID'),
|
||||
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: '套餐使用记录ID',
|
||||
prop: 'package_usage_id',
|
||||
type: 'input',
|
||||
config: { clearable: true }
|
||||
},
|
||||
{ label: '操作人账号ID', prop: 'operator_id', type: 'input', config: { clearable: true } },
|
||||
{
|
||||
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,139 @@
|
||||
<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="套餐使用记录ID" prop="package_usage_id" min-width="130" />
|
||||
<ElTableColumn label="套餐ID" prop="package_id" width="100" />
|
||||
<ElTableColumn label="卡ID / 设备ID" min-width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.iot_card_id ? `卡 ${row.iot_card_id}` : `设备 ${row.device_id}` }}
|
||||
</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 { reactive } 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 { PackageUsageDailyRecord } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
compactQuery,
|
||||
formatMb,
|
||||
optionalPositiveInt,
|
||||
recentDateRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toDateQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
package_usage_id: '',
|
||||
package_id: '',
|
||||
carrier_id: '',
|
||||
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()
|
||||
const packageUsageId = optionalPositiveInt(filters.package_usage_id, '套餐使用记录 ID')
|
||||
if (packageUsageId === undefined) {
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
}
|
||||
return TrafficRecordsService.getPackageUsageDailyRecords(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toDateQuery(filters.range),
|
||||
identifier,
|
||||
package_usage_id: packageUsageId,
|
||||
package_id: optionalPositiveInt(filters.package_id, '套餐 ID'),
|
||||
carrier_id: optionalPositiveInt(filters.carrier_id, '运营商账户 ID')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const searchItems: 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: '套餐使用记录ID',
|
||||
prop: 'package_usage_id',
|
||||
type: 'input',
|
||||
config: { clearable: true }
|
||||
},
|
||||
{ label: '套餐ID', prop: 'package_id', type: 'input', config: { clearable: true } },
|
||||
{ label: '运营商账户ID', prop: 'carrier_id', type: 'input', config: { clearable: true } }
|
||||
]
|
||||
|
||||
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,197 @@
|
||||
<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.iccid || row.asset_id || '' }}
|
||||
</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,
|
||||
isPlatformAccount,
|
||||
optionalPositiveInt,
|
||||
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_id: props.refundId ? String(props.refundId) : '',
|
||||
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),
|
||||
refund_id: optionalPositiveInt(filters.refund_id, '退款单 ID'),
|
||||
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: '退款单ID', prop: 'refund_id', type: 'input', config: { 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,367 @@
|
||||
<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 / 卡 ID" min-width="190">
|
||||
<template #default="{ row }">{{ row.iccid || row.iot_card_id || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="!isAsset" label="运营商账户" prop="carrier_id" width="100" />
|
||||
<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 { 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,
|
||||
formatMb,
|
||||
optionalBoolean,
|
||||
optionalPositiveInt,
|
||||
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 { 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: '' as string | number,
|
||||
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: optionalPositiveInt(filters.carrier_id, '运营商账户 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,
|
||||
input('运营商账户ID', 'carrier_id'),
|
||||
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>
|
||||
68
src/composables/useRecordList.ts
Normal file
68
src/composables/useRecordList.ts
Normal file
@@ -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<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 }
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ========== 资产套餐查询响应 ==========
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
201
src/types/api/trafficRecords.ts
Normal file
201
src/types/api/trafficRecords.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 流量记录查询、人工冲正、退款资产后处理尝试记录(后端 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
|
||||
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
|
||||
}
|
||||
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']
|
||||
}
|
||||
|
||||
150
src/utils/business/trafficRecords.ts
Normal file
150
src/utils/business/trafficRecords.ts
Normal file
@@ -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<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`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -285,7 +285,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 +316,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 +375,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 +509,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 +540,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) ||
|
||||
|
||||
@@ -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: '重新申请',
|
||||
|
||||
Reference in New Issue
Block a user