feat: 流量记录查询、套餐人工冲正与退款资产后处理
Some checks failed
构建并部署前端到测试环境 / deploy (push) Failing after 7m27s

- 新增采集成功/失败记录、卡日流量、套餐日流量详单、冲正流水、退款后处理尝试记录 6 个查询页
- 资产详情新增采集记录、冲正记录与人工冲正,同步后展示本次流量入账结果
- 退款列表与详情展示资产后处理状态,支持按状态筛选、人工重试与查看尝试记录
- 新增前端权限码常量(需在 tb_permission 补数据)
This commit is contained in:
邹进
2026-11-07 13:19:26 +09:00
parent 49a4d32eab
commit 984db9889c
35 changed files with 2693 additions and 31 deletions

View File

@@ -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`)
}
}

View 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
)
}
}

View 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>

View File

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

View File

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

View 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>

View 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>

View File

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

View 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>

View File

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

View 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 }
}

View 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 : '-'

View File

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

View File

@@ -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', // 员工代收款账单详情

View File

@@ -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
}
// ========== 资产套餐查询响应 ==========

View File

@@ -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[]
}

View 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
}

View File

@@ -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']
}

View 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}` : ''}`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>

View File

@@ -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 ? '是' : '否')

View File

@@ -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: '重新申请',