feat: 预计最终到期时间展示
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 8m8s

This commit is contained in:
luo
2026-07-22 16:31:01 +08:00
parent 8de4339505
commit 8faa2ea2ef
16 changed files with 342 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
## Context
运营需要在资产层查看当前主套餐及所有排队主套餐顺序接续后的预计最终到期时间。该结果依赖后端套餐队列、激活条件与计时规则,前端只负责展示接口结果。
## Goals / Non-Goals
- Goals:
- 在资产详情、IoT 卡列表和设备列表统一展示“预计套餐到期时间”。
- 区分可精确预计与待激活后才可计算的状态。
- 对临期资产提供基于后端剩余天数的视觉提示。
- Non-Goals:
- 不修改单个套餐明细的“到期时间”展示或套餐队列顺序。
- 不新增预计到期时间筛选、排序或前端日期计算。
- 不改变套餐续费、激活或到期规则。
## Decisions
- Decision: 统一使用资产详情和资产列表响应中的 `estimated_final_expires_at``days_until_final_expiry``expiry_estimate_status``is_expiring`,不调用额外计算接口。
- Decision: 仅当 `expiry_estimate_status=exact` 且存在 `estimated_final_expires_at` 时格式化展示日期;不可预计状态展示“待激活后起算”。
- Decision: `is_expiring=true` 是临期样式的唯一触发条件,`days_until_final_expiry` 只作为剩余天数展示或样式辅助信息,前端不自行判定临期阈值。
- Decision: 普通卡和设备列表保持既有服务端返回顺序与前端排序行为,不因临期字段重排。
- Alternatives considered: 前端根据当前套餐到期时间、排队套餐时长和计时基准计算最终日期。未采用,因为等待激活和后端队列规则会导致结果不准确。
## Risks / Trade-offs
- 后端缺少预计字段时无法显示最终日期 -> 对空值显示稳定占位,不以当前套餐日期替代。
- 未知 `expiry_estimate_status` 可能导致错误日期展示 -> 仅 `exact` 可显示日期,其他状态显示稳定占位或后端约定的不可预计提示。
- 列表增加时间列会占用宽度 -> 作为可配置动态列,沿用现有横向滚动与列选择能力。
## Migration Plan
1. 扩展资产详情及卡、设备列表类型以保留预计最终到期字段。
2. 将资产详情响应字段映射到页面状态,并在基础信息区域展示。
3. 在卡和设备列表增加预计套餐到期时间列及临期样式。
4. 验证无套餐、仅当前套餐、多个排队套餐和待激活后起算四类响应。
5. 如需回滚,移除新增展示字段和列表列;不涉及数据迁移。
## Open Questions
- `expiry_estimate_status``exact` 和待激活状态外的完整枚举及对应文案由后端接口文档确认;本提案要求未知状态不得伪造日期。

View File

@@ -0,0 +1,35 @@
# Change: 新增资产预计套餐到期时间展示
## Why
当前页面只能在套餐明细中查看单个套餐的到期时间,运营无法快速了解当前主套餐与全部排队主套餐接续后的资产最终到期时间。前端也不能可靠地自行叠加套餐时长,尤其当套餐需要激活后才开始计时时。
## What Changes
- 在资产详情、IoT 卡列表和设备列表增加统一的 `预计套餐到期时间` 展示。
- 资产详情接口和资产列表响应支持 `estimated_final_expires_at``days_until_final_expiry``expiry_estimate_status``is_expiring` 字段。
-`expiry_estimate_status=exact` 时,展示后端返回的 `estimated_final_expires_at`
- 当套餐尚待激活等无法预计最终日期时,展示“待激活后起算”,不得伪造日期。
-`is_expiring=true` 时,按后端返回的剩余天数使用临期颜色提示;普通资产列表不得因临期状态改变既有排序。
- 当前套餐自身的到期时间继续仅在套餐明细中展示;前端不得叠加套餐时长计算预计最终到期时间。
## Impact
- Affected specs:
- `asset-information`
- `iot-card-management`
- `device-management`
- Affected code:
- `src/types/api/asset.ts`
- `src/types/api/card.ts`
- `src/types/api/device.ts`
- `src/views/asset-management/asset-information/types.ts`
- `src/views/asset-management/asset-information/composables/useAssetInfo.ts`
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
- `src/views/asset-management/iot-card-management/index.vue`
- `src/views/asset-management/device-list/index.vue`
- API contracts:
- `GET /api/admin/assets/resolve/{identifier}`
- IoT 卡和设备资产列表接口
- Dependencies:
- 后端在资产详情与资产列表响应中返回预计最终到期字段。

View File

@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: Asset Estimated Final Expiry Contract
The admin asset information integration SHALL preserve `estimated_final_expires_at`, `days_until_final_expiry`, `expiry_estimate_status`, and `is_expiring` returned by `GET /api/admin/assets/resolve/{identifier}`.
#### Scenario: Preserve an exact final expiry estimate
- **GIVEN** 用户查询后台资产详情
- **WHEN** `GET /api/admin/assets/resolve/{identifier}` returns `expiry_estimate_status=exact`
- **THEN** 前端状态 MUST preserve `estimated_final_expires_at` as `string | null`
- **AND** 前端状态 MUST preserve `days_until_final_expiry` as `number | null`
- **AND** 前端状态 MUST preserve `is_expiring` as a boolean
#### Scenario: Preserve an unavailable final expiry estimate
- **GIVEN** 用户查询后台资产详情
- **WHEN** 接口返回待激活或其他不可预计的 `expiry_estimate_status`
- **THEN** 前端状态 MUST preserve `expiry_estimate_status`
- **AND** 前端 MUST NOT derive `estimated_final_expires_at` from current-package or package-detail fields
### Requirement: Asset Estimated Final Expiry Display
The admin asset information view SHALL display one asset-level field labeled `预计套餐到期时间` for the current primary package and all queued primary packages, without replacing individual package expiry dates in package details.
#### Scenario: Display an exact final expiry date
- **GIVEN** 资产详情返回 `expiry_estimate_status=exact`
- **AND** `estimated_final_expires_at` has a value
- **WHEN** 页面渲染卡资产或设备资产基础信息
- **THEN** 页面 MUST display `预计套餐到期时间`
- **AND** 页面 MUST format and display `estimated_final_expires_at`
#### Scenario: Display activation-pending final expiry
- **GIVEN** 资产详情返回等待激活等不可预计的 `expiry_estimate_status`
- **WHEN** 页面渲染卡资产或设备资产基础信息
- **THEN** `预计套餐到期时间` MUST display `待激活后起算`
- **AND** 页面 MUST NOT display a fabricated date
#### Scenario: Display no-package placeholder
- **GIVEN** 资产没有当前或排队主套餐
- **AND** `estimated_final_expires_at` is null or absent
- **WHEN** 页面渲染资产基础信息
- **THEN** `预计套餐到期时间` MUST display a stable placeholder
- **AND** 页面 MUST NOT substitute the current package detail expiry date
#### Scenario: Highlight backend-designated expiring asset
- **GIVEN** 资产详情返回 `is_expiring=true`
- **WHEN** 页面渲染 `预计套餐到期时间`
- **THEN** 页面 MUST apply the expiring visual treatment using `days_until_final_expiry`
- **AND** 页面 MUST NOT derive whether the asset is expiring from a locally calculated date difference
#### Scenario: Preserve package detail expiry semantics
- **GIVEN** 用户查看资产详情中的套餐明细
- **WHEN** 页面渲染单个套餐的到期时间
- **THEN** 页面 MUST continue to display that package's own expiry field in the package detail context
- **AND** 页面 MUST NOT replace it with `estimated_final_expires_at`

View File

@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Device Estimated Final Expiry Display
The device management list integration SHALL preserve and display the backend device-level estimated final package expiry fields without traversing bound cards or calculating package continuation dates in the frontend.
#### Scenario: Display exact device final expiry estimate
- **GIVEN** 设备列表接口返回 `expiry_estimate_status=exact` and `estimated_final_expires_at`
- **WHEN** 页面渲染设备列表行
- **THEN** 页面 MUST display a column labeled `预计套餐到期时间`
- **AND** 该列 MUST format and display `estimated_final_expires_at`
#### Scenario: Display activation-pending device final expiry
- **GIVEN** 设备列表接口返回待激活等不可预计的 `expiry_estimate_status`
- **WHEN** 页面渲染设备列表行
- **THEN** `预计套餐到期时间` MUST display `待激活后起算`
- **AND** 页面 MUST NOT traverse bound cards or display a fabricated date
#### Scenario: Highlight expiring device without reordering
- **GIVEN** 设备列表接口返回 `is_expiring=true` and `days_until_final_expiry`
- **WHEN** 页面渲染该设备的预计套餐到期时间
- **THEN** 页面 MUST apply the expiring visual treatment based on the backend fields
- **AND** 页面 MUST NOT change the ordinary device list sort order because of `is_expiring`
#### Scenario: Display no-package device placeholder
- **GIVEN** 设备列表记录没有可预计的最终到期时间
- **WHEN** 页面渲染预计套餐到期时间列
- **THEN** 页面 MUST display a stable placeholder
- **AND** 页面 MUST NOT use an individual package expiry as a substitute

View File

@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: IoT Card Estimated Final Expiry Display
The IoT card management list integration SHALL preserve and display the backend asset-level estimated final package expiry fields without calculating package continuation dates in the frontend.
#### Scenario: Display exact card final expiry estimate
- **GIVEN** 卡列表接口返回 `expiry_estimate_status=exact` and `estimated_final_expires_at`
- **WHEN** 页面渲染卡列表行
- **THEN** 页面 MUST display a column labeled `预计套餐到期时间`
- **AND** 该列 MUST format and display `estimated_final_expires_at`
#### Scenario: Display activation-pending card final expiry
- **GIVEN** 卡列表接口返回待激活等不可预计的 `expiry_estimate_status`
- **WHEN** 页面渲染卡列表行
- **THEN** `预计套餐到期时间` MUST display `待激活后起算`
- **AND** 页面 MUST NOT calculate or display a fabricated date
#### Scenario: Highlight expiring card without reordering
- **GIVEN** 卡列表接口返回 `is_expiring=true` and `days_until_final_expiry`
- **WHEN** 页面渲染该卡的预计套餐到期时间
- **THEN** 页面 MUST apply the expiring visual treatment based on the backend fields
- **AND** 页面 MUST NOT change the ordinary card list sort order because of `is_expiring`
#### Scenario: Display no-package card placeholder
- **GIVEN** 卡列表记录没有可预计的最终到期时间
- **WHEN** 页面渲染预计套餐到期时间列
- **THEN** 页面 MUST display a stable placeholder
- **AND** 页面 MUST NOT use an individual package expiry as a substitute

View File

@@ -0,0 +1,25 @@
## 1. API Contracts And Types
- [ ] 1.1 扩展资产详情响应和资产信息页面状态,支持 `estimated_final_expires_at``days_until_final_expiry``expiry_estimate_status``is_expiring`
- [ ] 1.2 扩展 IoT 卡和设备列表项类型,支持预计最终到期字段。
## 2. Asset Information
- [ ] 2.1 将资产解析接口返回的预计最终到期字段映射到资产详情页面状态。
- [ ] 2.2 在资产详情卡和设备基础信息中展示“预计套餐到期时间”。
- [ ] 2.3 `expiry_estimate_status=exact` 时格式化显示 `estimated_final_expires_at`;待激活等不可预计状态显示“待激活后起算”。
- [ ] 2.4 `is_expiring=true` 时按 `days_until_final_expiry` 应用临期样式,不根据日期或天数自行推导临期状态。
## 3. Asset Lists
- [ ] 3.1 在 IoT 卡列表新增“预计套餐到期时间”可配置列,显示接口返回的预计日期或“待激活后起算”。
- [ ] 3.2 在设备列表新增“预计套餐到期时间”可配置列,显示接口返回的预计日期或“待激活后起算”。
- [ ] 3.3 在两个列表对 `is_expiring=true` 的预计到期时间使用临期样式,且不改变现有列表排序。
## 4. Verification
- [ ] 4.1 验证无套餐时不伪造预计日期并显示稳定占位内容。
- [ ] 4.2 验证仅当前套餐和存在多个排队主套餐时,资产详情和两个列表均显示后端 `estimated_final_expires_at`
- [ ] 4.3 验证等待激活等不可预计状态显示“待激活后起算”,不显示计算出的日期。
- [ ] 4.4 验证 `is_expiring=true` 时使用临期样式,且卡、设备列表顺序不变。
- [ ] 4.5 运行相关类型检查、lint 或构建验证。

View File

@@ -66,6 +66,9 @@ export interface AssetStartResponse {
// 资产实名认证策略
export type AssetRealnamePolicy = 'none' | 'before_order' | 'after_order'
// 预计最终到期时间状态
export type ExpiryEstimateStatus = 'exact' | 'waiting_activation' | string
// 批量更新资产实名认证策略请求
export interface BatchUpdateAssetRealnamePolicyRequest {
asset_ids: number[]
@@ -114,6 +117,10 @@ export interface AssetResolveResponse {
virtual_used_mb: number // 展示已用量
total_virtual_used_mb?: number | null // 所有套餐已用量(MB),未请求时为 null
total_virtual_remaining_mb?: number | null // 所有套餐剩余量(MB),未请求时为 null
estimated_final_expires_at?: string | null // 当前及排队主套餐接续后的预计最终到期时间
days_until_final_expiry?: number | null // 距预计最终到期的剩余天数
expiry_estimate_status?: ExpiryEstimateStatus // 预计最终到期时间状态
is_expiring?: boolean // 是否临期,由后端判断
reduction_pct: number // 展示增幅比例(小数,如 0.428571
device_protect_status?: DeviceProtectStatus // 保护期状态none / stop / start仅 device
activated_at: string // 激活时间

View File

@@ -399,6 +399,10 @@ export interface StandaloneIotCard {
data_usage_mb: number // 累计流量使用(MB)
current_month_usage_mb?: number // 自然月累计流量(MB)
current_month_start_date?: string | null // 本月开始日期
estimated_final_expires_at?: string | null // 当前及排队主套餐接续后的预计最终到期时间
days_until_final_expiry?: number | null // 距预计最终到期的剩余天数
expiry_estimate_status?: string // 预计最终到期时间状态
is_expiring?: boolean // 是否临期,由后端判断
last_month_total_mb?: number // 上月流量总量(MB)
last_data_check_at?: string | null // 最后流量检查时间
last_real_name_check_at?: string | null // 最后实名检查时间

View File

@@ -67,6 +67,10 @@ export interface Device {
realname_policy?: string // 实名认证策略
real_name_status: 0 | 1 // 实名状态 (0:未实名, 1:已实名)
real_name_status_name: string // 实名状态名称
estimated_final_expires_at?: string | null // 当前及排队主套餐接续后的预计最终到期时间
days_until_final_expiry?: number | null // 距预计最终到期的剩余天数
expiry_estimate_status?: string // 预计最终到期时间状态
is_expiring?: boolean // 是否临期,由后端判断
authorized_enterprise_id?: number | null // 当前有效授权企业ID
authorized_enterprise_name?: string | null // 当前有效授权企业名称
}

View File

@@ -0,0 +1,23 @@
import { formatDateTime } from './format'
export interface ExpiryEstimate {
estimated_final_expires_at?: string | null
days_until_final_expiry?: number | null
expiry_estimate_status?: string | null
is_expiring?: boolean
}
export const getExpiryEstimateText = (estimate: ExpiryEstimate): string => {
if (estimate.expiry_estimate_status === 'exact') {
return estimate.estimated_final_expires_at
? formatDateTime(estimate.estimated_final_expires_at)
: '-'
}
return estimate.expiry_estimate_status ? '待激活后起算' : '-'
}
export const getExpiryEstimateTooltip = (estimate: ExpiryEstimate): string => {
if (!estimate.is_expiring || estimate.days_until_final_expiry == null) return ''
return `剩余${estimate.days_until_final_expiry}`
}

View File

@@ -7,3 +7,4 @@ export * from './validate'
export * from './calculate'
export * from './voucher'
export * from './apiRateLimit'
export * from './expiryEstimate'

View File

@@ -86,6 +86,14 @@
<ElDescriptionsItem label="所有套餐剩余量">
{{ formatUsageSummaryMb(cardInfo?.total_virtual_remaining_mb) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="预计套餐到期时间">
<span
:class="{ 'expiry-estimate--expiring': cardInfo?.is_expiring }"
:title="getExpiryEstimateTooltip(cardInfo)"
>
{{ getExpiryEstimateText(cardInfo) }}
</span>
</ElDescriptionsItem>
<template v-if="cardInfo?.bound_device_id">
<ElDescriptionsItem label="绑定设备号">
@@ -172,6 +180,14 @@
<ElDescriptionsItem label="所有套餐剩余量">
{{ formatUsageSummaryMb(cardInfo?.total_virtual_remaining_mb) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="预计套餐到期时间">
<span
:class="{ 'expiry-estimate--expiring': cardInfo?.is_expiring }"
:title="getExpiryEstimateTooltip(cardInfo)"
>
{{ getExpiryEstimateText(cardInfo) }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="最后同步时间">{{
formatDateTime(deviceRealtime?.last_sync_time) || '-'
}}</ElDescriptionsItem>
@@ -577,6 +593,7 @@
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
// Props
interface BindingCard {
@@ -624,6 +641,10 @@
gateway_extend?: string
total_virtual_used_mb?: number | null
total_virtual_remaining_mb?: number | null
estimated_final_expires_at?: string | null
days_until_final_expiry?: number | null
expiry_estimate_status?: string
is_expiring?: boolean
last_sync_time?: string | null
last_data_check_at?: string | null
last_real_name_check_at?: string | null
@@ -965,6 +986,11 @@
word-break: break-all;
}
.expiry-estimate--expiring {
font-weight: 500;
color: var(--el-color-danger);
}
.card-header {
display: flex;
gap: 12px;

View File

@@ -140,6 +140,10 @@ export function useAssetInfo() {
gateway_extend: data.gateway_extend ?? '',
total_virtual_used_mb: data.total_virtual_used_mb ?? null,
total_virtual_remaining_mb: data.total_virtual_remaining_mb ?? null,
estimated_final_expires_at: data.estimated_final_expires_at ?? null,
days_until_final_expiry: data.days_until_final_expiry ?? null,
expiry_estimate_status: data.expiry_estimate_status,
is_expiring: data.is_expiring ?? false,
bound_device_id: data.bound_device_id,
bound_device_no: data.bound_device_no || '',
bound_device_name: data.bound_device_name || '',

View File

@@ -35,6 +35,10 @@ export interface AssetInfo {
gateway_extend?: string
total_virtual_used_mb?: number | null
total_virtual_remaining_mb?: number | null
estimated_final_expires_at?: string | null
days_until_final_expiry?: number | null
expiry_estimate_status?: string
is_expiring?: boolean
last_sync_time?: string | null
last_data_check_at?: string | null
last_real_name_check_at?: string | null

View File

@@ -995,6 +995,7 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
import type { PackageSeriesResponse } from '@/types/api'
import type { EnterpriseItem } from '@/types/api/enterprise'
import type {
@@ -1606,6 +1607,7 @@
{ label: '设备名称', prop: 'device_name' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '套餐系列', prop: 'series_name' },
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
{ label: '设备型号', prop: 'device_model' },
{ label: '设备类型', prop: 'device_type' },
{ label: '状态', prop: 'status' },
@@ -1890,6 +1892,20 @@
minWidth: 180,
showOverflowTooltip: true
},
{
prop: 'estimated_final_expires_at',
label: '预计套餐到期时间',
width: 180,
formatter: (row: Device) =>
h(
'span',
{
class: { 'expiry-estimate--expiring': row.is_expiring },
title: getExpiryEstimateTooltip(row)
},
getExpiryEstimateText(row)
)
},
{
prop: 'device_model',
label: '设备型号',
@@ -3201,4 +3217,9 @@
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
.expiry-estimate--expiring {
font-weight: 500;
color: var(--el-color-danger);
}
</style>

View File

@@ -1045,6 +1045,7 @@
import { useUserStore } from '@/store/modules/user'
import { storeToRefs } from 'pinia'
import { formatDateTime } from '@/utils/business/format'
import { getExpiryEstimateText, getExpiryEstimateTooltip } from '@/utils/business/expiryEstimate'
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
import type {
StandaloneIotCard,
@@ -1946,6 +1947,7 @@
{ label: '绑定设备虚拟号', prop: 'device_virtual_no' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '套餐系列', prop: 'series_name' },
{ label: '预计套餐到期时间', prop: 'estimated_final_expires_at' },
{ label: '自然月累计流量(MB)', prop: 'current_month_usage_mb' },
{ label: '本月开始日期', prop: 'current_month_start_date' },
{ label: '上月流量总量(MB)', prop: 'last_month_total_mb' },
@@ -2117,6 +2119,20 @@
showOverflowTooltip: true,
formatter: (row: StandaloneIotCard) => row.series_name || '-'
},
{
prop: 'estimated_final_expires_at',
label: '预计套餐到期时间',
width: 180,
formatter: (row: StandaloneIotCard) =>
h(
'span',
{
class: { 'expiry-estimate--expiring': row.is_expiring },
title: getExpiryEstimateTooltip(row)
},
getExpiryEstimateText(row)
)
},
{
prop: 'status',
label: '状态',
@@ -3027,4 +3043,9 @@
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
.expiry-estimate--expiring {
font-weight: 500;
color: var(--el-color-danger);
}
</style>