feat: 资产详情前代后代换货标识
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m17s
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m17s
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
# Change: 资产详情前代后代换货标识
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
资产详情虽然可展示业务状态和世代编号,但运营无法判断当前资产在换货链中的位置,也无法在具备权限时快速前往关联资产。需要基于后端返回的换货链路数据明确展示前代和后代资产关系。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 扩展资产解析响应,读取 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset`。
|
||||||
|
- 前代资产存在时,在当前资产详情展示“换货新资产”标签与前代资产信息。
|
||||||
|
- 后代资产存在时,在当前资产详情展示“已换出旧资产”标签与后代资产信息。
|
||||||
|
- 中间资产同时展示前代和后代换货标识。
|
||||||
|
- 当关联项 `can_view=true` 时允许跳转资产详情;为 `false` 时仅展示标识文本,不提供跳转操作。
|
||||||
|
- 不由前端根据换货状态、世代编号或其他字段自行推导换货链;关联项为空时不展示对应区域。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected specs: `asset-information`
|
||||||
|
- Related active changes: `add-asset-status-generation-display`(共享资产世代和详情展示区域)
|
||||||
|
- Affected code:
|
||||||
|
- `src/types/api/asset.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`
|
||||||
|
- API contract:
|
||||||
|
- `GET /api/admin/assets/resolve/{identifier}`
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Asset Exchange Generation Trace Display
|
||||||
|
|
||||||
|
The asset information page SHALL display exchange generation relationships exclusively from `exchange_trace.previous_asset` and `exchange_trace.next_asset` returned by `GET /api/admin/assets/resolve/{identifier}`. The frontend MUST NOT infer the exchange chain from asset status, generation, or exchange status.
|
||||||
|
|
||||||
|
#### Scenario: Display previous asset for a replacement asset
|
||||||
|
|
||||||
|
- **GIVEN** 用户正在查看资产信息页
|
||||||
|
- **WHEN** 解析响应包含非空的 `exchange_trace.previous_asset`
|
||||||
|
- **THEN** 页面 MUST 显示“换货新资产”标签
|
||||||
|
- **AND** 页面 MUST 展示前代资产的标识符和换货单号
|
||||||
|
|
||||||
|
#### Scenario: Display next asset for a replaced asset
|
||||||
|
|
||||||
|
- **GIVEN** 用户正在查看资产信息页
|
||||||
|
- **WHEN** 解析响应包含非空的 `exchange_trace.next_asset`
|
||||||
|
- **THEN** 页面 MUST 显示“已换出旧资产”标签
|
||||||
|
- **AND** 页面 MUST 展示后代资产的标识符和换货单号
|
||||||
|
|
||||||
|
#### Scenario: Display both relationships for an intermediate asset
|
||||||
|
|
||||||
|
- **GIVEN** 用户正在查看位于换货链中间的资产
|
||||||
|
- **WHEN** 解析响应同时包含非空的 `previous_asset` 和 `next_asset`
|
||||||
|
- **THEN** 页面 MUST 同时展示“换货新资产”和“已换出旧资产”两个关联区域
|
||||||
|
|
||||||
|
#### Scenario: Hide absent exchange relationship
|
||||||
|
|
||||||
|
- **GIVEN** 用户正在查看资产信息页
|
||||||
|
- **WHEN** `previous_asset` 或 `next_asset` 为 `null`、空值或未返回
|
||||||
|
- **THEN** 页面 MUST NOT 展示对应的换货关联区域
|
||||||
|
- **AND** 前端 MUST NOT 根据资产状态、世代编号、换货单状态或其他字段推导该区域
|
||||||
|
|
||||||
|
### Requirement: Exchange Related Asset Navigation Authorization
|
||||||
|
|
||||||
|
The asset information page SHALL allow navigation to a related exchange asset only when that relationship item has `can_view=true`.
|
||||||
|
|
||||||
|
#### Scenario: Navigate to authorized related asset
|
||||||
|
|
||||||
|
- **GIVEN** 换货关联资产项的 `can_view=true`
|
||||||
|
- **WHEN** 用户点击关联资产标识符
|
||||||
|
- **THEN** 系统 MUST 跳转到该关联资产的资产详情
|
||||||
|
- **AND** 跳转 MUST 使用关联项返回的 `identifier`
|
||||||
|
|
||||||
|
#### Scenario: Render unauthorized related asset as text
|
||||||
|
|
||||||
|
- **GIVEN** 换货关联资产项的 `can_view=false`
|
||||||
|
- **WHEN** 页面渲染换货关联区域
|
||||||
|
- **THEN** 页面 MUST 展示关联资产标识符和换货单号
|
||||||
|
- **AND** 页面 MUST NOT 将关联资产标识符渲染为可点击跳转操作
|
||||||
|
|
||||||
|
### Requirement: Exchange Trace Response Contract
|
||||||
|
|
||||||
|
The frontend asset resolve contract SHALL support optional `exchange_trace.previous_asset` and `exchange_trace.next_asset` items, each with `asset_type`, `asset_id`, `identifier`, `exchange_no`, and `can_view`.
|
||||||
|
|
||||||
|
#### Scenario: Read exchange trace response fields
|
||||||
|
|
||||||
|
- **WHEN** 前端解析 `GET /api/admin/assets/resolve/{identifier}` 响应
|
||||||
|
- **THEN** 前端类型 MUST 支持 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset`
|
||||||
|
- **AND** 每个非空关联项 MUST 支持读取 `asset_type`、`asset_id`、`identifier`、`exchange_no`、`can_view`
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## 1. API Contract
|
||||||
|
|
||||||
|
- [x] 1.1 更新资产解析响应类型,支持 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset`。
|
||||||
|
- [x] 1.2 定义换货关联资产类型,读取 `asset_type`、`asset_id`、`identifier`、`exchange_no`、`can_view`。
|
||||||
|
|
||||||
|
## 2. Asset Detail Exchange Trace UI
|
||||||
|
|
||||||
|
- [x] 2.1 `previous_asset` 存在时展示“换货新资产”标签和前代资产信息。
|
||||||
|
- [x] 2.2 `next_asset` 存在时展示“已换出旧资产”标签和后代资产信息。
|
||||||
|
- [x] 2.3 当前资产同时存在前代和后代时,同时展示两个关联区域。
|
||||||
|
- [x] 2.4 关联资产为空时隐藏对应区域,不通过换货状态或世代编号推导关联链路。
|
||||||
|
|
||||||
|
## 3. Exchange Trace Navigation
|
||||||
|
|
||||||
|
- [x] 3.1 `can_view=true` 时允许点击关联资产标识跳转资产详情。
|
||||||
|
- [x] 3.2 `can_view=false` 时仅展示关联资产标识文本,不渲染跳转操作。
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
- [ ] 4.1 验证旧资产、新资产、中间资产和无关联资产的展示正确。
|
||||||
|
- [ ] 4.2 验证有权限和无权限关联资产的跳转行为正确。
|
||||||
|
- [x] 4.3 运行相关前端校验,并执行 `openspec validate update-asset-exchange-generation-links --strict`。
|
||||||
@@ -55,6 +55,21 @@ export interface AssetPollingStatus {
|
|||||||
next_poll_at?: string | null // 下次轮询时间
|
next_poll_at?: string | null // 下次轮询时间
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 换货关联资产
|
||||||
|
export interface AssetExchangeTraceAsset {
|
||||||
|
asset_type: AssetType
|
||||||
|
asset_id: number | null
|
||||||
|
identifier: string
|
||||||
|
exchange_no: string
|
||||||
|
can_view: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 换货前后代链路
|
||||||
|
export interface AssetExchangeTrace {
|
||||||
|
previous_asset?: AssetExchangeTraceAsset | null
|
||||||
|
next_asset?: AssetExchangeTraceAsset | null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 资产解析/详情响应
|
* 资产解析/详情响应
|
||||||
* 对应接口:GET /api/admin/assets/resolve/:identifier
|
* 对应接口:GET /api/admin/assets/resolve/:identifier
|
||||||
@@ -90,6 +105,7 @@ export interface AssetResolveResponse {
|
|||||||
accumulated_recharge?: number // 累计充值金额(分)
|
accumulated_recharge?: number // 累计充值金额(分)
|
||||||
first_commission_paid?: boolean // 一次性佣金是否已发放
|
first_commission_paid?: boolean // 一次性佣金是否已发放
|
||||||
polling?: AssetPollingStatus | null // 资产轮询状态
|
polling?: AssetPollingStatus | null // 资产轮询状态
|
||||||
|
exchange_trace?: AssetExchangeTrace | null // 换货前后代链路
|
||||||
|
|
||||||
// ===== 卡专属字段 (asset_type === 'card' 时) =====
|
// ===== 卡专属字段 (asset_type === 'card' 时) =====
|
||||||
iccid?: string // ICCID
|
iccid?: string // ICCID
|
||||||
|
|||||||
@@ -214,6 +214,46 @@
|
|||||||
</ElDescriptionsItem>
|
</ElDescriptionsItem>
|
||||||
</ElDescriptions>
|
</ElDescriptions>
|
||||||
|
|
||||||
|
<template v-if="previousExchangeAsset || nextExchangeAsset">
|
||||||
|
<ElDivider content-position="left">换货链路</ElDivider>
|
||||||
|
<ElDescriptions :column="descriptionsColumn" border>
|
||||||
|
<ElDescriptionsItem v-if="previousExchangeAsset" label="前代资产">
|
||||||
|
<div class="exchange-asset">
|
||||||
|
<ElTag type="success" size="small">换货新资产</ElTag>
|
||||||
|
<ElButton
|
||||||
|
v-if="previousExchangeAsset.can_view"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleNavigateToExchangeAsset(previousExchangeAsset)"
|
||||||
|
>
|
||||||
|
{{ previousExchangeAsset.identifier }}
|
||||||
|
</ElButton>
|
||||||
|
<span v-else>{{ previousExchangeAsset.identifier }}</span>
|
||||||
|
<span v-if="previousExchangeAsset.exchange_no" class="exchange-no">
|
||||||
|
换货单:{{ previousExchangeAsset.exchange_no }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem v-if="nextExchangeAsset" label="后代资产">
|
||||||
|
<div class="exchange-asset">
|
||||||
|
<ElTag type="warning" size="small">已换出旧资产</ElTag>
|
||||||
|
<ElButton
|
||||||
|
v-if="nextExchangeAsset.can_view"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleNavigateToExchangeAsset(nextExchangeAsset)"
|
||||||
|
>
|
||||||
|
{{ nextExchangeAsset.identifier }}
|
||||||
|
</ElButton>
|
||||||
|
<span v-else>{{ nextExchangeAsset.identifier }}</span>
|
||||||
|
<span v-if="nextExchangeAsset.exchange_no" class="exchange-no">
|
||||||
|
换货单:{{ nextExchangeAsset.exchange_no }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 设备绑定卡列表 -->
|
<!-- 设备绑定卡列表 -->
|
||||||
<template v-if="cardInfo?.asset_type === 'device'">
|
<template v-if="cardInfo?.asset_type === 'device'">
|
||||||
<ElDivider content-position="left">绑定卡列表</ElDivider>
|
<ElDivider content-position="left">绑定卡列表</ElDivider>
|
||||||
@@ -532,7 +572,7 @@
|
|||||||
ElMessage
|
ElMessage
|
||||||
} from 'element-plus'
|
} from 'element-plus'
|
||||||
import { CopyDocument } from '@element-plus/icons-vue'
|
import { CopyDocument } from '@element-plus/icons-vue'
|
||||||
import type { AssetPollingStatus } from '@/types/api'
|
import type { AssetExchangeTrace, AssetExchangeTraceAsset, AssetPollingStatus } from '@/types/api'
|
||||||
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
@@ -589,6 +629,7 @@
|
|||||||
last_real_name_check_at?: string | null
|
last_real_name_check_at?: string | null
|
||||||
last_card_status_check_at?: string | null
|
last_card_status_check_at?: string | null
|
||||||
polling?: AssetPollingStatus | null
|
polling?: AssetPollingStatus | null
|
||||||
|
exchange_trace?: AssetExchangeTrace | null
|
||||||
bound_device_id?: number
|
bound_device_id?: number
|
||||||
bound_device_no?: string
|
bound_device_no?: string
|
||||||
bound_device_name?: string
|
bound_device_name?: string
|
||||||
@@ -759,6 +800,11 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const previousExchangeAsset = computed(
|
||||||
|
() => props.cardInfo.exchange_trace?.previous_asset ?? null
|
||||||
|
)
|
||||||
|
const nextExchangeAsset = computed(() => props.cardInfo.exchange_trace?.next_asset ?? null)
|
||||||
|
|
||||||
// 设备详情里的实名状态和实名时间从选中的绑定卡取值:
|
// 设备详情里的实名状态和实名时间从选中的绑定卡取值:
|
||||||
// 只有一张卡时直接取该卡,多张卡时仅取 is_current=true 的卡。
|
// 只有一张卡时直接取该卡,多张卡时仅取 is_current=true 的卡。
|
||||||
const selectedDeviceStatusCard = computed(() => {
|
const selectedDeviceStatusCard = computed(() => {
|
||||||
@@ -797,6 +843,17 @@
|
|||||||
emit('navigateToCard', iccid)
|
emit('navigateToCard', iccid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleNavigateToExchangeAsset = (asset: AssetExchangeTraceAsset) => {
|
||||||
|
if (!asset.can_view) return
|
||||||
|
|
||||||
|
if (asset.asset_type === 'card') {
|
||||||
|
emit('navigateToCard', asset.identifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('navigateToDevice', asset.identifier)
|
||||||
|
}
|
||||||
|
|
||||||
// 复制ICCID
|
// 复制ICCID
|
||||||
const handleCopyICCID = async (iccid: string) => {
|
const handleCopyICCID = async (iccid: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -956,6 +1013,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exchange-asset {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.exchange-no {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.card-header {
|
.card-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export function useAssetInfo() {
|
|||||||
accumulated_recharge: data.accumulated_recharge,
|
accumulated_recharge: data.accumulated_recharge,
|
||||||
first_commission_paid: data.first_commission_paid,
|
first_commission_paid: data.first_commission_paid,
|
||||||
polling: data.polling ?? null,
|
polling: data.polling ?? null,
|
||||||
|
exchange_trace: data.exchange_trace ?? null,
|
||||||
|
|
||||||
// 卡专属字段 (asset_type === 'card')
|
// 卡专属字段 (asset_type === 'card')
|
||||||
iccid: data.iccid || '',
|
iccid: data.iccid || '',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 资产信息页面类型定义
|
* 资产信息页面类型定义
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AssetPollingStatus } from '@/types/api'
|
import type { AssetExchangeTrace, AssetPollingStatus } from '@/types/api'
|
||||||
|
|
||||||
// 资产类型枚举
|
// 资产类型枚举
|
||||||
export type AssetType = 'card' | 'device'
|
export type AssetType = 'card' | 'device'
|
||||||
@@ -40,6 +40,7 @@ export interface AssetInfo {
|
|||||||
last_real_name_check_at?: string | null
|
last_real_name_check_at?: string | null
|
||||||
last_card_status_check_at?: string | null
|
last_card_status_check_at?: string | null
|
||||||
polling?: AssetPollingStatus | null
|
polling?: AssetPollingStatus | null
|
||||||
|
exchange_trace?: AssetExchangeTrace | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设备绑定卡信息
|
// 设备绑定卡信息
|
||||||
|
|||||||
Reference in New Issue
Block a user