- 新增采集成功/失败记录、卡日流量、套餐日流量详单、冲正流水、退款后处理尝试记录 6 个查询页 - 资产详情新增采集记录、冲正记录与人工冲正,同步后展示本次流量入账结果 - 退款列表与详情展示资产后处理状态,支持按状态筛选、人工重试与查看尝试记录 - 新增前端权限码常量(需在 tb_permission 补数据)
This commit is contained in:
140
src/components/business/traffic-records/CardDailyUsages.vue
Normal file
140
src/components/business/traffic-records/CardDailyUsages.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ArtSearchBar
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
label-width="110px"
|
||||
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<ElCard shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
hint="数据来自卡日快照(每卡每天一条);日期含首尾,跨度不超过 31 天。不填资产标识查全部仅平台账号可用。"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable v-loading="loading" :data="rows" row-key="id" border empty-text="暂无卡日流量">
|
||||
<ElTableColumn label="日期" prop="date" width="120" />
|
||||
<ElTableColumn label="ICCID / 卡 ID" min-width="190">
|
||||
<template #default="{ row }">{{ row.iccid || row.iot_card_id }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="运营商账户" prop="carrier_id" width="100" />
|
||||
<ElTableColumn label="当日用量" min-width="110">
|
||||
<template #default="{ row }">{{ formatMb(row.settled_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="当日结束本月累计" min-width="140">
|
||||
<template #default="{ row }">{{ formatMb(row.month_used_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="最后一次运营商读数" min-width="150">
|
||||
<template #default="{ row }">{{ formatMb(row.vendor_used_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="写入次数" prop="obs_count" width="90" />
|
||||
<ElTableColumn label="防旧值" width="80">
|
||||
<template #default="{ row }">{{ row.stale_guarded ? '是' : '否' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="更新时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { CardDailyUsage } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
compactQuery,
|
||||
formatMb,
|
||||
optionalPositiveInt,
|
||||
recentDateRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toDateQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
carrier_id: '',
|
||||
min_usage_mb: '',
|
||||
range: recentDateRange(7) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize } =
|
||||
useRecordList<CardDailyUsage>((page) => {
|
||||
const identifier = filters.identifier.trim()
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
const minUsage = filters.min_usage_mb === '' ? undefined : Number(filters.min_usage_mb)
|
||||
if (minUsage !== undefined && (!Number.isFinite(minUsage) || minUsage < 0)) {
|
||||
throw new Error('当日用量下限须为非负数')
|
||||
}
|
||||
return TrafficRecordsService.getCardDailyUsages(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toDateQuery(filters.range),
|
||||
identifier,
|
||||
carrier_id: optionalPositiveInt(filters.carrier_id, '运营商账户 ID'),
|
||||
min_usage_mb: minUsage
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / MSISDN / IMEI / SN' }
|
||||
},
|
||||
{
|
||||
label: '日期',
|
||||
prop: 'range',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
clearable: false
|
||||
}
|
||||
},
|
||||
{ label: '运营商账户ID', prop: 'carrier_id', type: 'input', config: { clearable: true } },
|
||||
{
|
||||
label: '当日用量≥(MB)',
|
||||
prop: 'min_usage_mb',
|
||||
type: 'input',
|
||||
config: { clearable: true, placeholder: '排查异常大流量' }
|
||||
}
|
||||
]
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ArtSearchBar
|
||||
v-if="!isFixed"
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
:show-expand="true"
|
||||
label-width="120px"
|
||||
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<component :is="isFixed ? 'div' : ElCard" shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
:hint="hint"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
border
|
||||
:max-height="isFixed ? 460 : undefined"
|
||||
empty-text="暂无冲正记录"
|
||||
>
|
||||
<ElTableColumn label="冲正时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
v-if="!isFixed"
|
||||
label="套餐使用记录ID"
|
||||
prop="package_usage_id"
|
||||
min-width="130"
|
||||
/>
|
||||
<ElTableColumn v-if="!isFixed" label="卡ID / 设备ID" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{
|
||||
row.iot_card_id
|
||||
? `卡 ${row.iot_card_id}`
|
||||
: row.device_id
|
||||
? `设备 ${row.device_id}`
|
||||
: '-'
|
||||
}}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="调整量" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.delta_mb > 0 ? 'delta-deduct' : 'delta-refund'">
|
||||
{{ row.delta_mb > 0 ? '补扣' : '退回' }} {{ formatMb(Math.abs(row.delta_mb)) }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="已用量变化" min-width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatMb(row.before_usage_mb) }} → {{ formatMb(row.after_usage_mb) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="套餐状态变化" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ statusName(row.before_status) }} → {{ statusName(row.after_status) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="原因" prop="reason" min-width="200" show-overflow-tooltip />
|
||||
<ElTableColumn label="操作人ID" prop="operator_id" width="100" />
|
||||
<ElTableColumn
|
||||
label="关联采集 ID"
|
||||
prop="ref_observation_id"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
label="关联流量事件 ID"
|
||||
prop="ref_event_id"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { ElCard } from 'element-plus'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { PackageTrafficAdjustment } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { ADJUSTMENT_DIRECTION_OPTIONS } from '@/config/constants/trafficRecords'
|
||||
import {
|
||||
compactQuery,
|
||||
formatMb,
|
||||
optionalPositiveInt,
|
||||
recentTimeRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toTimeQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
/** 传入 identifier + packageUsageId 时为资产详情里的单笔套餐冲正记录 */
|
||||
const props = defineProps<{ identifier?: string; packageUsageId?: number }>()
|
||||
const isFixed = computed(() => !!props.identifier && !!props.packageUsageId)
|
||||
const hint = computed(() =>
|
||||
isFixed.value
|
||||
? '按时间倒序展示该套餐的全部冲正记录。'
|
||||
: '时间跨度不超过 31 天,结束时间不含。补扣为正数、退回为负数。不填资产标识查全部仅平台账号可用。'
|
||||
)
|
||||
|
||||
const PACKAGE_STATUS_NAMES: Record<number, string> = {
|
||||
0: '待生效',
|
||||
1: '生效中',
|
||||
2: '已用完',
|
||||
3: '已过期',
|
||||
4: '已失效'
|
||||
}
|
||||
const statusName = (status?: number) =>
|
||||
status === undefined || status === null ? '-' : PACKAGE_STATUS_NAMES[status] || String(status)
|
||||
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
package_usage_id: '',
|
||||
operator_id: '',
|
||||
direction: '',
|
||||
range: recentTimeRange(7) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear } =
|
||||
useRecordList<PackageTrafficAdjustment>((page) => {
|
||||
if (isFixed.value) {
|
||||
return TrafficRecordsService.getAssetPackageAdjustments(
|
||||
props.identifier!,
|
||||
props.packageUsageId!,
|
||||
page
|
||||
)
|
||||
}
|
||||
const identifier = filters.identifier.trim()
|
||||
const packageUsageId = optionalPositiveInt(filters.package_usage_id, '套餐使用记录 ID')
|
||||
if (packageUsageId === undefined) {
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
}
|
||||
return TrafficRecordsService.getTrafficAdjustments(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toTimeQuery(filters.range),
|
||||
identifier,
|
||||
package_usage_id: packageUsageId,
|
||||
operator_id: optionalPositiveInt(filters.operator_id, '操作人账号 ID'),
|
||||
direction: filters.direction as 'deduct' | 'refund' | ''
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / MSISDN / IMEI / SN' }
|
||||
},
|
||||
{
|
||||
label: '冲正时间',
|
||||
prop: 'range',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
clearable: false
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '套餐使用记录ID',
|
||||
prop: 'package_usage_id',
|
||||
type: 'input',
|
||||
config: { clearable: true }
|
||||
},
|
||||
{ label: '操作人账号ID', prop: 'operator_id', type: 'input', config: { clearable: true } },
|
||||
{
|
||||
label: '调整方向',
|
||||
prop: 'direction',
|
||||
type: 'select',
|
||||
options: ADJUSTMENT_DIRECTION_OPTIONS,
|
||||
config: { clearable: true }
|
||||
}
|
||||
]
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.identifier, props.packageUsageId],
|
||||
() => {
|
||||
clear()
|
||||
pagination.page = 1
|
||||
void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ reload: load })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.delta-deduct {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.delta-refund {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ArtSearchBar
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
:show-expand="true"
|
||||
label-width="120px"
|
||||
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<ElCard shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
hint="日期含首尾,跨度不超过 31 天;设备标识同时匹配设备套餐与其绑定卡的套餐。不填资产标识查全部仅平台账号可用。"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable v-loading="loading" :data="rows" row-key="id" border empty-text="暂无套餐日流量">
|
||||
<ElTableColumn label="日期" prop="date" width="120" />
|
||||
<ElTableColumn label="套餐使用记录ID" prop="package_usage_id" min-width="130" />
|
||||
<ElTableColumn label="套餐ID" prop="package_id" width="100" />
|
||||
<ElTableColumn label="卡ID / 设备ID" min-width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.iot_card_id ? `卡 ${row.iot_card_id}` : `设备 ${row.device_id}` }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="当日流量" min-width="110">
|
||||
<template #default="{ row }">{{ formatMb(row.daily_usage_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="截止当日累计" min-width="120">
|
||||
<template #default="{ row }">{{ formatMb(row.cumulative_usage_mb) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="更新时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { PackageUsageDailyRecord } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
compactQuery,
|
||||
formatMb,
|
||||
optionalPositiveInt,
|
||||
recentDateRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toDateQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
package_usage_id: '',
|
||||
package_id: '',
|
||||
carrier_id: '',
|
||||
range: recentDateRange(7) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize } =
|
||||
useRecordList<PackageUsageDailyRecord>((page) => {
|
||||
const identifier = filters.identifier.trim()
|
||||
const packageUsageId = optionalPositiveInt(filters.package_usage_id, '套餐使用记录 ID')
|
||||
if (packageUsageId === undefined) {
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
}
|
||||
return TrafficRecordsService.getPackageUsageDailyRecords(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toDateQuery(filters.range),
|
||||
identifier,
|
||||
package_usage_id: packageUsageId,
|
||||
package_id: optionalPositiveInt(filters.package_id, '套餐 ID'),
|
||||
carrier_id: optionalPositiveInt(filters.carrier_id, '运营商账户 ID')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / MSISDN / IMEI / SN' }
|
||||
},
|
||||
{
|
||||
label: '日期',
|
||||
prop: 'range',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
clearable: false
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '套餐使用记录ID',
|
||||
prop: 'package_usage_id',
|
||||
type: 'input',
|
||||
config: { clearable: true }
|
||||
},
|
||||
{ label: '套餐ID', prop: 'package_id', type: 'input', config: { clearable: true } },
|
||||
{ label: '运营商账户ID', prop: 'carrier_id', type: 'input', config: { clearable: true } }
|
||||
]
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
78
src/components/business/traffic-records/RawDataPanel.vue
Normal file
78
src/components/business/traffic-records/RawDataPanel.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="raw-data-panel">
|
||||
<div v-for="block in visibleBlocks" :key="block.title" class="raw-data-block">
|
||||
<div class="raw-data-title">
|
||||
<span>{{ block.title }}</span>
|
||||
<ElTag v-if="block.truncated" size="small" type="warning">已截断</ElTag>
|
||||
<ElButton link type="primary" size="small" @click="copy(block.text)">复制</ElButton>
|
||||
</div>
|
||||
<pre class="raw-data-content">{{ block.text }}</pre>
|
||||
</div>
|
||||
<div v-if="!visibleBlocks.length" class="raw-data-empty">无原始数据</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { RawDataBlock } from '@/types/api/trafficRecords'
|
||||
|
||||
const props = defineProps<{ blocks: RawDataBlock[] }>()
|
||||
|
||||
const toText = (value: unknown) => {
|
||||
if (typeof value === 'string') return value
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleBlocks = computed(() =>
|
||||
props.blocks
|
||||
.filter((block) => block.value !== undefined && block.value !== null && block.value !== '')
|
||||
.map((block) => ({ ...block, text: toText(block.value) }))
|
||||
)
|
||||
|
||||
async function copy(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.raw-data-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.raw-data-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.raw-data-content {
|
||||
max-height: 320px;
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.raw-data-empty {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
65
src/components/business/traffic-records/RecordTableShell.vue
Normal file
65
src/components/business/traffic-records/RecordTableShell.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="record-shell">
|
||||
<div v-if="hint" class="record-hint">{{ hint }}</div>
|
||||
<ElAlert
|
||||
v-if="error"
|
||||
class="record-error"
|
||||
type="error"
|
||||
:title="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<slot />
|
||||
<div class="record-pagination">
|
||||
<ElPagination
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="pageSizes"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="(value: number) => emit('page-change', value)"
|
||||
@size-change="(value: number) => emit('size-change', value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
error?: string
|
||||
hint?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
pageSizes?: number[]
|
||||
}>(),
|
||||
{ error: '', hint: '', pageSizes: () => [20, 50, 100] }
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
(e: 'page-change', page: number): void
|
||||
(e: 'size-change', size: number): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.record-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-hint {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.record-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ElAlert
|
||||
v-if="!allowed"
|
||||
type="warning"
|
||||
title="退款资产后处理尝试记录仅平台账号可查看"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<template v-else>
|
||||
<ArtSearchBar
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
:show-expand="true"
|
||||
label-width="110px"
|
||||
@update:filter="(value: Record<string, unknown>) => Object.assign(filters, value)"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<ElCard shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
hint="时间范围必填,跨度不超过 31 天,结束时间不含;按时间倒序。"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable v-loading="loading" :data="rows" row-key="id" border empty-text="暂无尝试记录">
|
||||
<ElTableColumn label="记录时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="退款单号" min-width="180">
|
||||
<template #default="{ row }">{{ row.refund_no || row.refund_id }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="资产" min-width="200">
|
||||
<template #default="{ row }">
|
||||
{{ row.asset_type === 'device' ? '设备' : '单卡' }}
|
||||
{{ row.iccid || row.asset_id || '' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="第几次" prop="attempt" width="80" />
|
||||
<ElTableColumn label="步骤" width="110">
|
||||
<template #default="{ row }">{{
|
||||
labelOf(REFUND_POST_STEP_LABELS, row.step)
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.result === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ labelOf(REFUND_POST_RESULT_LABELS, row.result) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="错误码" prop="error_code" min-width="120" show-overflow-tooltip />
|
||||
<ElTableColumn
|
||||
label="错误信息"
|
||||
prop="error_message"
|
||||
min-width="220"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn label="触发方式" width="90">
|
||||
<template #default="{ row }">{{
|
||||
labelOf(REFUND_POST_TRIGGER_LABELS, row.trigger)
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="耗时(ms)" prop="duration_ms" width="90" />
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</ElCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { RefundPostProcessAttempt } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
REFUND_POST_RESULT_LABELS,
|
||||
REFUND_POST_RESULT_OPTIONS,
|
||||
REFUND_POST_STEP_LABELS,
|
||||
REFUND_POST_STEP_OPTIONS,
|
||||
REFUND_POST_TRIGGER_LABELS,
|
||||
REFUND_POST_TRIGGER_OPTIONS,
|
||||
RECORD_MAX_SPAN_DAYS,
|
||||
labelOf
|
||||
} from '@/config/constants/trafficRecords'
|
||||
import {
|
||||
compactQuery,
|
||||
isPlatformAccount,
|
||||
optionalPositiveInt,
|
||||
recentTimeRange,
|
||||
toTimeQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
/** refundId 来自退款详情入口:自动按该退款单筛选,时间范围取最近 31 天 */
|
||||
const props = defineProps<{ refundId?: number }>()
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const allowed = computed(() => isPlatformAccount(userInfo.value.user_type))
|
||||
|
||||
const emptyFilters = () => ({
|
||||
refund_id: props.refundId ? String(props.refundId) : '',
|
||||
refund_no: '',
|
||||
identifier: '',
|
||||
step: '',
|
||||
result: '',
|
||||
error_code: '',
|
||||
trigger: '',
|
||||
range: recentTimeRange(props.refundId ? RECORD_MAX_SPAN_DAYS : 7) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear } =
|
||||
useRecordList<RefundPostProcessAttempt>((page) =>
|
||||
TrafficRecordsService.getRefundPostProcessAttempts(
|
||||
compactQuery({
|
||||
...page,
|
||||
...toTimeQuery(filters.range, true),
|
||||
refund_id: optionalPositiveInt(filters.refund_id, '退款单 ID'),
|
||||
refund_no: filters.refund_no.trim(),
|
||||
identifier: filters.identifier.trim(),
|
||||
step: filters.step,
|
||||
result: filters.result,
|
||||
error_code: filters.error_code.trim(),
|
||||
trigger: filters.trigger
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const select = (label: string, prop: string, options: SearchFormItem['options']) => ({
|
||||
label,
|
||||
prop,
|
||||
type: 'select' as const,
|
||||
options,
|
||||
config: { clearable: true }
|
||||
})
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '记录时间',
|
||||
prop: 'range',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
clearable: true
|
||||
}
|
||||
},
|
||||
{ label: '退款单ID', prop: 'refund_id', type: 'input', config: { clearable: true } },
|
||||
{ label: '退款单号', prop: 'refund_no', type: 'input', config: { clearable: true } },
|
||||
{
|
||||
label: '资产标识',
|
||||
prop: 'identifier',
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder: 'ICCID / 虚拟号 / IMEI / SN' }
|
||||
},
|
||||
select('处理步骤', 'step', REFUND_POST_STEP_OPTIONS),
|
||||
select('结果', 'result', REFUND_POST_RESULT_OPTIONS),
|
||||
{ label: '错误码', prop: 'error_code', type: 'input', config: { clearable: true } },
|
||||
select('触发方式', 'trigger', REFUND_POST_TRIGGER_OPTIONS)
|
||||
]
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.refundId, allowed.value],
|
||||
() => {
|
||||
clear()
|
||||
Object.assign(filters, emptyFilters())
|
||||
pagination.page = 1
|
||||
if (allowed.value) void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
192
src/components/business/traffic-records/TrafficAdjustDialog.vue
Normal file
192
src/components/business/traffic-records/TrafficAdjustDialog.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="modelValue"
|
||||
title="人工冲正"
|
||||
width="min(560px, 96vw)"
|
||||
destroy-on-close
|
||||
@update:model-value="(value: boolean) => emit('update:modelValue', value)"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<ElFormItem label="套餐名称">
|
||||
<span>{{ packageName || '-' }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="当前已用量">
|
||||
<span>{{ formatMb(currentUsageMb) }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="调整方向" prop="direction">
|
||||
<ElRadioGroup v-model="form.direction">
|
||||
<ElRadio value="deduct">补扣(增加已用量)</ElRadio>
|
||||
<ElRadio value="refund">退回(减少已用量)</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="调整量(MB)" prop="amount">
|
||||
<ElInputNumber
|
||||
v-model="form.amount"
|
||||
:min="1"
|
||||
:max="form.direction === 'refund' ? Math.max(currentUsageMb, 1) : undefined"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
step-strictly
|
||||
placeholder="正整数"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="调整后已用量">
|
||||
<span :class="{ 'adjust-invalid': expectedAfter !== null && expectedAfter < 0 }">
|
||||
{{ expectedAfter === null ? '-' : formatMb(expectedAfter) }}
|
||||
</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="原因" prop="reason">
|
||||
<ElInput
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="必填,说明冲正依据"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="关联采集 ID" prop="ref_observation_id">
|
||||
<ElInput v-model="form.ref_observation_id" maxlength="128" clearable placeholder="选填" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="关联流量事件 ID" prop="ref_event_id">
|
||||
<ElInput v-model="form.ref_event_id" maxlength="128" clearable placeholder="选填" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<ElAlert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="提交后系统会按规则重新判断停复机,前端不会直接停机或复机。"
|
||||
/>
|
||||
<template #footer>
|
||||
<ElButton @click="emit('update:modelValue', false)">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="submit">提交冲正</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { errorMessage, formatMb } from '@/utils/business/trafficRecords'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
identifier: string
|
||||
packageUsageId?: number
|
||||
packageName?: string
|
||||
currentUsageMb: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const emptyForm = () => ({
|
||||
direction: 'deduct' as 'deduct' | 'refund',
|
||||
amount: undefined as number | undefined,
|
||||
reason: '',
|
||||
ref_observation_id: '',
|
||||
ref_event_id: ''
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const deltaMb = computed(() => {
|
||||
if (!form.amount) return null
|
||||
return form.direction === 'deduct' ? form.amount : -form.amount
|
||||
})
|
||||
const expectedAfter = computed(() =>
|
||||
deltaMb.value === null ? null : props.currentUsageMb + deltaMb.value
|
||||
)
|
||||
|
||||
const rules: FormRules = {
|
||||
direction: [{ required: true, message: '请选择调整方向', trigger: 'change' }],
|
||||
amount: [
|
||||
{ required: true, message: '请输入调整量', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (!Number.isInteger(value) || value <= 0) return callback(new Error('调整量须为正整数'))
|
||||
if (form.direction === 'refund' && value > props.currentUsageMb) {
|
||||
return callback(new Error('调整后已用量不能小于 0'))
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
reason: [
|
||||
{
|
||||
validator: (_rule, value: string, callback) =>
|
||||
value?.trim() ? callback() : callback(new Error('原因必填')),
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, emptyForm())
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value || !formRef.value) return
|
||||
if (!props.identifier || !props.packageUsageId) {
|
||||
ElMessage.error('缺少资产标识或套餐使用记录ID')
|
||||
return
|
||||
}
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid || deltaMb.value === null || expectedAfter.value === null) return
|
||||
if (expectedAfter.value < 0) {
|
||||
ElMessage.error('调整后已用量不能小于 0')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`当前已用量 ${formatMb(props.currentUsageMb)},${
|
||||
form.direction === 'deduct' ? '补扣' : '退回'
|
||||
} ${formatMb(form.amount)},调整后 ${formatMb(expectedAfter.value)}。提交后系统会按规则重新判断停复机,确认提交?`,
|
||||
'确认人工冲正',
|
||||
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const response = await TrafficRecordsService.adjustAssetPackageTraffic(
|
||||
props.identifier,
|
||||
props.packageUsageId,
|
||||
{
|
||||
delta_mb: deltaMb.value,
|
||||
reason: form.reason.trim(),
|
||||
ref_observation_id: form.ref_observation_id.trim() || undefined,
|
||||
ref_event_id: form.ref_event_id.trim() || undefined
|
||||
}
|
||||
)
|
||||
const result = response.data
|
||||
ElMessage.success(
|
||||
result
|
||||
? `冲正成功:已用量 ${formatMb(result.before_usage_mb)} → ${formatMb(result.after_usage_mb)}`
|
||||
: '冲正成功'
|
||||
)
|
||||
emit('update:modelValue', false)
|
||||
emit('success')
|
||||
} catch (cause) {
|
||||
// 请求层已弹出后端错误;这里只兜底非 HTTP 错误
|
||||
if (!(cause as { response?: unknown })?.response) ElMessage.error(errorMessage(cause))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.adjust-invalid {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div class="traffic-records">
|
||||
<ArtSearchBar
|
||||
:filter="filters"
|
||||
:items="searchItems"
|
||||
:show-expand="searchItems.length >= 4"
|
||||
label-width="110px"
|
||||
@update:filter="updateFilters"
|
||||
@search="search"
|
||||
@reset="reset"
|
||||
/>
|
||||
<ElCard shadow="never">
|
||||
<RecordTableShell
|
||||
:error="error"
|
||||
:hint="hint"
|
||||
:page="pagination.page"
|
||||
:page-size="pagination.page_size"
|
||||
:total="total"
|
||||
:page-sizes="pageSizes"
|
||||
@page-change="changePage"
|
||||
@size-change="changeSize"
|
||||
>
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
border
|
||||
:max-height="isAsset ? 520 : undefined"
|
||||
:empty-text="emptyText"
|
||||
>
|
||||
<ElTableColumn type="expand" width="44">
|
||||
<template #default="{ row }">
|
||||
<RawDataPanel :blocks="rawBlocks(row)" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="采集时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.observed_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="isAsset" label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.record_kind === 'failure' ? 'danger' : 'success'" size="small">
|
||||
{{ row.record_kind === 'failure' ? '失败' : '成功' }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="!isAsset" label="ICCID / 卡 ID" min-width="190">
|
||||
<template #default="{ row }">{{ row.iccid || row.iot_card_id || '-' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-if="!isAsset" label="运营商账户" prop="carrier_id" width="100" />
|
||||
<ElTableColumn label="来源" width="90">
|
||||
<template #default="{ row }">{{ labelOf(TRAFFIC_SOURCE_LABELS, row.source) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="场景" prop="scene" min-width="120" show-overflow-tooltip />
|
||||
<template v-if="mode !== 'failure'">
|
||||
<ElTableColumn label="运营商读数" min-width="110">
|
||||
<template #default="{ row }">{{
|
||||
successValue(row, formatMb(row.vendor_used_mb))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="本月累计" min-width="110">
|
||||
<template #default="{ row }">{{
|
||||
successValue(row, formatMb(row.month_used_mb))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="本次增量" min-width="100">
|
||||
<template #default="{ row }">{{
|
||||
successValue(row, formatMb(row.delta_mb))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="写入判断" min-width="110">
|
||||
<template #default="{ row }">
|
||||
{{ successValue(row, labelOf(TRAFFIC_DECISION_LABELS, row.decision)) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="防旧值" width="80">
|
||||
<template #default="{ row }">{{
|
||||
successValue(row, row.stale_guarded ? '是' : '否')
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
<template v-if="mode !== 'success'">
|
||||
<ElTableColumn label="失败类型" min-width="100">
|
||||
<template #default="{ row }">{{
|
||||
failureValue(row, labelOf(TRAFFIC_RESULT_LABELS, row.result))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="错误类别" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ failureValue(row, labelOf(TRAFFIC_ERROR_CATEGORY_LABELS, row.error_category)) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="上游返回" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ failureValue(row, joinText(row.provider_code, row.provider_message)) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="HTTP 状态" width="90">
|
||||
<template #default="{ row }">{{
|
||||
failureValue(row, display(row.http_status))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="耗时(ms)" width="90">
|
||||
<template #default="{ row }">{{
|
||||
failureValue(row, display(row.duration_ms))
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
<ElTableColumn label="网关 code / msg" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{
|
||||
joinText(row.gateway_code, row.gateway_msg)
|
||||
}}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
label="网关 trace_id"
|
||||
prop="gateway_trace_id"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn label="网关尝试" width="80">
|
||||
<template #default="{ row }">{{ display(row.gateway_attempts) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
label="采集 ID"
|
||||
prop="observation_id"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</ElTable>
|
||||
</RecordTableShell>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TrafficRecordsService } from '@/api/modules/trafficRecords'
|
||||
import { useRecordList } from '@/composables/useRecordList'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import type { RawDataBlock, TrafficCollectionRecord } from '@/types/api/trafficRecords'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
TRAFFIC_DECISION_LABELS,
|
||||
TRAFFIC_DECISION_OPTIONS,
|
||||
TRAFFIC_ERROR_CATEGORY_LABELS,
|
||||
TRAFFIC_ERROR_CATEGORY_OPTIONS,
|
||||
TRAFFIC_FAILURE_RESULT_OPTIONS,
|
||||
TRAFFIC_RESULT_LABELS,
|
||||
TRAFFIC_SOURCE_LABELS,
|
||||
TRAFFIC_SOURCE_OPTIONS,
|
||||
BOOLEAN_FILTER_OPTIONS,
|
||||
labelOf
|
||||
} from '@/config/constants/trafficRecords'
|
||||
import {
|
||||
compactQuery,
|
||||
formatMb,
|
||||
optionalBoolean,
|
||||
optionalPositiveInt,
|
||||
recentTimeRange,
|
||||
requireIdentifierForNonPlatform,
|
||||
toTimeQuery
|
||||
} from '@/utils/business/trafficRecords'
|
||||
import RawDataPanel from './RawDataPanel.vue'
|
||||
import RecordTableShell from './RecordTableShell.vue'
|
||||
|
||||
/**
|
||||
* success:全局采集成功记录;failure:全局采集失败记录;
|
||||
* asset:资产详情的卡采集记录(固定 identifier,可包含失败记录)。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{ mode?: 'success' | 'failure' | 'asset'; identifier?: string }>(),
|
||||
{ mode: 'success', identifier: '' }
|
||||
)
|
||||
|
||||
const isAsset = computed(() => props.mode === 'asset')
|
||||
const { info: userInfo } = storeToRefs(useUserStore())
|
||||
const pageSizes = computed(() => (isAsset.value ? [50, 100] : [20, 50, 100]))
|
||||
const emptyText = computed(() => (props.mode === 'failure' ? '暂无采集失败记录' : '暂无采集记录'))
|
||||
const hint = computed(() =>
|
||||
isAsset.value
|
||||
? '仅采集时刻有套餐的卡有记录,保留 90 天;时间跨度不超过 31 天,结束时间不含。'
|
||||
: '时间跨度不超过 31 天,结束时间不含;在线保留当月加最近 2 个整月。不填资产标识查全部仅平台账号可用。'
|
||||
)
|
||||
|
||||
const emptyFilters = () => ({
|
||||
identifier: '',
|
||||
carrier_id: '' as string | number,
|
||||
decision: '',
|
||||
source: '',
|
||||
scene: '',
|
||||
has_delta: '',
|
||||
stale_guarded: '',
|
||||
result: '',
|
||||
error_category: '',
|
||||
provider_code: '',
|
||||
include_failures: '',
|
||||
range: recentTimeRange(1) as [string, string] | null
|
||||
})
|
||||
const filters = reactive(emptyFilters())
|
||||
|
||||
const { rows, total, loading, error, pagination, load, search, changePage, changeSize, clear } =
|
||||
useRecordList<TrafficCollectionRecord>(
|
||||
(page) => {
|
||||
const time = toTimeQuery(filters.range)
|
||||
if (isAsset.value) {
|
||||
return TrafficRecordsService.getAssetCollections(
|
||||
props.identifier,
|
||||
compactQuery({
|
||||
...page,
|
||||
...time,
|
||||
decision: filters.decision,
|
||||
source: filters.source,
|
||||
scene: filters.scene.trim(),
|
||||
include_failures: optionalBoolean(filters.include_failures)
|
||||
})
|
||||
)
|
||||
}
|
||||
const identifier = filters.identifier.trim()
|
||||
requireIdentifierForNonPlatform(identifier, userInfo.value.user_type)
|
||||
const common = {
|
||||
...page,
|
||||
...time,
|
||||
identifier,
|
||||
carrier_id: optionalPositiveInt(filters.carrier_id, '运营商账户 ID'),
|
||||
source: filters.source,
|
||||
scene: filters.scene.trim()
|
||||
}
|
||||
if (props.mode === 'failure') {
|
||||
return TrafficRecordsService.getCollectionFailures(
|
||||
compactQuery({
|
||||
...common,
|
||||
result: filters.result,
|
||||
error_category: filters.error_category,
|
||||
provider_code: filters.provider_code.trim()
|
||||
})
|
||||
)
|
||||
}
|
||||
return TrafficRecordsService.getCollections(
|
||||
compactQuery({
|
||||
...common,
|
||||
decision: filters.decision,
|
||||
has_delta: optionalBoolean(filters.has_delta),
|
||||
stale_guarded: optionalBoolean(filters.stale_guarded)
|
||||
})
|
||||
)
|
||||
},
|
||||
isAsset.value ? 50 : 20
|
||||
)
|
||||
|
||||
const rangeItem: SearchFormItem = {
|
||||
label: '采集时间',
|
||||
prop: 'range',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
clearable: false
|
||||
}
|
||||
}
|
||||
const select = (label: string, prop: string, options: unknown[]): SearchFormItem => ({
|
||||
label,
|
||||
prop,
|
||||
type: 'select',
|
||||
options: options as SearchFormItem['options'],
|
||||
config: { clearable: true }
|
||||
})
|
||||
const input = (label: string, prop: string, placeholder = ''): SearchFormItem => ({
|
||||
label,
|
||||
prop,
|
||||
type: 'input',
|
||||
config: { clearable: true, maxlength: 64, placeholder }
|
||||
})
|
||||
|
||||
const searchItems = computed<SearchFormItem[]>(() => {
|
||||
if (isAsset.value) {
|
||||
return [
|
||||
rangeItem,
|
||||
select('写入判断', 'decision', TRAFFIC_DECISION_OPTIONS),
|
||||
select('来源', 'source', TRAFFIC_SOURCE_OPTIONS),
|
||||
input('场景', 'scene'),
|
||||
select('包含失败记录', 'include_failures', BOOLEAN_FILTER_OPTIONS)
|
||||
]
|
||||
}
|
||||
const common = [
|
||||
input('资产标识', 'identifier', 'ICCID / 虚拟号 / MSISDN / IMEI / SN'),
|
||||
rangeItem,
|
||||
input('运营商账户ID', 'carrier_id'),
|
||||
select('来源', 'source', TRAFFIC_SOURCE_OPTIONS),
|
||||
input('场景', 'scene')
|
||||
]
|
||||
if (props.mode === 'failure') {
|
||||
return [
|
||||
...common,
|
||||
select('失败类型', 'result', TRAFFIC_FAILURE_RESULT_OPTIONS),
|
||||
select('错误类别', 'error_category', TRAFFIC_ERROR_CATEGORY_OPTIONS),
|
||||
input('上游返回码', 'provider_code')
|
||||
]
|
||||
}
|
||||
return [
|
||||
...common,
|
||||
select('写入判断', 'decision', TRAFFIC_DECISION_OPTIONS),
|
||||
select('有增量', 'has_delta', BOOLEAN_FILTER_OPTIONS),
|
||||
select('触发防旧值', 'stale_guarded', BOOLEAN_FILTER_OPTIONS)
|
||||
]
|
||||
})
|
||||
|
||||
function updateFilters(value: Record<string, unknown>) {
|
||||
Object.assign(filters, value)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, emptyFilters())
|
||||
void search()
|
||||
}
|
||||
|
||||
const isFailure = (row: TrafficCollectionRecord) =>
|
||||
props.mode === 'failure' || row.record_kind === 'failure'
|
||||
const successValue = (row: TrafficCollectionRecord, text: string) => (isFailure(row) ? '-' : text)
|
||||
const failureValue = (row: TrafficCollectionRecord, text: string) => (isFailure(row) ? text : '-')
|
||||
|
||||
const display = (value: unknown) =>
|
||||
value === undefined || value === null || value === '' ? '-' : String(value)
|
||||
const joinText = (...values: unknown[]) =>
|
||||
values.filter((value) => value !== undefined && value !== null && value !== '').join(' / ') ||
|
||||
'-'
|
||||
|
||||
function rawBlocks(row: TrafficCollectionRecord): RawDataBlock[] {
|
||||
const response: RawDataBlock =
|
||||
row.raw_response !== undefined && row.raw_response !== null
|
||||
? { title: '原始响应', value: row.raw_response, truncated: row.raw_truncated }
|
||||
: { title: '原始响应(文本)', value: row.raw_response_text, truncated: row.raw_truncated }
|
||||
return [
|
||||
{ title: '采集队列', value: row.queue_points },
|
||||
{ title: '原始请求', value: row.raw_request },
|
||||
response,
|
||||
{ title: '流量事件 ID', value: row.event_id }
|
||||
]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.mode, props.identifier],
|
||||
() => {
|
||||
clear()
|
||||
Object.assign(filters, emptyFilters())
|
||||
pagination.page = 1
|
||||
pagination.page_size = isAsset.value ? 50 : 20
|
||||
if (isAsset.value && !props.identifier) return
|
||||
void load()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
defineExpose({ reload: load })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.traffic-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user