diff --git a/openspec/changes/update-asset-exchange-generation-links/proposal.md b/openspec/changes/update-asset-exchange-generation-links/proposal.md new file mode 100644 index 0000000..28f52e3 --- /dev/null +++ b/openspec/changes/update-asset-exchange-generation-links/proposal.md @@ -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}` diff --git a/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md b/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md new file mode 100644 index 0000000..d230a1c --- /dev/null +++ b/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md @@ -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` diff --git a/openspec/changes/update-asset-exchange-generation-links/tasks.md b/openspec/changes/update-asset-exchange-generation-links/tasks.md new file mode 100644 index 0000000..4517e08 --- /dev/null +++ b/openspec/changes/update-asset-exchange-generation-links/tasks.md @@ -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`。 diff --git a/src/types/api/asset.ts b/src/types/api/asset.ts index fbf800a..7bf1234 100644 --- a/src/types/api/asset.ts +++ b/src/types/api/asset.ts @@ -55,6 +55,21 @@ export interface AssetPollingStatus { 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 @@ -90,6 +105,7 @@ export interface AssetResolveResponse { accumulated_recharge?: number // 累计充值金额(分) first_commission_paid?: boolean // 一次性佣金是否已发放 polling?: AssetPollingStatus | null // 资产轮询状态 + exchange_trace?: AssetExchangeTrace | null // 换货前后代链路 // ===== 卡专属字段 (asset_type === 'card' 时) ===== iccid?: string // ICCID diff --git a/src/views/asset-management/asset-information/components/BasicInfoCard.vue b/src/views/asset-management/asset-information/components/BasicInfoCard.vue index 9b0141d..7672e2d 100644 --- a/src/views/asset-management/asset-information/components/BasicInfoCard.vue +++ b/src/views/asset-management/asset-information/components/BasicInfoCard.vue @@ -214,6 +214,46 @@ + + 换货链路 + + + + 换货新资产 + + {{ previousExchangeAsset.identifier }} + + {{ previousExchangeAsset.identifier }} + + 换货单:{{ previousExchangeAsset.exchange_no }} + + + + + + 已换出旧资产 + + {{ nextExchangeAsset.identifier }} + + {{ nextExchangeAsset.identifier }} + + 换货单:{{ nextExchangeAsset.exchange_no }} + + + + + + 绑定卡列表 @@ -532,7 +572,7 @@ ElMessage } from 'element-plus' 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 { useAuth } from '@/composables/useAuth' import { useUserStore } from '@/store/modules/user' @@ -589,6 +629,7 @@ last_real_name_check_at?: string | null last_card_status_check_at?: string | null polling?: AssetPollingStatus | null + exchange_trace?: AssetExchangeTrace | null bound_device_id?: number bound_device_no?: 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 的卡。 const selectedDeviceStatusCard = computed(() => { @@ -797,6 +843,17 @@ 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 const handleCopyICCID = async (iccid: string) => { 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) { .card-header { flex-direction: column; diff --git a/src/views/asset-management/asset-information/composables/useAssetInfo.ts b/src/views/asset-management/asset-information/composables/useAssetInfo.ts index 46c26fe..b1a3277 100644 --- a/src/views/asset-management/asset-information/composables/useAssetInfo.ts +++ b/src/views/asset-management/asset-information/composables/useAssetInfo.ts @@ -124,6 +124,7 @@ export function useAssetInfo() { accumulated_recharge: data.accumulated_recharge, first_commission_paid: data.first_commission_paid, polling: data.polling ?? null, + exchange_trace: data.exchange_trace ?? null, // 卡专属字段 (asset_type === 'card') iccid: data.iccid || '', diff --git a/src/views/asset-management/asset-information/types.ts b/src/views/asset-management/asset-information/types.ts index 5969c5a..633494b 100644 --- a/src/views/asset-management/asset-information/types.ts +++ b/src/views/asset-management/asset-information/types.ts @@ -2,7 +2,7 @@ * 资产信息页面类型定义 */ -import type { AssetPollingStatus } from '@/types/api' +import type { AssetExchangeTrace, AssetPollingStatus } from '@/types/api' // 资产类型枚举 export type AssetType = 'card' | 'device' @@ -40,6 +40,7 @@ export interface AssetInfo { last_real_name_check_at?: string | null last_card_status_check_at?: string | null polling?: AssetPollingStatus | null + exchange_trace?: AssetExchangeTrace | null } // 设备绑定卡信息