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

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