feat: 新增运营商停机原因
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m44s

This commit is contained in:
sexygoat
2026-05-12 12:11:53 +08:00
parent 5d20d7dd3f
commit 471d1196b3
7 changed files with 147 additions and 15 deletions

View File

@@ -0,0 +1,40 @@
# Change: 补充资产详情 gateway_extend 卡状态扩展展示
## Why
当前后台“资产信息”页还没有消费接口新增的 `gateway_extend` 字段,因此运营人员无法在资产详情中直接看到运营商侧实际停机原因。
最新接口契约已经补充两类返回:
- 设备资产的绑定卡列表项新增 `gateway_extend`
- 卡资产详情新增 `gateway_extend`
如果前端不补齐这部分契约和展示规范,资产详情页会继续丢失上游已经返回的停机原因信息,影响停机排查和运营判断。
## What Changes
- 为后台“资产信息”页新增 `gateway_extend` 消费规范:
- 卡资产详情需接收并保留 `gateway_extend`
- 设备资产绑定卡列表项需接收并保留 `gateway_extend`
- 页面展示时需按上游原样展示该字段,不做二次翻译或改写
- 为后台“资产信息”页新增 `gateway_extend` 展示规范:
- 卡资产在“卡信息”区域展示运营商侧实际停机原因
- 设备资产在“绑定卡列表”中展示每张卡的运营商侧实际停机原因
- 字段为空时展示稳定占位内容
- 范围约束:
- 仅覆盖后台管理端资产详情页
- 不要求本次同步修改卡列表、设备列表或 C 端页面
- 不改变后端接口路径、方法和字段命名
## Impact
- Affected specs:
- `asset-information`
- Affected code:
- `src/types/api/asset.ts`
- `src/views/asset-management/asset-information/composables/useAssetInfo.ts`
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
- `src/views/asset-management/asset-information/types.ts`
- Dependencies:
- 依赖后端资产详情相关响应已返回 `gateway_extend`
- Breaking changes:
-

View File

@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Admin Asset Gateway Extend Contract
The admin asset information integration SHALL accept and preserve `gateway_extend` for card asset details and device bound-card entries.
#### Scenario: Card asset detail preserves gateway_extend
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 资产详情响应返回 `gateway_extend`
- **WHEN** 前端解析并映射卡资产详情数据
- **THEN** 页面状态 MUST 保留 `gateway_extend` 原始字符串
- **AND** 后续页面渲染 MUST 可以直接消费该字段
#### Scenario: Device bound-card entries preserve gateway_extend
- **GIVEN** 用户正在查看后台“资产信息”中的设备资产详情
- **AND** 绑定卡列表数据中的某条记录返回 `gateway_extend`
- **WHEN** 前端解析初次详情响应或后续刷新返回的绑定卡数据
- **THEN** 对应卡记录 MUST 保留各自的 `gateway_extend` 原始字符串
- **AND** 绑定卡列表的刷新合并过程 MUST NOT 无故丢失已有的 `gateway_extend`
### Requirement: Admin Asset Gateway Extend Display
The admin asset information view SHALL display `gateway_extend` as the carrier-side actual stop reason for card assets and device bound cards.
#### Scenario: Show gateway_extend on card asset page
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
- **WHEN** 页面渲染“卡信息”区域
- **THEN** 页面 MUST 提供“运营商停机原因”或等效标签来展示 `gateway_extend`
- **AND** 展示内容 MUST 与上游返回的 `gateway_extend` 保持一致
- **AND** 当 `gateway_extend` 为空时,页面 MUST 显示稳定占位内容
#### Scenario: Show gateway_extend in device bound-card list
- **GIVEN** 用户正在查看后台“资产信息”中的设备资产详情
- **WHEN** 页面渲染“绑定卡列表”
- **THEN** 每条绑定卡记录 MUST 提供“运营商停机原因”或等效展示位来展示本卡的 `gateway_extend`
- **AND** 展示内容 MUST 与该卡返回的 `gateway_extend` 保持一致
- **AND** 当某条卡记录的 `gateway_extend` 为空时,该行 MUST 显示稳定占位内容

View File

@@ -0,0 +1,15 @@
## 1. Contract Alignment
- [x] 1.1 扩展后台资产详情相关类型,支持卡资产详情与设备绑定卡列表中的 `gateway_extend`
- [x] 1.2 调整资产详情数据映射与设备绑定卡刷新合并逻辑,确保 `gateway_extend` 在初次加载和后续刷新后都不会丢失。
## 2. UI Display
- [x] 2.1 在卡资产“卡信息”区域新增 `gateway_extend` 展示,作为运营商侧实际停机原因。
- [x] 2.2 在设备资产“绑定卡列表”中新增 `gateway_extend` 展示,并为缺失值提供稳定占位内容。
## 3. Verification
- [ ] 3.1 验证卡资产详情返回 `gateway_extend` 时页面可按原样展示。
- [ ] 3.2 验证设备资产绑定卡列表返回 `gateway_extend` 时每条卡记录展示正确。
- [ ] 3.3 验证资产刷新或实时状态更新后,已展示的 `gateway_extend` 不会被错误清空。

View File

@@ -57,6 +57,7 @@ export interface AssetResolveResponse {
series_name: string // 套餐系列名称 series_name: string // 套餐系列名称
real_name_status: RealNameStatus // 实名状态0 未实名 / 1 已实名 real_name_status: RealNameStatus // 实名状态0 未实名 / 1 已实名
realname_policy?: string // 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名) realname_policy?: string // 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)
real_name_at?: string | null // 实名时间
network_status?: NetworkStatus // 网络状态0 停机 / 1 开机(仅 card network_status?: NetworkStatus // 网络状态0 停机 / 1 开机(仅 card
current_package: string // 当前套餐名称(无则空) current_package: string // 当前套餐名称(无则空)
current_package_usage_id: number | null // 当前主套餐对应的套餐使用记录 ID无主套餐时为 null current_package_usage_id: number | null // 当前主套餐对应的套餐使用记录 ID无主套餐时为 null
@@ -86,6 +87,7 @@ export interface AssetResolveResponse {
supplier?: string // 供应商 supplier?: string // 供应商
activation_status?: number // 激活状态 activation_status?: number // 激活状态
enable_polling?: boolean // 是否参与轮询 enable_polling?: boolean // 是否参与轮询
gateway_extend?: string // Gateway 卡状态扩展字段,原样返回上游 extend
// ===== 设备专属字段 (asset_type === 'device' 时) ===== // ===== 设备专属字段 (asset_type === 'device' 时) =====
bound_card_count?: number // 绑定卡数量 bound_card_count?: number // 绑定卡数量
@@ -117,6 +119,7 @@ export interface AssetBoundCard {
realname_policy?: string // 实名认证策略 realname_policy?: string // 实名认证策略
slot_position: number // 插槽位置 slot_position: number // 插槽位置
is_current?: boolean // 是否为设备当前使用的卡 is_current?: boolean // 是否为设备当前使用的卡
gateway_extend?: string // Gateway 卡状态扩展字段,原样返回上游 extend
} }
// ========== 资产实时状态响应 ========== // ========== 资产实时状态响应 ==========
@@ -199,6 +202,7 @@ export type AssetRefreshResponse = AssetRealtimeStatusResponse
* 对应接口GET /api/admin/assets/:asset_type/:id/packages * 对应接口GET /api/admin/assets/:asset_type/:id/packages
*/ */
export interface AssetPackageUsageRecord { export interface AssetPackageUsageRecord {
id?: number // 兼容前端当前映射字段
package_usage_id?: number // 套餐使用记录 ID package_usage_id?: number // 套餐使用记录 ID
package_id?: number // 套餐 ID package_id?: number // 套餐 ID
package_name?: string // 套餐名称 package_name?: string // 套餐名称
@@ -209,8 +213,10 @@ export interface AssetPackageUsageRecord {
status_name?: string // 状态中文名 status_name?: string // 状态中文名
real_total_mb?: number // 真流量总量(百分比以此为基准) real_total_mb?: number // 真流量总量(百分比以此为基准)
real_used_mb?: number // 真流量已用 real_used_mb?: number // 真流量已用
real_remaining_mb?: number // 兼容前端当前映射字段
virtual_total_mb?: number // 业务停机阈值 virtual_total_mb?: number // 业务停机阈值
virtual_used_mb?: number // 展示已用量 virtual_used_mb?: number // 展示已用量
virtual_remaining_mb?: number // 兼容前端当前映射字段
virtual_ratio?: number // 虚流量比例real/virtual virtual_ratio?: number // 虚流量比例real/virtual
reduction_pct?: number // 展示增幅比例(小数,如 0.428571 reduction_pct?: number // 展示增幅比例(小数,如 0.428571
enable_virtual_data?: boolean // 是否启用虚流量 enable_virtual_data?: boolean // 是否启用虚流量

View File

@@ -67,6 +67,10 @@
</ElTag> </ElTag>
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="运营商停机原因" :span="2">
<span class="gateway-extend-text">{{ cardInfo?.gateway_extend || '-' }}</span>
</ElDescriptionsItem>
<template v-if="cardInfo?.bound_device_id"> <template v-if="cardInfo?.bound_device_id">
<ElDescriptionsItem label="绑定设备号"> <ElDescriptionsItem label="绑定设备号">
<ElButton <ElButton
@@ -160,7 +164,8 @@
</div> </div>
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn prop="msisdn" label="MSISDN" min-width="120" /> <ElTableColumn prop="msisdn" label="MSISDN" min-width="150" showOverflowTooltip/>
<ElTableColumn prop="carrier_name" label="运营商" min-width="120" showOverflowTooltip /> <ElTableColumn prop="carrier_name" label="运营商" min-width="120" showOverflowTooltip />
<ElTableColumn prop="slot_position" label="卡槽位置" width="130"> <ElTableColumn prop="slot_position" label="卡槽位置" width="130">
<template #default="scope"> <template #default="scope">
@@ -194,7 +199,12 @@
{{ scope.row.real_name_at ? formatDateTime(scope.row.real_name_at) : '-' }} {{ scope.row.real_name_at ? formatDateTime(scope.row.real_name_at) : '-' }}
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn label="操作" width="180"> <ElTableColumn label="运营商停机原因" min-width="220" showOverflowTooltip>
<template #default="scope">
{{ scope.row.gateway_extend || '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="180" fixed="right">
<template #default="scope"> <template #default="scope">
<!-- 根据网络状态显示启用或停用按钮 --> <!-- 根据网络状态显示启用或停用按钮 -->
<ElButton <ElButton
@@ -437,6 +447,7 @@
real_name_status?: number real_name_status?: number
real_name_at?: string real_name_at?: string
realname_policy?: string realname_policy?: string
gateway_extend?: string
} }
interface AssetInfo { interface AssetInfo {
@@ -461,6 +472,7 @@
series_name?: string series_name?: string
real_name_at?: string real_name_at?: string
supplier?: string supplier?: string
gateway_extend?: string
bound_device_id?: number bound_device_id?: number
bound_device_no?: string bound_device_no?: string
bound_device_name?: string bound_device_name?: string
@@ -690,6 +702,10 @@
<style scoped lang="scss"> <style scoped lang="scss">
.info-card { .info-card {
&.basic-info { &.basic-info {
.gateway-extend-text {
word-break: break-all;
}
.card-header { .card-header {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -6,7 +6,12 @@
import { ref } from 'vue' import { ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { AssetService } from '@/api/modules' import { AssetService } from '@/api/modules'
import type { AssetWalletResponse, DeviceGatewayInfo, AssetPackageUsageRecord } from '@/types/api' import type {
AssetBoundCard,
AssetWalletResponse,
DeviceGatewayInfo,
AssetPackageUsageRecord
} from '@/types/api'
type TrafficDisplaySource = { type TrafficDisplaySource = {
real_total_mb?: number | null real_total_mb?: number | null
@@ -39,6 +44,23 @@ const getDisplayedUsagePercentage = (traffic?: TrafficDisplaySource | null) => {
return totalMb > 0 ? `${((usedMb / totalMb) * 100).toFixed(2)}%` : '0.00%' return totalMb > 0 ? `${((usedMb / totalMb) * 100).toFixed(2)}%` : '0.00%'
} }
const mergeAssetBoundCards = (
existingCards: AssetBoundCard[] = [],
realtimeCards: AssetBoundCard[] = []
): AssetBoundCard[] =>
realtimeCards.map((realtimeCard) => {
const existingCard = existingCards.find((card) => card.iccid === realtimeCard.iccid)
if (!existingCard) return realtimeCard
const mergedCard = { ...existingCard } as unknown as Record<string, unknown>
for (const [key, value] of Object.entries(realtimeCard)) {
if (value !== null && value !== undefined) {
mergedCard[key] = value
}
}
return mergedCard as unknown as AssetBoundCard
})
export function useAssetInfo() { export function useAssetInfo() {
// 状态管理 // 状态管理
const loading = ref(false) const loading = ref(false)
@@ -99,6 +121,7 @@ export function useAssetInfo() {
carrier_name: data.carrier_name || '', carrier_name: data.carrier_name || '',
supplier: data.supplier || '', supplier: data.supplier || '',
network_status: data.network_status, network_status: data.network_status,
gateway_extend: data.gateway_extend ?? '',
bound_device_id: data.bound_device_id, bound_device_id: data.bound_device_id,
bound_device_no: data.bound_device_no || '', bound_device_no: data.bound_device_no || '',
bound_device_name: data.bound_device_name || '', bound_device_name: data.bound_device_name || '',
@@ -293,18 +316,7 @@ export function useAssetInfo() {
if (data.cards && data.cards.length > 0) { if (data.cards && data.cards.length > 0) {
// 合并实时卡数据与原始卡数据,保留 real_name_at 等静态字段 // 合并实时卡数据与原始卡数据,保留 real_name_at 等静态字段
// 仅用实时接口中非 null/undefined 的字段覆盖,避免清掉 resolve 接口返回的静态数据 // 仅用实时接口中非 null/undefined 的字段覆盖,避免清掉 resolve 接口返回的静态数据
const existingCards: any[] = cardInfo.value.cards || [] cardInfo.value.cards = mergeAssetBoundCards(cardInfo.value.cards || [], data.cards)
cardInfo.value.cards = data.cards.map((realtimeCard: any) => {
const existing = existingCards.find((c: any) => c.iccid === realtimeCard.iccid)
if (!existing) return realtimeCard
const merged = { ...existing }
for (const [key, value] of Object.entries(realtimeCard)) {
if (value !== null && value !== undefined) {
merged[key] = value
}
}
return merged
})
} }
if (data.online_status !== undefined) { if (data.online_status !== undefined) {
cardInfo.value.online_status = data.online_status cardInfo.value.online_status = data.online_status

View File

@@ -27,6 +27,7 @@ export interface AssetInfo {
sn?: string sn?: string
cards?: BindingCard[] cards?: BindingCard[]
status?: number status?: number
gateway_extend?: string
} }
// 设备绑定卡信息 // 设备绑定卡信息
@@ -42,6 +43,7 @@ export interface BindingCard {
network_status?: number network_status?: number
real_name_status?: number real_name_status?: number
realname_policy?: string realname_policy?: string
gateway_extend?: string
} }
// 设备实时信息接口 // 设备实时信息接口