This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Design: 后台资产信息设备信号摘要与审计日志可读化
|
||||
|
||||
## Context
|
||||
|
||||
当前后台资产信息相关页面已经具备设备实时信息区块和共享的操作审计日志组件,但和最新要求相比存在两个核心问题:
|
||||
|
||||
- 设备实时信息依赖前端根据 `RSRP/RSRQ/SINR` 计算“信号强度”,并直接展示 `RSSI/RSRP/RSRQ/SINR` 原始指标;而最新要求希望后台直接消费后端总结好的 `signal_quality` 与 `signal_bad_reason`。
|
||||
- 操作审计日志虽然已经开始消费 `operation_content_*`,但规则仍不够稳定,容易继续暴露 `xxxid/xxxID` 这类内部字段,也没有围绕新增可读字段形成明确展示约束。
|
||||
|
||||
同时,操作审计日志组件是共享组件,既出现在资产详情页,也出现在卡/设备列表页的审计日志弹窗中,因此展示规则需要一次性收敛。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- Goals:
|
||||
- 后台设备实时信息直接展示后端返回的信号摘要字段
|
||||
- 后台页面不再把原始信号指标作为核心展示项
|
||||
- 操作审计日志优先展示业务可读字段,并系统性隐藏内部 ID 字段
|
||||
- 让资产详情页与日志弹窗共用同一套展示规则
|
||||
- Non-Goals:
|
||||
- 不改造 C 端接口与 C 端页面
|
||||
- 不修改后端信号判断规则或日志生成逻辑
|
||||
- 不把本次提案扩展成设备列表、卡列表其他非日志字段的全面重构
|
||||
|
||||
## Decisions
|
||||
|
||||
- Decision: 后台设备实时信息以 `signal_quality` 和 `signal_bad_reason` 作为信号展示真值来源
|
||||
|
||||
- Rationale: 最新接口已经把信号综合判断和可能原因抽象成稳定文案,继续沿用前端启发式计算会造成“双重口径”。
|
||||
|
||||
- Decision: `RSSI/RSRP/RSRQ/SINR` 原始字段仍保留在类型层,但从后台页面展示中移除
|
||||
|
||||
- Rationale: 文档明确说明原始字段仍保留,只是不再作为当前页面的展示主体;保留类型兼容性更稳妥。
|
||||
|
||||
- Decision: 操作审计日志统一优先展示可读业务字段,内部 `id` / `*_id` / `*Id` / `*ID` 一律隐藏
|
||||
|
||||
- Rationale: 新增日志字段的目的就是减少运营对内部标识的理解成本,因此展示层应明确偏向业务字段。
|
||||
|
||||
- Decision: 共享 `OperationLogsCard` 作为唯一日志展示实现,不在资产详情页和弹窗中分叉规则
|
||||
|
||||
- Rationale: 当前弹窗与资产详情页共用同一组件,保持单一规则可以避免不同入口出现展示漂移。
|
||||
|
||||
- Decision: `signal_bad_reason` 按“有值就展示,无值则显示占位”的方式处理
|
||||
- Rationale: 文档把它定义为信号较弱时的怀疑原因提示,但接口仍可能返回空值或缺失值,前端需要稳定兜底。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 活跃变更 `update-order-upload-and-device-switch-mode` 同样修改 `BasicInfoCard.vue`,落地时需要避免覆盖其尚未归档的页面调整。
|
||||
- 共享日志组件的规则收紧后,会同时影响资产详情页和列表页弹窗;这是预期收益,但也要求验证面更广。
|
||||
- 统一隐藏 `xxxid/xxxID` 字段后,若后端暂未补齐某些操作类型的可读字段,个别日志行的展示信息可能变少。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 扩展 admin 资产实时状态类型,接收新增信号摘要字段。
|
||||
2. 调整后台设备实时信息 UI,用摘要字段替代旧信号展示。
|
||||
3. 收敛旧信号计算逻辑的页面使用点。
|
||||
4. 调整共享操作审计日志组件的字段优先级与 ID 过滤规则。
|
||||
5. 在资产详情页和列表页日志弹窗中完成联调验证。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 暂无。本提案默认“C 端不纳入本次范围”已经确定。
|
||||
@@ -0,0 +1,46 @@
|
||||
# Change: 更新后台资产信息设备信号摘要与操作审计日志展示
|
||||
|
||||
## Why
|
||||
|
||||
当前后台资产信息页与操作审计日志展示和最新接口/产品口径存在两类偏差:
|
||||
|
||||
- 设备实时信息仍以前端推导的“信号强度”加 `RSSI/RSRP/RSRQ/SINR` 原始字段为主,而最新需求要求后台直接展示后端摘要字段 `signal_quality` 和 `signal_bad_reason`。
|
||||
- 资产操作审计日志接口已经补充了更多可直接面向运营展示的业务字段,但前端仍可能显示内部 `xxxid/xxxID` 或缺少对可读字段的系统性支持。
|
||||
|
||||
如果不收敛这两类差异,后台页面会继续出现“展示口径与接口设计不一致”“日志可读性不足”的问题。
|
||||
|
||||
本提案仅覆盖后台管理端(admin)能力,不包含 C 端接口和 C 端页面。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 更新后台“资产信息”中的设备实时信息展示:
|
||||
- 接入并展示 `data.device_realtime.signal_quality`
|
||||
- 接入并展示 `data.device_realtime.signal_bad_reason`
|
||||
- 从后台页面移除 `RSSI/RSRP/RSRQ/SINR` 及前端推导的旧“信号强度”展示
|
||||
- 保留原始信号字段在类型契约中的可用性,但不再作为后台页面主展示内容
|
||||
- 更新后台“资产操作审计日志”展示:
|
||||
- 支持直接展示 `operation_content_before` / `operation_content_after` 中新增的可读业务字段
|
||||
- 优先展示 `iccid`、`iccids`、`device_virtual_no`、`device_virtual_nos`、`device_imei`、`device_sn`、`shop_name`、`to_shop_name`、`target_shop_name`、`source_shop_name`
|
||||
- 统一隐藏内部 `id` / `*_id` / `*Id` / `*ID` 字段,避免继续向运营暴露内部标识
|
||||
- 明确覆盖 `card_allocate`、`card_recall`、`device_allocate`、`device_recall`、`device_bind_card`、`device_unbind_card` 等关键日志类型
|
||||
- 范围约束:
|
||||
- 不修改 C 端 spec
|
||||
- 不要求本次提案改造 C 端接口消费逻辑
|
||||
- 不改变后端接口路径、HTTP 方法和返回字段命名
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `asset-information`
|
||||
- `asset-operation-logs`
|
||||
- Affected code:
|
||||
- `src/types/api/asset.ts`
|
||||
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
|
||||
- `src/views/asset-management/asset-information/composables/useAssetFormatters.ts`
|
||||
- `src/views/asset-management/asset-information/components/OperationLogsCard.vue`
|
||||
- `src/components/business/OperationLogsDialog.vue`
|
||||
- Dependencies:
|
||||
- 与活跃变更 `update-order-upload-and-device-switch-mode` 存在同页组件交集,实施前需要先对齐 `BasicInfoCard.vue` 的最新状态
|
||||
- 依赖后端已按文档提供 admin 侧新增字段与日志内容
|
||||
- Breaking changes:
|
||||
- 无外部接口破坏性变更;本次主要是后台展示契约与类型定义的增量调整
|
||||
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin Device Realtime Signal Summary
|
||||
|
||||
The admin asset information view SHALL use backend-provided signal summary fields for device assets and MUST NOT continue using raw signal metrics as primary displayed signal content.
|
||||
|
||||
#### Scenario: Show backend signal summary on device asset page
|
||||
|
||||
- **GIVEN** 用户正在查看后台“资产信息”中的设备资产详情
|
||||
- **AND** `device_realtime` 返回了 `signal_quality`
|
||||
- **WHEN** 设备实时信息区块完成渲染
|
||||
- **THEN** 页面 MUST 显示 `signal_quality`
|
||||
- **AND** 页面 MUST 在 `signal_bad_reason` 有值时显示该字段
|
||||
- **AND** 页面 MUST 在 `signal_bad_reason` 为空时显示稳定占位内容
|
||||
|
||||
#### Scenario: Raw signal metrics are no longer shown in admin device realtime section
|
||||
|
||||
- **GIVEN** 用户正在查看后台“资产信息”中的设备实时信息
|
||||
- **WHEN** 页面渲染设备信号相关内容
|
||||
- **THEN** 页面 MUST NOT 显示 `信号强度(RSSI)`、`接收功率(RSRP)`、`接收质量(RSRQ)`、`信噪比(SINR)` 这些原始信号指标标签
|
||||
- **AND** 页面 MUST NOT 继续展示前端推导的旧“信号强度”字段
|
||||
|
||||
### Requirement: Admin Device Realtime Contract Compatibility
|
||||
|
||||
The admin asset information integration SHALL preserve additive signal summary fields in device realtime responses without changing non-signal realtime field behavior.
|
||||
|
||||
#### Scenario: Accept additive signal summary fields from realtime API
|
||||
|
||||
- **GIVEN** `GET /api/admin/assets/:identifier/realtime-status` 或 `POST /api/admin/assets/:identifier/refresh` 返回设备资产实时数据
|
||||
- **WHEN** 前端解析 `data.device_realtime`
|
||||
- **THEN** 前端类型契约 MUST 支持 `signal_quality`
|
||||
- **AND** 前端类型契约 MUST 支持 `signal_bad_reason`
|
||||
- **AND** 其他非信号实时字段 MUST 保持原有展示行为
|
||||
@@ -0,0 +1,42 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin Operation Logs Prefer Readable Business Fields
|
||||
|
||||
The admin asset operation log view SHALL prefer readable business fields from operation log content over internal identifiers when rendering change content.
|
||||
|
||||
#### Scenario: Show readable fields for card allocation and recall logs
|
||||
|
||||
- **GIVEN** 一条 `card_allocate` 或 `card_recall` 日志包含 `iccid`、`iccids`、`shop_name`、`to_shop_name` 或 `target_shop_name`
|
||||
- **WHEN** 日志列表渲染该条记录
|
||||
- **THEN** 页面 MUST 优先展示这些可读业务字段
|
||||
- **AND** 用户 MUST NOT 依赖内部店铺 ID 或其他内部标识来理解日志含义
|
||||
|
||||
#### Scenario: Show readable fields for device allocation and recall logs
|
||||
|
||||
- **GIVEN** 一条 `device_allocate` 或 `device_recall` 日志包含 `device_virtual_no`、`device_virtual_nos`、`device_imei`、`shop_name`、`target_shop_name`
|
||||
- **WHEN** 日志列表渲染该条记录
|
||||
- **THEN** 页面 MUST 优先展示这些设备和店铺的可读字段
|
||||
|
||||
#### Scenario: Show readable fields for device bind and unbind logs
|
||||
|
||||
- **GIVEN** 一条 `device_bind_card` 或 `device_unbind_card` 日志包含 `device_virtual_no`、`device_imei`、`device_sn`、`iccid`
|
||||
- **WHEN** 日志列表渲染该条记录
|
||||
- **THEN** 页面 MUST 直接展示这些可读业务字段
|
||||
|
||||
### Requirement: Admin Operation Logs Hide Internal ID Fields
|
||||
|
||||
The admin asset operation log view MUST suppress internal ID-like fields from rendered change content.
|
||||
|
||||
#### Scenario: Hide internal id fields in change content
|
||||
|
||||
- **GIVEN** 某条日志的 `operation_content_before`、`operation_content_after` 或相关字段说明中包含 `id`、`*_id`、`*Id` 或 `*ID`
|
||||
- **WHEN** 日志变更内容被渲染
|
||||
- **THEN** 这些内部 ID 字段 MUST NOT 出现在界面上
|
||||
- **AND** `ICCID`、`IMEI`、`SN`、设备虚拟号、店铺名称等业务可读标识 MUST 仍然允许显示
|
||||
|
||||
#### Scenario: Shared log dialog follows the same hidden-id rule
|
||||
|
||||
- **GIVEN** 用户从资产详情页、设备列表页或卡列表页打开后台操作审计日志
|
||||
- **WHEN** 同一条日志在不同入口被渲染
|
||||
- **THEN** 各入口 MUST 使用一致的字段过滤规则
|
||||
- **AND** 各入口 MUST 一致隐藏内部 `xxxid/xxxID` 字段
|
||||
@@ -0,0 +1,21 @@
|
||||
## 1. Proposal Review
|
||||
|
||||
- [x] 1.1 与产品/需求方确认本提案仅覆盖后台管理端,不包含 C 端范围。
|
||||
- [ ] 1.2 确认设备实时信息中应保留的非信号字段范围,避免误删其他设备状态信息。
|
||||
- [x] 1.3 确认操作审计日志中 `xxxid/xxxID` 字段统一隐藏的展示规则。
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 扩展 admin 资产实时状态相关类型,接收 `device_realtime.signal_quality` 与 `device_realtime.signal_bad_reason`。
|
||||
- [x] 2.2 调整后台资产信息页的设备实时信息展示,改为展示后端摘要字段,并移除旧的信号强度推导与原始信号指标展示。
|
||||
- [x] 2.3 清理或收敛只为旧信号展示服务的前端格式化逻辑,避免残留无效 UI 计算。
|
||||
- [x] 2.4 调整操作审计日志展示逻辑,优先展示新增可读业务字段。
|
||||
- [x] 2.5 统一隐藏日志中的 `id` / `*_id` / `*Id` / `*ID` 字段,同时保留 `ICCID`、`IMEI`、`SN`、设备虚拟号等业务标识展示。
|
||||
- [x] 2.6 验证资产详情页与列表页中的操作审计日志弹窗展示一致。
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [ ] 3.1 验证 `GET /api/admin/assets/:identifier/realtime-status` 的设备资产返回可正确映射新增字段。
|
||||
- [ ] 3.2 验证 `POST /api/admin/assets/:identifier/refresh` 刷新后设备实时信息展示一致。
|
||||
- [ ] 3.3 验证 `card_allocate` / `card_recall` / `device_allocate` / `device_recall` / `device_bind_card` / `device_unbind_card` 的日志展示可直接阅读。
|
||||
- [ ] 3.4 验证日志中不再显示内部 `xxxid/xxxID` 字段。
|
||||
@@ -132,6 +132,8 @@ export interface DeviceGatewayInfo {
|
||||
connect_time?: string // 本次联网时长(秒)
|
||||
last_online_time?: string // 设备最后在线时间
|
||||
last_update_time?: string // 信息最后更新时间
|
||||
signal_quality?: string // 信号综合判断
|
||||
signal_bad_reason?: string // 信号较弱原因提示
|
||||
rsrp?: number // 信号接收功率(dBm)
|
||||
rsrq?: number // 信号接收质量(dB)
|
||||
rssi?: string // 信号强度
|
||||
@@ -145,6 +147,7 @@ export interface DeviceGatewayInfo {
|
||||
mac_address?: string // MAC 地址
|
||||
limit_speed?: number // 限速(KB/s),0=不限
|
||||
current_iccid?: string // 当前使用卡 ICCID
|
||||
client_number?: number // 当前已连接客户端数
|
||||
max_clients?: number // 最大连接客户端数
|
||||
software_version?: string // 固件版本
|
||||
switch_mode?: string // 切卡模式
|
||||
@@ -199,6 +202,7 @@ export interface AssetPackageUsageRecord {
|
||||
package_usage_id?: number // 套餐使用记录 ID
|
||||
package_id?: number // 套餐 ID
|
||||
package_name?: string // 套餐名称
|
||||
order_no?: string // 订单号
|
||||
package_type?: PackageType // formal(正式套餐)/ addon(加油包)
|
||||
usage_type?: UsageType // 使用类型:single_card/device
|
||||
status?: PackageUsageStatus // 0 待生效 / 1 生效中 / 2 已用完 / 3 已过期 / 4 已失效
|
||||
|
||||
@@ -114,11 +114,6 @@
|
||||
<ElDescriptionsItem label="切卡模式">{{
|
||||
getSwitchModeName(cardInfo?.switch_mode)
|
||||
}}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="信号强度">
|
||||
<ElTag :type="getSignalStrengthType()" size="small">
|
||||
{{ calculateSignalStrength() }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="已绑定卡数">{{
|
||||
cardInfo?.bound_card_count || 0
|
||||
}}</ElDescriptionsItem>
|
||||
@@ -266,44 +261,16 @@
|
||||
</ElDescriptionsItem>
|
||||
|
||||
<!-- 信号信息 -->
|
||||
<ElDescriptionsItem label="信号强度(RSSI)">
|
||||
{{
|
||||
deviceRealtime.rssi !== undefined &&
|
||||
deviceRealtime.rssi !== null &&
|
||||
deviceRealtime.rssi !== ''
|
||||
? deviceRealtime.rssi
|
||||
: '-'
|
||||
}}
|
||||
<ElDescriptionsItem label="信号综合判断">
|
||||
<ElTag :type="getSignalQualityType(deviceRealtime.signal_quality)" size="small">
|
||||
{{ getSignalQualityText(deviceRealtime.signal_quality) }}
|
||||
</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="接收功率(RSRP)">
|
||||
{{
|
||||
deviceRealtime.rsrp !== undefined &&
|
||||
deviceRealtime.rsrp !== null &&
|
||||
deviceRealtime.rsrp !== ''
|
||||
? `${deviceRealtime.rsrp} dBm`
|
||||
: '-'
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="接收质量(RSRQ)">
|
||||
{{
|
||||
deviceRealtime.rsrq !== undefined &&
|
||||
deviceRealtime.rsrq !== null &&
|
||||
deviceRealtime.rsrq !== ''
|
||||
? `${deviceRealtime.rsrq} dB`
|
||||
: '-'
|
||||
}}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="信噪比(SINR)">
|
||||
{{
|
||||
deviceRealtime.sinr !== undefined &&
|
||||
deviceRealtime.sinr !== null &&
|
||||
deviceRealtime.sinr !== ''
|
||||
? `${deviceRealtime.sinr} dB`
|
||||
: '-'
|
||||
}}
|
||||
<ElDescriptionsItem label="信号较弱原因" :span="3">
|
||||
{{ getSignalBadReasonText(deviceRealtime.signal_bad_reason) }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="已连接设备数">
|
||||
{{ deviceRealtime.client_number }} / {{ deviceRealtime.max_clients }}
|
||||
{{ deviceRealtime.client_number ?? '-' }} / {{ deviceRealtime.max_clients ?? '-' }}
|
||||
</ElDescriptionsItem>
|
||||
|
||||
<!-- WiFi 信息 -->
|
||||
@@ -437,7 +404,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
ElCard,
|
||||
ElDescriptions,
|
||||
@@ -479,6 +446,7 @@
|
||||
msisdn?: string
|
||||
carrier_name?: string
|
||||
real_name_status?: number
|
||||
realname_policy?: string
|
||||
network_status?: number
|
||||
current_month_usage_mb?: number
|
||||
last_gateway_reading_mb?: number
|
||||
@@ -510,16 +478,15 @@
|
||||
interface DeviceRealtimeInfo {
|
||||
online_status?: number
|
||||
status?: number
|
||||
battery_level?: number
|
||||
battery_level?: number | null
|
||||
run_time?: string
|
||||
connect_time?: string
|
||||
last_online_time?: string
|
||||
rssi?: string
|
||||
rsrp?: string
|
||||
rsrq?: string
|
||||
sinr?: string
|
||||
client_number?: number
|
||||
max_clients?: number
|
||||
current_iccid?: string
|
||||
signal_quality?: string
|
||||
signal_bad_reason?: string
|
||||
wifi_enabled?: boolean
|
||||
ssid?: string
|
||||
wifi_password?: string
|
||||
@@ -590,7 +557,6 @@
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 使用格式化工具
|
||||
const deviceRealtimeRef = toRef(props, 'deviceRealtime')
|
||||
const {
|
||||
formatDataSize,
|
||||
formatDuration,
|
||||
@@ -601,10 +567,11 @@
|
||||
getAssetStatusType,
|
||||
getOnlineStatusName,
|
||||
getOnlineStatusType,
|
||||
calculateSignalStrength,
|
||||
getSignalStrengthType,
|
||||
getSignalQualityText,
|
||||
getSignalQualityType,
|
||||
getSignalBadReasonText,
|
||||
getSwitchModeName
|
||||
} = useAssetFormatters(deviceRealtimeRef)
|
||||
} = useAssetFormatters()
|
||||
|
||||
// 排序后的设备卡列表(按卡槽位置排序,当前卡排在最前面)
|
||||
const sortedDeviceCards = computed(() => {
|
||||
@@ -628,8 +595,8 @@
|
||||
})
|
||||
|
||||
// 处理轮询开关变化
|
||||
const handlePollingChange = (value: boolean) => {
|
||||
emit('update:pollingEnabled', value)
|
||||
const handlePollingChange = (value: string | number | boolean) => {
|
||||
emit('update:pollingEnabled', Boolean(value))
|
||||
}
|
||||
|
||||
// 跳转到卡片信息
|
||||
|
||||
@@ -96,28 +96,46 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-wrapper">
|
||||
<ElProgress
|
||||
:percentage="
|
||||
getUsageProgress(
|
||||
currentPackage.virtual_used_mb || 0,
|
||||
currentPackage.virtual_total_mb || 0
|
||||
)
|
||||
"
|
||||
:color="
|
||||
getProgressColorByPercentage(
|
||||
<div class="progress-track">
|
||||
<ElProgress
|
||||
:show-text="false"
|
||||
:percentage="
|
||||
getUsageProgress(
|
||||
currentPackage.virtual_used_mb || 0,
|
||||
currentPackage.virtual_total_mb || 0
|
||||
)
|
||||
"
|
||||
:color="
|
||||
getProgressColorByPercentage(
|
||||
getUsageProgress(
|
||||
currentPackage.virtual_used_mb || 0,
|
||||
currentPackage.virtual_total_mb || 0
|
||||
)
|
||||
)
|
||||
"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
<div
|
||||
class="breakpoint-marker"
|
||||
:style="
|
||||
getBreakpointMarkerStyle(
|
||||
currentPackage.virtual_total_mb || 0,
|
||||
currentPackage.real_total_mb || 0
|
||||
)
|
||||
"
|
||||
:title="`停机阈值/真流量总量: ${formatDataSize(currentPackage.virtual_total_mb || 0)} / ${formatDataSize(currentPackage.real_total_mb || 0)}`"
|
||||
></div>
|
||||
</div>
|
||||
<span class="progress-percentage">
|
||||
{{
|
||||
formatProgressPercentage(
|
||||
getUsageProgress(
|
||||
currentPackage.virtual_used_mb || 0,
|
||||
currentPackage.virtual_total_mb || 0
|
||||
)
|
||||
)
|
||||
"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
<div
|
||||
class="breakpoint-marker"
|
||||
:style="{ left: getBreakpointPosition(currentPackage) + '%' }"
|
||||
:title="`停机阈值/真流量总量: ${formatDataSize(currentPackage.virtual_total_mb || 0)} / ${formatDataSize(currentPackage.real_total_mb || 0)}`"
|
||||
></div>
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,8 +249,14 @@
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import type { PackageInfo } from '../types'
|
||||
|
||||
const { formatAmount, formatDataSize, getUsageProgress, getProgressColorByPercentage } =
|
||||
useAssetFormatters()
|
||||
const {
|
||||
formatAmount,
|
||||
formatDataSize,
|
||||
getUsageProgress,
|
||||
getProgressColorByPercentage,
|
||||
formatProgressPercentage,
|
||||
getBreakpointMarkerStyle
|
||||
} = useAssetFormatters()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { info: userInfo } = storeToRefs(userStore)
|
||||
@@ -269,11 +293,6 @@
|
||||
}
|
||||
return typeMap[type || ''] || '-'
|
||||
}
|
||||
|
||||
const getBreakpointPosition = (pkg: PackageInfo) => {
|
||||
if (!pkg.real_total_mb || pkg.real_total_mb === 0) return 0
|
||||
return (pkg.virtual_total_mb / pkg.real_total_mb) * 100
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -369,18 +388,34 @@
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
|
||||
.breakpoint-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--el-color-danger);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
.progress-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.breakpoint-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--el-color-danger);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
min-width: 44px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,8 @@
|
||||
{ label: '设备重置', value: 'device_reset' },
|
||||
{ label: '设备分配', value: 'device_allocate' },
|
||||
{ label: '设备回收', value: 'device_recall' },
|
||||
{ label: '设备绑卡', value: 'device_bind_card' },
|
||||
{ label: '设备解绑卡', value: 'device_unbind_card' },
|
||||
{ label: '卡分配', value: 'card_allocate' },
|
||||
{ label: '卡回收', value: 'card_recall' },
|
||||
{ label: '卡停机', value: 'card_stop' },
|
||||
@@ -195,6 +197,8 @@
|
||||
device_reset: 'warning',
|
||||
device_allocate: 'primary',
|
||||
device_recall: 'warning',
|
||||
device_bind_card: 'primary',
|
||||
device_unbind_card: 'warning',
|
||||
card_allocate: 'primary',
|
||||
card_recall: 'warning',
|
||||
card_stop: 'danger',
|
||||
@@ -223,17 +227,26 @@
|
||||
asset_type: '资产类型',
|
||||
batch_no: '批次号',
|
||||
card_category: '卡类型',
|
||||
device_imei: '设备IMEI',
|
||||
device_sn: '设备SN',
|
||||
device_virtual_no: '设备虚拟号',
|
||||
device_virtual_nos: '设备虚拟号',
|
||||
enable_polling: '轮询开关',
|
||||
file_key: '文件存储键',
|
||||
first_realname_at: '首次实名时间',
|
||||
iccid: 'ICCID',
|
||||
iccids: 'ICCID',
|
||||
network_status: '网络状态',
|
||||
real_name_status: '实名状态',
|
||||
realname_policy: '实名认证策略',
|
||||
shop_name: '店铺',
|
||||
source_service: '来源服务',
|
||||
source_shop_name: '来源店铺',
|
||||
status: '资产状态',
|
||||
stop_reason: '停机原因',
|
||||
switch_mode: '切卡模式'
|
||||
switch_mode: '切卡模式',
|
||||
target_shop_name: '目标店铺',
|
||||
to_shop_name: '目标店铺'
|
||||
}
|
||||
|
||||
const realnamePolicyMap: Record<string, string> = {
|
||||
@@ -371,6 +384,13 @@
|
||||
if (typeof value === 'boolean') return value ? '是' : '否'
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'string') return value || '空'
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '空'
|
||||
if (value.every((item) => ['string', 'number', 'boolean'].includes(typeof item))) {
|
||||
return value.map((item) => formatValue(item)).join('、')
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
<div class="card-header">
|
||||
<span>套餐列表</span>
|
||||
<div class="card-header-right">
|
||||
<ElButton type="primary" :disabled="props.boundDeviceId" @click="handleShowRecharge"
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="Boolean(props.boundDeviceId)"
|
||||
@click="handleShowRecharge"
|
||||
>套餐充值</ElButton
|
||||
>
|
||||
</div>
|
||||
@@ -97,28 +100,46 @@
|
||||
>
|
||||
</div>
|
||||
<div class="progress-wrapper">
|
||||
<ElProgress
|
||||
style="margin-top: 4px"
|
||||
:percentage="
|
||||
getUsageProgress(
|
||||
scope.row.virtual_used_mb || 0,
|
||||
scope.row.virtual_total_mb || 0
|
||||
)
|
||||
"
|
||||
:color="
|
||||
getProgressColorByPercentage(
|
||||
<div class="progress-track">
|
||||
<ElProgress
|
||||
style="margin-top: 4px"
|
||||
:show-text="false"
|
||||
:percentage="
|
||||
getUsageProgress(
|
||||
scope.row.virtual_used_mb || 0,
|
||||
scope.row.virtual_total_mb || 0
|
||||
)
|
||||
"
|
||||
:color="
|
||||
getProgressColorByPercentage(
|
||||
getUsageProgress(
|
||||
scope.row.virtual_used_mb || 0,
|
||||
scope.row.virtual_total_mb || 0
|
||||
)
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="breakpoint-marker"
|
||||
:style="
|
||||
getBreakpointMarkerStyle(
|
||||
scope.row.virtual_total_mb || 0,
|
||||
scope.row.real_total_mb || 0
|
||||
)
|
||||
"
|
||||
:title="`停机阈值/真流量总量: ${formatDataSize(scope.row.virtual_total_mb || 0)} / ${formatDataSize(scope.row.real_total_mb || 0)}`"
|
||||
></div>
|
||||
</div>
|
||||
<span class="progress-percentage">
|
||||
{{
|
||||
formatProgressPercentage(
|
||||
getUsageProgress(
|
||||
scope.row.virtual_used_mb || 0,
|
||||
scope.row.virtual_total_mb || 0
|
||||
)
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="breakpoint-marker"
|
||||
:style="{ left: getBreakpointPosition(scope.row) + '%' }"
|
||||
:title="`停机阈值/真流量总量: ${formatDataSize(scope.row.virtual_total_mb || 0)} / ${formatDataSize(scope.row.real_total_mb || 0)}`"
|
||||
></div>
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -346,14 +367,11 @@
|
||||
formatAmount,
|
||||
getPackageStatusTagType,
|
||||
getUsageProgress,
|
||||
getProgressColorByPercentage
|
||||
getProgressColorByPercentage,
|
||||
formatProgressPercentage,
|
||||
getBreakpointMarkerStyle
|
||||
} = useAssetFormatters()
|
||||
|
||||
const getBreakpointPosition = (pkg: PackageInfo) => {
|
||||
if (!pkg.real_total_mb || pkg.real_total_mb === 0) return 0
|
||||
return (pkg.virtual_total_mb / pkg.real_total_mb) * 100
|
||||
}
|
||||
|
||||
// 套餐名称点击
|
||||
const handlePackageNameClick = (packageRow: PackageInfo) => {
|
||||
if (!hasAuth('package_usage:daily_records')) {
|
||||
@@ -459,18 +477,34 @@
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
|
||||
.breakpoint-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--el-color-danger);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
.progress-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.breakpoint-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--el-color-danger);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
min-width: 44px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* 资产信息格式化工具函数
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
|
||||
|
||||
export function useAssetFormatters(deviceRealtime?: Ref<any>) {
|
||||
export function useAssetFormatters() {
|
||||
// 格式化数据大小(MB 转 GB/MB)
|
||||
const formatDataSize = (mb: number) => {
|
||||
if (mb >= 1024) {
|
||||
@@ -145,57 +143,27 @@ export function useAssetFormatters(deviceRealtime?: Ref<any>) {
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const parseSignalValue = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const parsed = Number(value)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
// 获取设备信号综合判断文案
|
||||
const getSignalQualityText = (quality?: string | null) => {
|
||||
return quality?.trim() || '暂无数据'
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
const calculateSignalScore = () => {
|
||||
if (!deviceRealtime?.value) return null
|
||||
|
||||
const rsrp = parseSignalValue(deviceRealtime.value.rsrp)
|
||||
const rsrq = parseSignalValue(deviceRealtime.value.rsrq)
|
||||
const sinr = parseSignalValue(deviceRealtime.value.sinr)
|
||||
|
||||
// RSRP、RSRQ 必须是负数;SINR 允许 0 或负值
|
||||
if (rsrp === null || rsrq === null || sinr === null) return null
|
||||
if (rsrp >= 0 || rsrq >= 0) return null
|
||||
|
||||
const rsrpScore = clamp((rsrp + 120) * 2, 0, 100)
|
||||
const rsrqScore = clamp((rsrq + 20) * 5, 0, 100)
|
||||
const sinrScore = clamp(sinr * 5, 0, 100)
|
||||
|
||||
return rsrpScore * 0.5 + sinrScore * 0.3 + rsrqScore * 0.2
|
||||
}
|
||||
|
||||
// 计算信号强度等级:基于 RSRP、RSRQ、SINR 的综合评分,RSSI 不参与
|
||||
const calculateSignalStrength = () => {
|
||||
const score = calculateSignalScore()
|
||||
if (score === null) return '-'
|
||||
|
||||
if (score >= 85) return '极好'
|
||||
if (score >= 70) return '良好'
|
||||
if (score >= 50) return '一般'
|
||||
if (score >= 30) return '较差'
|
||||
return '很差'
|
||||
}
|
||||
|
||||
// 获取信号强度标签类型
|
||||
const getSignalStrengthType = (): TagType => {
|
||||
const strength = calculateSignalStrength()
|
||||
// 获取设备信号综合判断标签类型
|
||||
const getSignalQualityType = (quality?: string | null): TagType => {
|
||||
const normalizedQuality = getSignalQualityText(quality)
|
||||
const map: Record<string, TagType> = {
|
||||
极好: 'success',
|
||||
良好: 'success',
|
||||
一般: 'warning',
|
||||
较差: 'danger',
|
||||
很差: 'danger'
|
||||
信号很好: 'success',
|
||||
信号正常: 'success',
|
||||
信号较弱: 'warning',
|
||||
信号很差: 'danger',
|
||||
暂无数据: 'info'
|
||||
}
|
||||
return map[strength] || 'info'
|
||||
return map[normalizedQuality] || 'info'
|
||||
}
|
||||
|
||||
// 获取设备弱信号原因文案
|
||||
const getSignalBadReasonText = (reason?: string | null) => {
|
||||
return reason?.trim() || '暂时无法判断'
|
||||
}
|
||||
|
||||
// 获取切卡模式名称
|
||||
@@ -242,6 +210,31 @@ export function useAssetFormatters(deviceRealtime?: Ref<any>) {
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
// 格式化进度百分比文本
|
||||
const formatProgressPercentage = (percentage: number): string => {
|
||||
if (!Number.isFinite(percentage)) return '0%'
|
||||
const normalizedPercentage = Math.min(Math.max(percentage, 0), 100)
|
||||
if (Number.isInteger(normalizedPercentage)) return `${normalizedPercentage}%`
|
||||
return `${normalizedPercentage.toFixed(2).replace(/\.?0+$/, '')}%`
|
||||
}
|
||||
|
||||
// 计算断点位置百分比(用于虚流量停机阈值标记)
|
||||
const getBreakpointPercentage = (breakpoint: number, total: number): number => {
|
||||
if (!total || total <= 0) return 0
|
||||
const percentage = (breakpoint / total) * 100
|
||||
return Math.min(Math.max(Math.round(percentage * 100) / 100, 0), 100)
|
||||
}
|
||||
|
||||
// 生成断点标记样式,避免圆点在进度条两端溢出
|
||||
const getBreakpointMarkerStyle = (breakpoint: number, total: number, markerSize: number = 8) => {
|
||||
const percentage = getBreakpointPercentage(breakpoint, total)
|
||||
const halfMarkerSize = markerSize / 2
|
||||
|
||||
return {
|
||||
left: `clamp(${halfMarkerSize}px, ${percentage}%, calc(100% - ${halfMarkerSize}px))`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
formatDataSize,
|
||||
formatAmount,
|
||||
@@ -256,12 +249,16 @@ export function useAssetFormatters(deviceRealtime?: Ref<any>) {
|
||||
getOnlineStatusType,
|
||||
getWalletStatusType,
|
||||
getPackageStatusTagType,
|
||||
calculateSignalStrength,
|
||||
getSignalStrengthType,
|
||||
getSignalQualityText,
|
||||
getSignalQualityType,
|
||||
getSignalBadReasonText,
|
||||
getSwitchModeName,
|
||||
getTransactionTypeTag,
|
||||
getPaymentStatusType,
|
||||
getUsageProgress,
|
||||
getProgressColorByPercentage
|
||||
getProgressColorByPercentage,
|
||||
formatProgressPercentage,
|
||||
getBreakpointPercentage,
|
||||
getBreakpointMarkerStyle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,33 +46,50 @@ export interface BindingCard {
|
||||
|
||||
// 设备实时信息接口
|
||||
export interface DeviceRealtimeInfo {
|
||||
device_id?: number
|
||||
device_id?: number | string
|
||||
status?: string
|
||||
online_status?: number
|
||||
report_time?: string
|
||||
boot_time?: string
|
||||
running_time?: string
|
||||
run_time?: string
|
||||
connect_time?: string
|
||||
last_online_time?: string
|
||||
battery_level?: number | null
|
||||
client_number?: number
|
||||
max_clients?: number
|
||||
rssi?: string
|
||||
rsrp?: string
|
||||
rsrq?: string
|
||||
sinr?: string
|
||||
signal_quality?: string
|
||||
signal_bad_reason?: string
|
||||
band?: string
|
||||
pci?: string
|
||||
wifi_enabled?: number
|
||||
wifi_enabled?: boolean | number
|
||||
ssid?: string
|
||||
wifi_ssid?: string
|
||||
wifi_password?: string
|
||||
network_type?: string
|
||||
ip_address?: string
|
||||
wan_ip?: string
|
||||
lan_ip?: string
|
||||
mac_address?: string
|
||||
apn?: string
|
||||
current_iccid?: string
|
||||
imsi?: string
|
||||
uplink_rate?: number
|
||||
downlink_rate?: number
|
||||
uplink_speed_limit?: number
|
||||
downlink_speed_limit?: number
|
||||
limit_speed?: number
|
||||
switch_mode?: string
|
||||
imei?: string
|
||||
model?: string
|
||||
software_version?: string
|
||||
firmware_version?: string
|
||||
sync_interval?: number
|
||||
last_update_time?: string
|
||||
}
|
||||
|
||||
// 套餐信息接口
|
||||
@@ -81,6 +98,7 @@ export interface PackageInfo {
|
||||
package_id: number
|
||||
package_name: string
|
||||
package_price: number
|
||||
paid_amount?: number
|
||||
real_total_mb: number
|
||||
real_used_mb: number
|
||||
real_remaining_mb: number
|
||||
@@ -96,6 +114,7 @@ export interface PackageInfo {
|
||||
package_type?: string // 套餐类型: formal(正式套餐)/ addon(加油包)
|
||||
enable_virtual_data?: boolean // 是否启用虚流量
|
||||
order_no?: string // 订单号
|
||||
created_at?: string // 创建时间
|
||||
}
|
||||
|
||||
// 钱包信息接口 (使用已有的 AssetWalletResponse)
|
||||
|
||||
Reference in New Issue
Block a user