Compare commits
2 Commits
8b6ce505b5
...
010b941d0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
010b941d0e | ||
|
|
a25ea5954c |
@@ -19,24 +19,6 @@ export const normalizeAssetInfo = (data = {}) => ({
|
||||
: data.is_expiring === true || data.is_expiring === 1
|
||||
});
|
||||
|
||||
const normalizePackageHistoryNode = (node = {}) => ({
|
||||
...node,
|
||||
children: Array.isArray(node.children)
|
||||
? node.children.map(normalizePackageHistoryNode)
|
||||
: [],
|
||||
expand_by_default: node.expand_by_default === true || node.expand_by_default === 1
|
||||
});
|
||||
|
||||
export const normalizeAssetPackageHistory = (data = {}) => ({
|
||||
...data,
|
||||
items: Array.isArray(data.items)
|
||||
? data.items.map(normalizePackageHistoryNode)
|
||||
: [],
|
||||
page: Number(data.page),
|
||||
size: Number(data.size),
|
||||
total: Number(data.total)
|
||||
});
|
||||
|
||||
export const isAssetRealNameCompleted = (assetInfo = {}) => {
|
||||
if (assetInfo.asset_type === 'device') {
|
||||
return (assetInfo.cards || []).some((card) => Number(card?.real_name_status) === 1);
|
||||
@@ -68,7 +50,7 @@ export const assetApi = {
|
||||
url: '/api/c/v1/asset/package-history',
|
||||
method: 'GET',
|
||||
data: { identifier, page, page_size, ...params }
|
||||
}).then(normalizeAssetPackageHistory);
|
||||
});
|
||||
},
|
||||
|
||||
getPackages(identifier, packageType = '') {
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
<template>
|
||||
<view class="card package-node" :class="{ 'package-node-child': depth > 0, 'relationship-exception': isMasterMissing }">
|
||||
<view class="package-header flex-row-sb">
|
||||
<view class="package-title-wrap">
|
||||
<view class="package-name">{{ node.package_name }}</view>
|
||||
<text v-if="packageTypeName" class="package-type">{{ packageTypeName }}</text>
|
||||
</view>
|
||||
<view class="tag-apple" :class="getStatusClass(node.status)">{{ node.status_name }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isMasterMissing" class="relationship-status">
|
||||
{{ node.relationship_status_name || node.relationship_status }}
|
||||
</view>
|
||||
<view v-if="hasMasterUsageId" class="relationship-meta">
|
||||
关联主套餐使用记录:{{ node.master_usage_id }}
|
||||
</view>
|
||||
|
||||
<view class="package-info">
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">购买时间</view>
|
||||
<view class="info-value">{{ formatDate(node.created_at) }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">激活时间</view>
|
||||
<view class="info-value">{{ formatDate(node.activated_at) }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">到期时间</view>
|
||||
<view class="info-value">{{ formatDate(node.expires_at) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="divider"></view>
|
||||
|
||||
<view class="flow-info">
|
||||
<view class="flow-title">流量信息</view>
|
||||
<view class="flow-stats">
|
||||
<view class="flow-item">
|
||||
<view class="flow-label">已使用</view>
|
||||
<view class="flow-value">{{ getUsedFlow(node) }}</view>
|
||||
</view>
|
||||
<view class="flow-item">
|
||||
<view class="flow-label">总流量</view>
|
||||
<view class="flow-value">{{ getTotalFlow(node) }}</view>
|
||||
</view>
|
||||
<view class="flow-item">
|
||||
<view class="flow-label">剩余</view>
|
||||
<view class="flow-value">{{ getRemainFlow(node) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="progress-section">
|
||||
<view class="progress-apple">
|
||||
<view class="progress-fill" :style="{ width: getUsagePercent(node) }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="hasChildren"
|
||||
class="expand-control"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-expanded="String(expanded)"
|
||||
@tap.stop="toggleExpanded"
|
||||
>
|
||||
<text>{{ expanded ? '收起关联加油包' : '展开关联加油包' }}</text>
|
||||
<text class="expand-count">({{ children.length }})</text>
|
||||
</view>
|
||||
|
||||
<view v-if="expanded && hasChildren" class="child-list">
|
||||
<PackageHistoryNode
|
||||
v-for="(child, index) in children"
|
||||
:key="getNodeKey(child, index)"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
const children = computed(() => Array.isArray(props.node.children) ? props.node.children : []);
|
||||
const hasChildren = computed(() => children.value.length > 0);
|
||||
const isMasterMissing = computed(() => props.node.relationship_status === 'master_missing');
|
||||
const hasMasterUsageId = computed(() => (
|
||||
props.node.master_usage_id !== null && props.node.master_usage_id !== undefined
|
||||
));
|
||||
const packageTypeName = computed(() => ({
|
||||
formal: '正式套餐',
|
||||
addon: '加油包'
|
||||
}[props.node.package_type] || props.node.package_type || ''));
|
||||
const expanded = ref(props.node.expand_by_default === true);
|
||||
|
||||
const getStatusClass = (status) => ({
|
||||
0: 'tag-warning',
|
||||
1: 'tag-success',
|
||||
2: 'tag-primary',
|
||||
3: 'tag-secondary',
|
||||
4: 'tag-danger'
|
||||
}[Number(status)] || '');
|
||||
|
||||
const toNumber = (value) => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
};
|
||||
|
||||
const formatMB = (value) => {
|
||||
const mb = toNumber(value);
|
||||
if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const getUsedAmount = (item) => item.enable_virtual_data
|
||||
? toNumber(item.virtual_used_mb)
|
||||
: toNumber(item.real_used_mb);
|
||||
|
||||
const getUsedFlow = (item) => formatMB(getUsedAmount(item));
|
||||
const getTotalFlow = (item) => formatMB(item.real_total_mb);
|
||||
const getRemainFlow = (item) => formatMB(Math.max(toNumber(item.real_total_mb) - getUsedAmount(item), 0));
|
||||
|
||||
const getUsagePercent = (item) => {
|
||||
const total = toNumber(item.real_total_mb);
|
||||
if (!total) return '0%';
|
||||
return `${Math.min((getUsedAmount(item) / total) * 100, 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const formatDate = (value) => value ? String(value).replace('T', ' ').slice(0, 19) : '-';
|
||||
const getNodeKey = (item, index) => item.package_usage_id ?? `${props.depth}-${index}`;
|
||||
const toggleExpanded = () => {
|
||||
expanded.value = !expanded.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.package-node {
|
||||
margin-bottom: var(--space-md);
|
||||
|
||||
&.package-node-child {
|
||||
margin: var(--space-sm) 0 0;
|
||||
border-left: 6rpx solid var(--primary-light);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.relationship-exception {
|
||||
border-left: 6rpx solid var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.package-header {
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.package-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.package-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.package-type,
|
||||
.relationship-meta {
|
||||
font-size: 22rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.relationship-status {
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: 12rpx 16rpx;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--danger-light, #fff2f0);
|
||||
color: var(--danger);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.relationship-meta {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.package-info .info-row {
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
margin: var(--space-md) 0;
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
margin-bottom: var(--space-sm);
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.flow-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flow-label {
|
||||
margin-bottom: 4rpx;
|
||||
font-size: 20rpx;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.flow-value {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-apple {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 8rpx;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-small);
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||
}
|
||||
|
||||
.expand-control {
|
||||
display: inline-flex;
|
||||
margin-top: var(--space-md);
|
||||
padding: 12rpx 16rpx;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.expand-count {
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
|
||||
.child-list {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.tag-secondary {
|
||||
background: var(--gray-200) !important;
|
||||
color: var(--gray-600) !important;
|
||||
}
|
||||
</style>
|
||||
97
docs/API.md
97
docs/API.md
@@ -376,102 +376,7 @@ data:
|
||||
- wallet_balance:钱包余额(分)
|
||||
|
||||
|
||||
## 3.2 资产套餐历史(当前契约)
|
||||
|
||||
URL:
|
||||
GET /api/c/v1/asset/package-history
|
||||
|
||||
更新说明(2026-09-08):
|
||||
* 套餐历史已改为主套餐—加油包关系组。`items` 只包含顶层关系组;关联加油包位于所属主项的 `children` 中,子项绝不会跨页返回。
|
||||
* `total` 为筛选后的顶层关系组数量,分页壳使用 `page`、`size`、`total`;其中 `size` 表示每页顶层关系组数量。
|
||||
* 传入 `status` 和 `package_type` 时,两项必须由同一条使用记录联合命中;主项或任一子项命中时,接口均返回完整关系组,客户端不得对组内节点二次过滤。
|
||||
* 加油包通过 `master_usage_id` 标识关联主套餐使用记录。关联主套餐物理缺失时,节点以独立顶层项返回并设置 `relationship_status: "master_missing"`;关联主套餐存在但读取或展示失败时,接口按统一错误响应返回失败。
|
||||
|
||||
Query 参数:
|
||||
- identifier(string,必填)- 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN),长度 1–50
|
||||
- package_type(string,可选)- 套餐类型(formal:正式套餐;addon:加油包)
|
||||
- status(integer,可选)- 套餐状态(0:待生效;1:生效中;2:已用完;3:已过期;4:已失效)
|
||||
- page(integer,必填)- 页码,最小为 1
|
||||
- page_size(integer,必填)- 每页顶层关系组数量,范围 1–100
|
||||
|
||||
成功响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"timestamp": "2026-09-08T00:00:00Z",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"activated_at": "2026-09-01T00:00:00Z",
|
||||
"children": [
|
||||
{
|
||||
"children": [],
|
||||
"expand_by_default": false,
|
||||
"master_usage_id": 5001,
|
||||
"package_id": 1002,
|
||||
"package_name": "5GB加油包",
|
||||
"package_type": "addon",
|
||||
"package_usage_id": 5002,
|
||||
"status": 1,
|
||||
"status_name": "生效中"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-09-01T00:00:00Z",
|
||||
"enable_virtual_data": false,
|
||||
"expand_by_default": true,
|
||||
"expires_at": "2026-09-30T23:59:59Z",
|
||||
"master_usage_id": null,
|
||||
"order_id": 0,
|
||||
"package_id": 1001,
|
||||
"package_name": "10GB月套餐",
|
||||
"package_type": "formal",
|
||||
"package_usage_id": 5001,
|
||||
"priority": 1,
|
||||
"real_total_mb": 10240,
|
||||
"real_used_mb": 2048,
|
||||
"reduction_pct": 0,
|
||||
"status": 1,
|
||||
"status_name": "生效中",
|
||||
"usage_type": "single_card",
|
||||
"virtual_total_mb": 10240,
|
||||
"virtual_used_mb": 2048
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"size": 10,
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`data.items[]` 为递归节点,所有节点均可包含以下字段:
|
||||
- activated_at(date-time,可空):激活时间
|
||||
- children(array):关联加油包或下级关联节点
|
||||
- created_at(date-time):购买创建时间
|
||||
- enable_virtual_data(boolean):是否启用虚流量
|
||||
- expand_by_default(boolean):是否默认展开 `children`
|
||||
- expires_at(date-time,可空):到期时间
|
||||
- master_usage_id(integer,可空):关联主套餐使用记录 ID;普通主项为 `null`
|
||||
- order_id(integer):历史兼容字段,始终输出零值 `0`,不填充真实订单 ID
|
||||
- package_id、package_name、package_type、package_usage_id:套餐及使用记录标识;`package_type` 为 `formal` 或 `addon`
|
||||
- priority:优先级
|
||||
- real_total_mb、real_used_mb:真实总量和真实已用量(MB)
|
||||
- virtual_total_mb、virtual_used_mb:业务停机阈值和展示已用量(MB)
|
||||
- reduction_pct:展示增幅比例
|
||||
- status、status_name:套餐状态及名称
|
||||
- usage_type:使用类型(single_card/device)
|
||||
- relationship_status、relationship_status_name:关系异常状态及名称;仅在主套餐物理缺失时返回 `master_missing`
|
||||
|
||||
错误响应:
|
||||
- HTTP 400:请求参数错误
|
||||
- HTTP 401:未认证或认证已过期
|
||||
- HTTP 403:无权访问
|
||||
- HTTP 500:服务器内部错误,包括关联主套餐存在但读取或展示失败
|
||||
|
||||
以上失败场景均使用统一 `ErrorResponse`:`code`、`msg`、`timestamp`,可选 `data`。H5 通过共享请求层展示后端返回的 `msg`;401 同时清除登录状态并跳转登录页。
|
||||
|
||||
## 3.2 资产套餐历史(旧版,已废弃)
|
||||
## 3.2 资产套餐历史
|
||||
|
||||
URL:
|
||||
GET /api/c/v1/asset/package-history
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Change: Retry 19-digit ICCID login with a Luhn check digit
|
||||
|
||||
## Why
|
||||
|
||||
Some IoT cards are entered or scanned as a 19-digit ICCID prefix. The asset-verification endpoint requires the 20-digit ICCID, so the first verification cannot return an asset token even though the identifier can be deterministically completed.
|
||||
|
||||
## What Changes
|
||||
|
||||
- When a login identifier is exactly 19 numeric characters and starts with `89`, call `/api/c/v1/auth/verify-asset` with the entered value first.
|
||||
- If that first request fails or does not return `asset_token`, compute the twentieth digit with the ISO/IEC 7812 Luhn (mod-10) check-digit algorithm and retry verification once using the completed ICCID.
|
||||
- Keep the first failure entirely silent to the customer. Only the retry failure is handled by the existing login error flow.
|
||||
- Persist and use the successful 20-digit ICCID for the authenticated session without mutating the text in the input field during the silent retry.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected capability: `iccid-login-check-digit-retry` (new)
|
||||
- Affected code: `pages/login/login.vue`
|
||||
- Affected API: `POST /api/c/v1/auth/verify-asset`
|
||||
- No backend API, request payload shape, or shared error handling is changed.
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Silent 19-digit ICCID verification retry
|
||||
|
||||
When an asset login identifier matches `^89\\d{17}$`, the H5 client SHALL first verify the entered 19-digit value. If that attempt fails or does not return a non-empty `asset_token`, it SHALL calculate the ISO/IEC 7812 Luhn mod-10 check digit, append it as the twentieth digit, and verify the completed ICCID exactly once.
|
||||
|
||||
#### Scenario: Completed ICCID succeeds
|
||||
|
||||
- **WHEN** a customer enters a 19-digit numeric identifier beginning with `89`
|
||||
- **AND** the initial verification does not provide an asset token
|
||||
- **AND** verification of the Luhn-completed 20-digit ICCID returns an asset token
|
||||
- **THEN** the customer continues through login without seeing an error from the first verification
|
||||
- **AND** the authenticated session uses the 20-digit ICCID
|
||||
|
||||
#### Scenario: Initial verification succeeds
|
||||
|
||||
- **WHEN** a customer enters a 19-digit numeric identifier beginning with `89`
|
||||
- **AND** initial verification returns an asset token
|
||||
- **THEN** the client SHALL not calculate or submit a second identifier
|
||||
|
||||
#### Scenario: Completed ICCID fails
|
||||
|
||||
- **WHEN** the Luhn-completed retry does not return an asset token
|
||||
- **THEN** the client SHALL invoke the existing login failure presentation once using the retry failure
|
||||
|
||||
#### Scenario: Identifier is not a 19-digit ICCID prefix
|
||||
|
||||
- **WHEN** an identifier does not match `^89\\d{17}$`
|
||||
- **AND** verification fails or does not return an asset token
|
||||
- **THEN** the client SHALL not issue a retry
|
||||
- **AND** SHALL retain the existing login failure presentation
|
||||
10
openspec/changes/add-iccid-login-check-digit-retry/tasks.md
Normal file
10
openspec/changes/add-iccid-login-check-digit-retry/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 1. Login fallback
|
||||
|
||||
- [x] 1.1 Add a pure ISO/IEC 7812 Luhn mod-10 check-digit helper for a 19-digit ICCID prefix.
|
||||
- [x] 1.2 Retry asset verification once and silently only when the initial identifier matches `^89\\d{17}$` and the first verification fails or lacks `asset_token`.
|
||||
- [x] 1.3 Persist the completed ICCID only after the retry succeeds; retain the existing visible error path for all final failures and non-matching identifiers.
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 Verify known Luhn vectors plus first-attempt success, fallback success, fallback failure, and non-ICCID failure behavior.
|
||||
- [x] 2.2 Run the H5 production build.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Change: Update asset package history hierarchy
|
||||
|
||||
## Why
|
||||
|
||||
资产套餐历史接口已从平铺使用记录升级为主套餐与关联加油包的关系组。当前 H5 页面把每一条记录当作独立卡片,并以返回条数推断是否还有下一页,无法正确展示层级、默认展开状态和按关系组分页的结果。
|
||||
|
||||
## What Changes
|
||||
|
||||
- Consume `children` recursively and render a main-package card with its associated addon-package cards.
|
||||
- Honor `expand_by_default` for the initial expanded state and allow users to expand or collapse a group without losing any child records.
|
||||
- Preserve and render relationship metadata needed by the contract, including `master_usage_id` for addons and `relationship_status` / `relationship_status_name` for a `master_missing` standalone item.
|
||||
- Use the response pagination shell (`page`, `size`, `total`) as top-level relationship-group pagination; never infer completion from the number of returned nodes.
|
||||
- Keep the complete returned group when the API filter matches either its main package or one of its addons. The client will pass filters through unchanged and will not re-filter individual nodes.
|
||||
- Update the local API documentation to match the hierarchical response fields and pagination shell.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected capability: `asset-package-history-display` (new)
|
||||
- Affected code: `pages/asset-package-history/asset-package-history.vue`, `api/modules/asset.js`, `docs/API.md`
|
||||
- Affected API: `GET /api/c/v1/asset/package-history`
|
||||
- No backend filtering, relationship recovery, or error-code mapping is implemented in this H5 repository; the client continues to surface the API's unified error response through the shared request layer.
|
||||
@@ -1,56 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Hierarchical package history display
|
||||
|
||||
The H5 client SHALL render each item returned by `GET /api/c/v1/asset/package-history` as a top-level relationship group and SHALL render its recursively supplied `children` as associated addon-package entries without flattening, omitting, or moving them to another page.
|
||||
|
||||
#### Scenario: Main package with addon children
|
||||
|
||||
- **WHEN** a returned top-level package has one or more `children`
|
||||
- **THEN** the client displays the main package and all supplied children within the same relationship group
|
||||
- **AND** each node retains its own package, usage, status, and traffic-display fields
|
||||
|
||||
#### Scenario: Relationship exception item
|
||||
|
||||
- **WHEN** a top-level item has `relationship_status` equal to `master_missing`
|
||||
- **THEN** the client displays it as a standalone relationship-exception item
|
||||
- **AND** displays `relationship_status_name` when supplied
|
||||
|
||||
### Requirement: Group expansion behavior
|
||||
|
||||
The H5 client SHALL initialize a package group's visibility from `expand_by_default` and SHALL allow the customer to toggle groups that have children. Addon nodes SHALL retain their supplied `master_usage_id` metadata.
|
||||
|
||||
#### Scenario: Default-expanded relationship group
|
||||
|
||||
- **WHEN** a top-level group is returned with `expand_by_default` equal to true
|
||||
- **THEN** its children are visible on initial render
|
||||
|
||||
#### Scenario: Customer expands a collapsed group
|
||||
|
||||
- **WHEN** a top-level group with children is returned with `expand_by_default` equal to false and the customer activates its expand control
|
||||
- **THEN** every returned child in that group becomes visible
|
||||
- **AND** no additional API request is made to retrieve those children
|
||||
|
||||
### Requirement: Relationship-group pagination
|
||||
|
||||
The H5 client SHALL treat `page`, `size`, and `total` from the package-history response as pagination metadata for top-level relationship groups. It SHALL not use a child count or the number of returned nodes to decide whether more pages are available.
|
||||
|
||||
#### Scenario: Last page has a full number of groups
|
||||
|
||||
- **WHEN** the response contains `size` top-level groups and `page * size` is greater than or equal to `total`
|
||||
- **THEN** the client indicates that no more relationship groups are available
|
||||
|
||||
#### Scenario: Filtered group response
|
||||
|
||||
- **WHEN** the API returns a complete relationship group because either its main node or an addon node jointly matches the supplied `status` and `package_type` filter
|
||||
- **THEN** the client displays the complete returned group without applying node-level filtering
|
||||
|
||||
### Requirement: Package history failure handling
|
||||
|
||||
The H5 client SHALL rely on the shared request layer for non-success package-history responses, preserving the API-provided unified error message and authentication handling.
|
||||
|
||||
#### Scenario: Related master cannot be read
|
||||
|
||||
- **WHEN** the API returns a non-success response because a related master record exists but cannot be displayed
|
||||
- **THEN** the client does not synthesize a relationship item
|
||||
- **AND** the shared request layer displays the unified API error response
|
||||
@@ -1,16 +0,0 @@
|
||||
## 1. API contract and data handling
|
||||
|
||||
- [x] 1.1 Document the hierarchical node fields, relationship exceptions, and top-level pagination shell for package history.
|
||||
- [x] 1.2 Add a client-side response normalizer that preserves recursively returned `children` and all relationship metadata.
|
||||
- [x] 1.3 Use `total` and returned top-level groups to drive pagination, so children never consume page capacity or cross pages.
|
||||
|
||||
## 2. Package history UI
|
||||
|
||||
- [x] 2.1 Render returned top-level relationship groups and their addon children with the existing package metrics and status styling.
|
||||
- [x] 2.2 Initialize groups from `expand_by_default` and add an accessible expand/collapse control for groups with children.
|
||||
- [x] 2.3 Render `master_missing` as a standalone relationship-exception item and preserve normal shared-request error handling for all other API failures.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Verify nested, collapsed, expanded, filtered-group, `master_missing`, and multi-page response fixtures.
|
||||
- [x] 3.2 Run the H5 production build and inspect the changed files for contract consistency.
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<!-- 空状态提示 -->
|
||||
<view v-if="!historyLoaded || (loading && packageList.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
@@ -9,81 +10,169 @@
|
||||
<view class="empty-desc">当前账号下暂无套餐历史信息</view>
|
||||
</view>
|
||||
|
||||
<view v-else>
|
||||
<PackageHistoryNode
|
||||
v-for="(item, index) in packageList"
|
||||
:key="getGroupKey(item, index)"
|
||||
:node="item"
|
||||
/>
|
||||
<!-- 套餐列表 -->
|
||||
<view v-else class="card package-card" v-for="(item, index) in packageList" :key="index">
|
||||
<view class="package-header flex-row-sb">
|
||||
<view class="package-name">{{ item.package_name }}</view>
|
||||
<view class="tag-apple" :class="getStatusClass(item.status)">{{ item.status_name }}</view>
|
||||
</view>
|
||||
|
||||
<view class="package-info">
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">激活时间</view>
|
||||
<view class="info-value">{{ item.activated_at || '-' }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">到期时间</view>
|
||||
<view class="info-value">{{ item.expires_at || '-' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="divider"></view>
|
||||
|
||||
<view class="flow-info">
|
||||
<view class="flow-title">流量信息</view>
|
||||
<view class="flow-stats">
|
||||
<view class="flow-item">
|
||||
<view class="flow-label">已使用</view>
|
||||
<view class="flow-value">{{ getUsedFlow(item) }}</view>
|
||||
</view>
|
||||
<view class="flow-item">
|
||||
<view class="flow-label">总流量</view>
|
||||
<view class="flow-value">{{ getTotalFlow(item) }}</view>
|
||||
</view>
|
||||
<view class="flow-item">
|
||||
<view class="flow-label">剩余</view>
|
||||
<view class="flow-value">{{ getRemainFlow(item) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="progress-section">
|
||||
<view class="progress-apple">
|
||||
<view class="progress-fill" :style="{width: getUsagePercent(item) + '%'}"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="packageList.length > 0" class="load-more">
|
||||
<!-- 加载更多 -->
|
||||
<view class="load-more" v-if="packageList.length > 0">
|
||||
<text v-if="loading">加载中...</text>
|
||||
<text v-else-if="noMore">没有更多套餐</text>
|
||||
<text v-else role="button" tabindex="0" @tap="loadMore">点击加载更多</text>
|
||||
<text v-else-if="noMore">没有更多了</text>
|
||||
<text v-else @tap="loadMore">点击加载更多</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { assetApi } from '@/api/index.js';
|
||||
import PackageHistoryNode from '@/components/PackageHistoryNode.vue';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const packageList = ref([]);
|
||||
const loading = ref(false);
|
||||
const historyLoaded = ref(false);
|
||||
const noMore = ref(false);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
|
||||
let packageList = reactive([]);
|
||||
let loading = ref(false);
|
||||
let historyLoaded = ref(false);
|
||||
let noMore = ref(false);
|
||||
let page = ref(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const getGroupKey = (item, index) => item.package_usage_id ?? `group-${index}`;
|
||||
const getStatusClass = (status) => {
|
||||
const classMap = {
|
||||
'0': 'tag-warning',
|
||||
'1': 'tag-success',
|
||||
'2': 'tag-primary',
|
||||
'3': 'tag-secondary',
|
||||
'4': 'tag-danger'
|
||||
};
|
||||
return classMap[status] || '';
|
||||
};
|
||||
|
||||
const formatMB = (mb) => {
|
||||
if (!mb && mb !== 0) return '0 MB';
|
||||
if (mb >= 1024) {
|
||||
return (mb / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
return mb.toFixed(2) + ' MB';
|
||||
};
|
||||
|
||||
// 获取已使用流量
|
||||
const getUsedFlow = (item) => {
|
||||
if (item.enable_virtual_data) {
|
||||
return formatMB(item.virtual_used_mb || 0);
|
||||
} else {
|
||||
return formatMB(item.real_used_mb || 0);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取总流量
|
||||
const getTotalFlow = (item) => {
|
||||
return formatMB(item.real_total_mb || 0);
|
||||
};
|
||||
|
||||
// 获取剩余流量
|
||||
const getRemainFlow = (item) => {
|
||||
const total = item.real_total_mb || 0;
|
||||
const used = item.enable_virtual_data ? (item.virtual_used_mb || 0) : (item.real_used_mb || 0);
|
||||
const remain = Math.max(total - used, 0);
|
||||
return formatMB(remain);
|
||||
};
|
||||
|
||||
// 获取使用百分比
|
||||
const getUsagePercent = (item) => {
|
||||
const used = item.real_used_mb || 0;
|
||||
const total = item.real_total_mb || 0;
|
||||
|
||||
if (!total) return 0;
|
||||
return Math.min((used / total) * 100, 100).toFixed(2);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T').join(' ').slice(0, 19);
|
||||
};
|
||||
|
||||
const loadPackageList = async (append = false) => {
|
||||
if (loading.value || (append && noMore.value)) return;
|
||||
|
||||
if (loading.value || noMore.value) return;
|
||||
loading.value = true;
|
||||
const requestedPage = page.value;
|
||||
|
||||
try {
|
||||
const data = await assetApi.getPackageHistory(
|
||||
userStore.state.identifier,
|
||||
requestedPage,
|
||||
page.value,
|
||||
pageSize
|
||||
);
|
||||
const groups = data.items;
|
||||
const responsePage = Number.isInteger(data.page) && data.page > 0
|
||||
? data.page
|
||||
: requestedPage;
|
||||
const responseSize = Number.isInteger(data.size) && data.size > 0
|
||||
? data.size
|
||||
: pageSize;
|
||||
const responseTotal = Number.isFinite(data.total) && data.total >= 0
|
||||
? data.total
|
||||
: 0;
|
||||
|
||||
const newData = (data.items || []).map(item => ({
|
||||
...item,
|
||||
activated_at: item.activated_at ? formatDate(item.activated_at) : '',
|
||||
created_at: formatDate(item.created_at),
|
||||
expires_at: item.expires_at ? formatDate(item.expires_at) : ''
|
||||
}));
|
||||
|
||||
if (append) {
|
||||
packageList.value.push(...groups);
|
||||
packageList.push(...newData);
|
||||
} else {
|
||||
packageList.value = groups;
|
||||
packageList.splice(0, packageList.length, ...newData);
|
||||
}
|
||||
|
||||
total.value = responseTotal;
|
||||
noMore.value = responsePage * responseSize >= total.value;
|
||||
page.value = responsePage + 1;
|
||||
} catch (error) {
|
||||
// The shared request layer handles the API's unified error response and authentication errors.
|
||||
console.error('加载套餐历史失败', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
historyLoaded.value = true;
|
||||
if (newData.length < pageSize) {
|
||||
noMore.value = true;
|
||||
} else {
|
||||
page.value++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载套餐历史失败', e);
|
||||
}
|
||||
loading.value = false;
|
||||
historyLoaded.value = true;
|
||||
};
|
||||
|
||||
const loadMore = () => loadPackageList(true);
|
||||
const loadMore = () => {
|
||||
if (!noMore.value) {
|
||||
loadPackageList(true);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadPackageList();
|
||||
@@ -91,46 +180,83 @@
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 500rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400rpx;
|
||||
padding: 120rpx 40rpx;
|
||||
|
||||
.empty-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-bottom: 30rpx;
|
||||
opacity: 0.6;
|
||||
.container {
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 500rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin-bottom: 16rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
min-height: 400rpx;
|
||||
.empty-icon { width: 120rpx; height: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
||||
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
.package-card {
|
||||
margin-bottom: var(--space-md);
|
||||
.package-header {
|
||||
margin-bottom: var(--space-md);
|
||||
.package-name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); }
|
||||
}
|
||||
|
||||
.package-info {
|
||||
.info-row {
|
||||
padding: var(--space-xs) 0;
|
||||
.info-label { font-size: 24rpx; color: var(--text-tertiary); }
|
||||
.info-value { font-size: 24rpx; color: var(--text-primary); }
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
background: var(--gray-200);
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.flow-info {
|
||||
.flow-title { font-size: 26rpx; font-weight: 600; color: var(--text-primary); margin-bottom: var(--space-sm); }
|
||||
.flow-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-sm);
|
||||
.flow-item {
|
||||
text-align: center;
|
||||
.flow-label { font-size: 20rpx; color: var(--text-tertiary); margin-bottom: 4rpx; }
|
||||
.flow-value { font-size: 26rpx; font-weight: 600; color: var(--text-primary); }
|
||||
}
|
||||
}
|
||||
.progress-section {
|
||||
.progress-apple {
|
||||
width: 100%;
|
||||
height: 8rpx;
|
||||
background: var(--gray-200);
|
||||
border-radius: var(--radius-small);
|
||||
overflow: hidden;
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||
border-radius: var(--radius-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: var(--space-lg);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
padding: var(--space-lg);
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
.tag-secondary { background: var(--gray-200) !important; color: var(--gray-600) !important; }
|
||||
</style>
|
||||
|
||||
@@ -258,6 +258,50 @@
|
||||
return (params.get('identifier') || '').trim();
|
||||
};
|
||||
|
||||
const isNineteenDigitIccidPrefix = (value) => /^89\d{17}$/.test(value);
|
||||
|
||||
const appendIccidLuhnCheckDigit = (prefix) => {
|
||||
let sum = 0;
|
||||
|
||||
for (let index = prefix.length - 1, offset = 0; index >= 0; index--, offset++) {
|
||||
let digit = Number(prefix[index]);
|
||||
if (offset % 2 === 0) {
|
||||
digit *= 2;
|
||||
if (digit > 9) digit -= 9;
|
||||
}
|
||||
sum += digit;
|
||||
}
|
||||
|
||||
return `${prefix}${(10 - (sum % 10)) % 10}`;
|
||||
};
|
||||
|
||||
const verifyAssetToken = async (assetIdentifier) => {
|
||||
const verifyData = await authApi.verifyAsset(assetIdentifier);
|
||||
if (!verifyData?.asset_token) {
|
||||
throw { msg: '资产校验未返回有效登录凭证' };
|
||||
}
|
||||
return verifyData;
|
||||
};
|
||||
|
||||
const verifyAssetWithIccidRetry = async (enteredIdentifier) => {
|
||||
try {
|
||||
return {
|
||||
verifyData: await verifyAssetToken(enteredIdentifier),
|
||||
verifiedIdentifier: enteredIdentifier
|
||||
};
|
||||
} catch (firstError) {
|
||||
if (!isNineteenDigitIccidPrefix(enteredIdentifier)) {
|
||||
throw firstError;
|
||||
}
|
||||
|
||||
const completedIccid = appendIccidLuhnCheckDigit(enteredIdentifier);
|
||||
return {
|
||||
verifyData: await verifyAssetToken(completedIccid),
|
||||
verifiedIdentifier: completedIccid
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const doLogin = async () => {
|
||||
loading.value = true;
|
||||
// 丢弃上一轮未完成授权留下的临时凭证,避免校验失败时继续复用。
|
||||
@@ -265,13 +309,11 @@
|
||||
sessionStorage.removeItem('assetToken');
|
||||
}
|
||||
try {
|
||||
const verifyData = await authApi.verifyAsset(identifier.value);
|
||||
if (!verifyData?.asset_token) {
|
||||
throw { msg: '资产校验未返回有效登录凭证' };
|
||||
}
|
||||
const enteredIdentifier = identifier.value;
|
||||
const { verifyData, verifiedIdentifier } = await verifyAssetWithIccidRetry(enteredIdentifier);
|
||||
|
||||
userStore.setAssetToken(verifyData.asset_token);
|
||||
userStore.setIdentifier(identifier.value);
|
||||
userStore.setIdentifier(verifiedIdentifier);
|
||||
|
||||
await redirectToWxAuth(verifyData.asset_token);
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user