feat: 7月迭代
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m11s

This commit is contained in:
luo
2026-07-27 18:13:19 +08:00
parent 6a794d88af
commit 4f281da778
27 changed files with 1176 additions and 167 deletions

View File

@@ -1,12 +1,31 @@
import request from '@/utils/request.js'; import request from '@/utils/request.js';
export const normalizeAssetInfo = (data = {}) => ({
...data,
allowed_payment_methods: Array.isArray(data.allowed_payment_methods)
? data.allowed_payment_methods
: [],
real_name_status: data.real_name_status === undefined || data.real_name_status === null
? data.real_name_status
: Number(data.real_name_status),
realname_required: data.realname_required === undefined || data.realname_required === null
? data.realname_required
: data.realname_required === true || data.realname_required === 1,
days_until_final_expiry: data.days_until_final_expiry === null || data.days_until_final_expiry === undefined
? data.days_until_final_expiry
: Number(data.days_until_final_expiry),
is_expiring: data.is_expiring === undefined || data.is_expiring === null
? data.is_expiring
: data.is_expiring === true || data.is_expiring === 1
});
export const assetApi = { export const assetApi = {
getInfo(identifier) { getInfo(identifier) {
return request({ return request({
url: '/api/c/v1/asset/info', url: '/api/c/v1/asset/info',
method: 'GET', method: 'GET',
data: { identifier } data: { identifier }
}); }).then(normalizeAssetInfo);
}, },
getPackageHistory(identifier, page, page_size, params = {}) { getPackageHistory(identifier, page, page_size, params = {}) {

View File

@@ -5,7 +5,8 @@ export const authApi = {
return request({ return request({
url: '/api/c/v1/auth/verify-asset', url: '/api/c/v1/auth/verify-asset',
method: 'POST', method: 'POST',
data: { identifier } data: { identifier },
showError: false
}); });
}, },

View File

@@ -22,10 +22,8 @@ export const orderApi = {
}, },
create(identifier, package_ids, payment_method) { create(identifier, package_ids, payment_method) {
const data = { identifier, package_ids }; const data = { identifier, package_ids, payment_method };
if (payment_method === 'alipay') { if (payment_method === 'wechat') {
data.payment_method = payment_method;
} else {
data.app_type = APP_TYPE; data.app_type = APP_TYPE;
} }

View File

@@ -10,6 +10,7 @@
<swiper-item v-for="item in items" :key="item.id"> <swiper-item v-for="item in items" :key="item.id">
<view class="notification-content"> <view class="notification-content">
<view class="notification-title">{{ item.title || '业务通知' }}</view> <view class="notification-title">{{ item.title || '业务通知' }}</view>
<view class="notification-severity">{{ getNotificationMeta(item) }}</view>
<view class="notification-time">{{ formatDate(item.created_at) }}</view> <view class="notification-time">{{ formatDate(item.created_at) }}</view>
<scroll-view scroll-y class="notification-body"> <scroll-view scroll-y class="notification-body">
<text>{{ item.body || '暂无通知内容' }}</text> <text>{{ item.body || '暂无通知内容' }}</text>
@@ -47,6 +48,20 @@
if (!value) return '-'; if (!value) return '-';
return value.replace('T', ' ').slice(0, 16); return value.replace('T', ' ').slice(0, 16);
}; };
const getNotificationMeta = (item) => {
const category = {
expiry: '套餐临期',
exchange: '换货'
}[item?.category] || '业务通知';
const severity = {
info: '提示',
warning: '警告',
error: '错误',
critical: '严重'
}[item?.severity];
return severity ? `${category} · ${severity}` : category;
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -108,6 +123,12 @@
line-height: 1.45; line-height: 1.45;
} }
.notification-severity {
margin-top: 10rpx;
color: var(--primary);
font-size: 22rpx;
}
.notification-time { .notification-time {
margin-top: 12rpx; margin-top: 12rpx;
color: var(--text-tertiary); color: var(--text-tertiary);

View File

@@ -6,7 +6,10 @@
<view class="title">{{ currentCardNo || '-' }}</view> <view class="title">{{ currentCardNo || '-' }}</view>
</view> </view>
<view class="caption">套餐名称{{ deviceInfo.packageName || '-' }}</view> <view class="caption">套餐名称{{ deviceInfo.packageName || '-' }}</view>
<view class="caption">套餐到期时间{{ deviceInfo.expireDate || '-' }}</view> <view class="caption" :class="{ 'expiry-warning': isExpiring }">
套餐到期时间{{ deviceInfo.expireDate || '-' }}
<text v-if="isExpiring && expiryDays !== null">剩余 {{ expiryDays }} </text>
</view>
</view> </view>
<view v-if="isDevice" class="tag-apple" :class="onlineStatus === '在线' ? 'tag-success' : 'tag-warning'"> <view v-if="isDevice" class="tag-apple" :class="onlineStatus === '在线' ? 'tag-success' : 'tag-warning'">
{{ onlineStatus }} {{ onlineStatus }}
@@ -24,7 +27,9 @@
deviceInfo: { type: Object, default: () => ({}) }, deviceInfo: { type: Object, default: () => ({}) },
onlineStatus: { type: String, default: '离线' }, onlineStatus: { type: String, default: '离线' },
networkStatus: { type: [String, Number], default: '离线' }, networkStatus: { type: [String, Number], default: '离线' },
isDevice: { type: Boolean, default: true } isDevice: { type: Boolean, default: true },
isExpiring: { type: Boolean, default: false },
expiryDays: { type: [Number, String], default: null }
}); });
</script> </script>
@@ -33,6 +38,8 @@
.user-info-card { .user-info-card {
color: var(--text-primary); color: var(--text-primary);
.expiry-warning { color: var(--danger); }
.user-details { .user-details {
flex: 1; flex: 1;
} }

View File

@@ -29,22 +29,6 @@
POST /api/c/v1/auth/verify-asset POST /api/c/v1/auth/verify-asset
``` ```
管理后台相关接口:
```http
PUT /api/admin/shops/:id
GET /api/admin/shops
GET /api/admin/shops/:id
```
店铺字段:
```json
{
"client_login_disabled": true
}
```
#### 预期效果 #### 预期效果
- 被限制的店铺无法新登录 H5/C 端。 - 被限制的店铺无法新登录 H5/C 端。
@@ -181,12 +165,6 @@ POST /api/c/v1/orders/create
GET /api/c/v1/asset/info GET /api/c/v1/asset/info
``` ```
后台资产解析接口也会返回相关字段:
```http
GET /api/admin/assets/resolve/:identifier
```
#### 预期效果 #### 预期效果
- 用户看到资产综合计算后的最终到期时间。 - 用户看到资产综合计算后的最终到期时间。
@@ -285,11 +263,11 @@ GET /api/c/v1/orders/:id
- #84 H5 首页隐藏设备下 ICCID本期确认不做。 - #84 H5 首页隐藏设备下 ICCID本期确认不做。
- #73 行业卡未实名复机:保持现有逻辑。 - #73 行业卡未实名复机:保持现有逻辑。
- #94 状态同步和运营商回调:继续读取现有资产状态字段,无新增端调用。 - #94 状态同步和运营商回调:继续读取现有资产状态字段,无新增 H5/C 端调用。
- 企业微信审批回调:由企微服务器调用,前端禁止调用。 - 企业微信审批回调:H5/C 端不调用。
- 原路退款、聚水潭、跨品类换货、分销码/佣金提现:本期不做。 - 原路退款、聚水潭、跨品类换货、分销码/佣金提现:本期不做。
## 四、端联调注意事项 ## 四、H5/C 端联调注意事项
- 金额接口字段默认单位为“分”,页面展示时转换为“元”,提交时仍传整数分。 - 金额接口字段默认单位为“分”,页面展示时转换为“元”,提交时仍传整数分。
- `effective_realname_policy``allowed_payment_methods` 必须以后端返回值为准。 - `effective_realname_policy``allowed_payment_methods` 必须以后端返回值为准。

View File

@@ -0,0 +1,277 @@
# C 端所需接口文档
## 1. 通用约定
- 测试环境:`https://cmp-api.boss160.cn`
- 鉴权:除特别说明外,所有接口都需要登录后的 JWT。
```http
Authorization: Bearer <token>
Content-Type: application/json
```
- `identifier`:资产标识符,可传 SN、IMEI、虚拟号、ICCID 或 MSISDN长度 150。
- 金额单位:分。页面展示时转换为元,提交时仍传整数分。
- 成功响应:`code = 0`
- 错误响应:`400` 参数错误、`401` 未认证或过期、`403` 无权访问、`500` 服务端错误。
通用成功响应格式:
```json
{
"code": 0,
"data": {},
"msg": "success",
"timestamp": "2026-07-27T10:00:00+08:00"
}
```
错误响应格式:
```json
{
"code": 1001,
"data": {},
"msg": "参数验证失败",
"timestamp": "2026-07-27T10:00:00+08:00"
}
```
## 2. 接口清单
| 用途 | 方法 | 路径 |
| --- | --- | --- |
| 获取资产信息 | GET | `/api/c/v1/asset/info` |
| 充值前校验 | GET | `/api/c/v1/wallet/recharge-check` |
| 创建套餐订单 | POST | `/api/c/v1/orders/create` |
| 创建充值订单 | POST | `/api/c/v1/wallet/recharge` |
| 订单列表 | GET | `/api/c/v1/orders` |
| 订单详情 | GET | `/api/c/v1/orders/{id}` |
## 3. 获取资产信息
### 请求
```http
GET /api/c/v1/asset/info?identifier=1234567890
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `identifier` | string | 是 | 资产标识符150 个字符 |
### `data` 关键返回字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `asset_id` | integer | 资产 ID |
| `asset_type` | string | `card` 卡、`device` 设备 |
| `identifier` | string | 当前资产标识符 |
| `iccid` / `msisdn` | string | 卡 ICCID / 手机号 |
| `sn` / `imei` / `virtual_no` | string | 设备序列号 / IMEI / 虚拟号 |
| `device_name` / `device_model` | string | 设备名称 / 型号 |
| `carrier_name` / `carrier_type` | string | 运营商名称 / 类型(`CMCC``CUCC``CTCC``CBN` |
| `status` / `status_name` | integer / string | 资产归属状态:`1` 在库、`2` 已分销;卡沿用卡状态枚举 |
| `activation_status` / `activation_status_name` | integer / string | 激活状态:`0` 未激活、`1` 已激活 |
| `network_status` / `network_status_name` | integer / string | 网络状态:`0` 停机、`1` 开机 |
| `real_name_status` / `real_name_status_name` | integer / string | 实名状态:`0` 未实名、`1` 已实名 |
| `realname_policy` | string | 实名策略:`none``before_order``after_order` |
| `effective_realname_policy` | string | 当前实际生效的实名策略 |
| `realname_required` | boolean | 当前资产是否需要实名 |
| `allowed_payment_methods` | string[] | 允许的支付方式:`wallet``wechat``alipay` |
| `wallet_balance` | integer | 钱包余额,单位为分 |
| `current_package_id` | integer | 当前主套餐 ID无套餐时为 `0`,可用于续费 |
| `current_package` | string | 当前套餐名称,无套餐时为空 |
| `current_package_activated_at` | datetime / null | 当前主套餐开始时间 |
| `current_package_expires_at` | datetime / null | 当前主套餐到期时间 |
| `estimated_final_expires_at` | datetime / null | 预计最终到期时间 |
| `days_until_final_expiry` | integer/null | 距预计最终到期的上海自然日天数 |
| `expiry_estimate_status` | string | `exact` 精确、`waiting_activation` 待激活、`none` 无套餐、`invalid_data` 数据异常 |
| `is_expiring` | boolean | 是否临期(精确推算且剩余 015 天) |
| `enable_virtual_data` | boolean | 当前主套餐是否启用虚流量 |
| `real_total_mb` / `real_used_mb` | integer | 真实总量 / 真实已用量,单位 MB |
| `virtual_total_mb` / `virtual_used_mb` | integer / number | 业务停机阈值 / 展示已用量,单位 MB |
| `reduction_pct` | number | 展示增幅比例:`real_total_mb / virtual_total_mb - 1` |
| `cards` | object[] | 设备绑定卡列表,包含 ICCID、MSISDN、网络状态、实名状态、插槽位置等 |
| `device_realtime` | object/null | 设备实时信息包含在线状态、电量、信号、WiFi、客户端数等 |
页面流量展示建议:总量使用 `real_total_mb`;已用量在 `enable_virtual_data = true` 时使用 `virtual_used_mb`,否则使用 `real_used_mb`
## 4. 充值前校验
### 请求
```http
GET /api/c/v1/wallet/recharge-check?identifier=1234567890
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `identifier` | string | 是 | 资产标识符 |
### `data` 返回字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `allowed_payment_methods` | string[]/null | 当前允许的支付方式:`wallet``wechat``alipay` |
| `need_force_recharge` | boolean | 是否必须先完成强制充值 |
| `force_recharge_amount` | integer | 强制充值金额,单位为分 |
| `min_amount` | integer | 最小充值金额,单位为分 |
| `max_amount` | integer | 最大充值金额,单位为分 |
| `message` | string | 页面提示信息 |
| `trigger_type` | string | 强制充值触发类型 |
## 5. 创建套餐订单
### 请求
```http
POST /api/c/v1/orders/create
```
```json
{
"identifier": "1234567890",
"package_ids": [1001, 1002],
"payment_method": "wechat",
"app_type": "miniapp"
}
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `identifier` | string | 是 | 资产标识符 |
| `package_ids` | integer[] | 是 | 套餐 ID 列表 |
| `payment_method` | string | 是 | `wallet``wechat``alipay` |
| `app_type` | string | 微信支付时是 | `official_account` 公众号、`miniapp` 小程序 |
### `data` 返回字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `idempotent` | boolean | 是否返回了已存在的待支付订单 |
| `order_type` | string | `package` 套餐订单、`recharge` 充值订单 |
| `order` | object | 订单信息,见下表 |
| `linked_package_info` | object | 关联套餐和强制充值信息 |
| `recharge` | object/null | 自动充值信息 |
| `pay_config` | object/null | 微信支付参数 |
| `payment_link` | object/null | 支付宝或其他网页支付链接 |
`order` 主要字段:`order_id``order_no``created_at``payment_method``payment_status``payment_status_name``total_amount`
- `payment_status``1` 待支付、`2` 已支付、`3` 已取消、`4` 已退款。
- `linked_package_info``force_recharge_amount``package_names``total_package_amount``wallet_credit`,金额均为分。
- `recharge``recharge_id``recharge_no``amount``status``status_name``auto_purchase_status`
- `pay_config``app_id``nonce_str``package``pay_sign``sign_type``timestamp`
- `payment_link``copy_link``qr_link``payment_no``pay_expire_at`
## 6. 创建充值订单
### 请求
```http
POST /api/c/v1/wallet/recharge
```
```json
{
"amount": 1000,
"identifier": "1234567890",
"payment_method": "wechat",
"app_type": "miniapp"
}
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `amount` | integer | 是 | 充值金额110000000 分 |
| `identifier` | string | 是 | 资产标识符 |
| `payment_method` | string | 是 | `wechat` 微信支付、`alipay` 支付宝 |
| `app_type` | string | 微信支付时是 | `official_account``miniapp` |
### `data` 返回字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `recharge` | object | 充值信息:`recharge_id``recharge_no``amount``status` |
| `pay_config` | object/null | 微信支付参数:`app_id``nonce_str``package``pay_sign``sign_type``timestamp` |
| `payment_link` | object/null | 支付链接:`copy_link``qr_link``payment_no``pay_expire_at` |
`recharge.status``0` 待支付、`1` 已支付、`2` 已关闭。
## 7. 订单列表
### 请求
```http
GET /api/c/v1/orders?identifier=1234567890&page=1&page_size=10
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `identifier` | string | 是 | 资产标识符 |
| `payment_status` | integer | 否 | `1` 待支付、`2` 已支付、`3` 已取消、`4` 已退款 |
| `page` | integer | 是 | 页码,从 1 开始 |
| `page_size` | integer | 是 | 每页数量1100 |
### `data` 返回字段
```json
{
"items": [],
"page": 1,
"size": 10,
"total": 0
}
```
`items[]` 字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `order_id` / `order_no` | integer / string | 订单 ID / 订单号 |
| `asset_id` / `asset_type` | integer / string | 资产 ID / `card``device` |
| `asset_identifier` | string | 下单时资产标识快照;设备优先虚拟号,其次 IMEI |
| `package_ids` / `package_names` | array | 套餐 ID / 名称列表 |
| `payment_method` | string | `wallet``wechat``alipay` |
| `payment_status` / `payment_status_name` | integer / string | 支付状态及中文名称 |
| `total_amount` | integer | 订单总金额,单位为分 |
| `created_at` | string | 创建时间 |
## 8. 订单详情
### 请求
```http
GET /api/c/v1/orders/{id}
```
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `id` | integer | 是 | 订单 ID放在 URL 路径中 |
### `data` 返回字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `order_id` / `order_no` | integer / string | 订单 ID / 订单号 |
| `asset_id` / `asset_type` | integer / string | 资产 ID / `card``device` |
| `asset_identifier` | string | 下单时资产标识快照 |
| `packages` | object[] | 订单套餐明细 |
| `payment_method` | string | 支付方式 |
| `payment_status` / `payment_status_name` | integer / string | 支付状态及中文名称 |
| `total_amount` | integer | 订单总金额,单位为分 |
| `created_at` | string | 创建时间 |
| `paid_at` | string/null | 支付时间 |
| `completed_at` | string/null | 完成时间 |
`packages[]` 字段:`package_id``package_name``package_type``formal` 正式套餐、`addon` 加油包)、`price`(分)、`quantity`
## 9. 前端调用流程
1. 登录后保存 JWT后续请求统一携带 `Authorization: Bearer <token>`
2. 进入资产页面调用资产信息接口,并使用返回的 `allowed_payment_methods` 渲染支付方式。
3. 充值先调用充值前校验;若 `need_force_recharge = true`,使用后端返回的强制充值金额和提示。
4. 套餐购买或续费调用创建订单接口,`package_ids` 使用当前资产的 `current_package_id` 或历史订单的 `package_ids`
5. 充值调用创建充值订单接口。微信支付时必须传 `app_type`
6. 页面展示支付链接或支付参数后,支付结果通过订单详情等查询接口确认;不要仅根据前端跳转结果判断已支付。

View File

@@ -0,0 +1,63 @@
## Context
本次迭代同时调整认证入口、资产初始化、套餐购买、钱包充值、订单查询和站内通知入口。后端已经通过资产信息、充值校验、订单和通知接口返回策略与业务状态H5/C 端需要消费这些结果,而不是复制后端规则。当前仓库已经存在部分支付、实名和通知页面,因此提案以增量收敛行为为主。
接口约定以 `docs/所需接口文档/new-api.md` 为准;登录限制接口和通知接口的调用路径同时以 `docs/产品迭代7月份/七月迭代H5_C端改动说明.md` 中列出的既有路径为准。
## Goals / Non-Goals
- Goals: 让登录限制、实名策略、支付方式、强充约束、下架套餐续费、预计到期时间、临期/换货通知及订单标识展示均以后端返回为准。
- Goals: 保持现有 H5/C API 路径和支付入口,完成前端参数和展示规则收敛。
- Non-Goals: 不新增后端接口,不修改订单历史快照,不由 H5/C 调用企微审批回调,也不实现七月说明中列出的排除项。
## Decisions
### 1. Backend is the source of truth
资产初始化统一读取 `effective_realname_policy``realname_required``real_name_status``allowed_payment_methods``estimated_final_expires_at``days_until_final_expiry``expiry_estimate_status``is_expiring`。前端不再根据 `asset_type`、卡/设备组合或本地枚举推导实名和支付规则;设备是否已实名也以服务端最终状态为准。
### 2. Keep login failure before token persistence
`POST /api/c/v1/auth/verify-asset` 成功后才允许进入后续微信登录,并保存本次返回的 `asset_token`。业务失败时直接展示后端 `msg`,当前登录尝试不得继续获取或覆盖资产 TokenToken 的签发和吊销仍由服务端负责。
### 3. Normalize payment payloads at the API boundary
订单和充值页面只提交用户从后端允许列表中选择的 `payment_method`。仅在 `payment_method = wechat` 时提交接口要求的 `app_type`;钱包和支付宝不添加无关的微信字段。金额在 UI 层转换为元,在请求层保持整数分。
支付创建成功后,根据 `pay_config``payment_link` 进入既有支付处理;返回页面或支付完成后使用订单详情/状态查询确认结果,不能仅凭前端跳转成功判定已支付。
### 4. Separate catalog visibility from renewal eligibility
普通套餐列表继续只展示可售套餐。老客户续费使用资产信息中的 `current_package_id` 或历史订单中的 `package_ids` 作为已知套餐 ID复用 `POST /api/c/v1/orders/create`,不创建“下架套餐续费”专用接口。前端不得修改历史订单数据或把下架套餐重新放入普通新客购买列表。
### 5. Display the final expiry estimate
资产页面优先展示 `estimated_final_expires_at`,并用 `expiry_estimate_status` 判断其是否可展示,用 `days_until_final_expiry``is_expiring` 控制剩余天数及临期样式。前端不根据当前套餐到期时间自行累加计算最终日期;无可用估算时显示明确的空状态。
### 6. Reuse the existing customer notification entry
首页或通知入口读取未读数,通知页面读取列表,用户查看/点击通知后调用单条已读接口。套餐临期的 15/7/3 天触发和 03 天的优先级由后端通知数据表达,前端只负责按等级排序/展示和更新未读数。换货通知沿用同一套入口不新增营销、ERP 或业务员提醒通道。
本提案依赖进行中的 `add-personal-notifications` 变更提供通知数据的可见性、分页和幂等已读语义;本提案只定义 H5/C 的消费方式。
### 7. Preserve server snapshots in order views
订单列表和详情使用后端返回的 `purchase_role``asset_identifier`。设备显示优先使用 `virtual_no`,为空时使用 `imei`,绝不以 `sn` 代替;缺失数据保持空占位,不由前端伪造。金额继续按分转元展示。
## Risks / Trade-offs
- 后端字段缺失或为空时,页面可能无法给出策略或到期日期;通过统一空状态和错误提示避免前端猜测。
- 同一支付入口可能同时收到 `pay_config``payment_link` 为空的结果;前端必须保留既有错误处理并允许通过订单状态查询恢复。
- `add-personal-notifications` 与本提案同时推进时,需要先确认通知 API 的返回字段和分页参数一致;本提案不重复修改该服务契约。
- 下架套餐续费依赖资产或历史订单提供合法的套餐 ID若两者均不存在应明确提示不可续费而不是从普通列表猜测套餐。
## Migration Plan
1. 先更新 API 封装和数据归一化,再逐个接入登录、资产、支付、套餐、通知和订单页面。
2. 使用接口模拟数据覆盖策略冲突、支付方式变化、强充、临期等级、下架套餐和空标识场景。
3. 联调确认支付结果查询、通知已读幂等性及历史订单快照后发布。
4. 若任一后端字段未上线,回退对应 UI 展示入口,不回退到前端硬编码业务规则。
## Open Questions
- None. The proposal follows the July change description and the provided API document; backend response details not listed there remain opaque to the client and are displayed through existing generic error handling.

View File

@@ -0,0 +1,41 @@
# Change: Update July H5/C iteration behavior
## Why
七月迭代要求 H5/C 端将登录、实名、支付、套餐续费、到期提醒、站内通知和订单展示统一切换为以后端业务结果为准。当前页面和 API 封装仍存在按资产类型或前端本地规则判断的逻辑,部分订单提交也没有始终传递用户选择的支付方式,容易造成错误引导、支付参数不完整和订单信息展示失真。
## What Changes
- 在资产校验登录流程中处理店铺 C 端登录限制;被限制时展示后端业务错误,不保存或继续使用本次登录的资产 Token。
- 使用资产信息接口返回的 `effective_realname_policy``realname_required``real_name_status` 驱动实名状态展示及下单前后的实名流程。
- 使用 `allowed_payment_methods` 渲染支付方式,并在套餐下单、强充校验和钱包充值时按后端规则提交 `payment_method`;微信支付按要求提交 `app_type`
- 普通套餐列表隐藏下架套餐,同时允许正在使用下架套餐的老客户通过 `current_package_id` 或历史订单 `package_ids` 复用 `/api/c/v1/orders/create` 续费。
- 在资产详情、资产列表等页面展示后端计算的 `estimated_final_expires_at`,并使用临期字段进行高亮。
- 接入 C 端站内通知的未读数、列表和单条已读接口,展示套餐临期和换货通知,并按后端临期等级处理提醒优先级。
- 在订单列表和详情展示后端返回的 `purchase_role``asset_identifier`;设备标识按 `virtual_no` 优先、`imei` 兜底,不使用 SN 冒充。
- 保持金额接口以分传输、页面以元展示,并在支付参数或支付链接返回后通过订单状态查询确认支付结果。
本提案不包含首页隐藏设备 ICCID、行业卡未实名复机、状态同步/运营商回调、企微审批回调、原路退款、跨品类换货、分销码或佣金提现等明确排除项。H5/C 端不调用企微审批回调接口。
## Capabilities
### New Capabilities
- `c-login-access`: Enforce the shop-level C-end login restriction during asset verification
- `asset-realname-flow`: Drive real-name behavior from backend asset policy and status
- `asset-expiry-display`: Display the estimated final package expiry and expiry state
- `backend-driven-payment`: Render and submit payment methods from backend policy
- `legacy-package-renewal`: Allow eligible existing customers to renew discontinued packages
- `c-notification-reminders`: Surface expiry and exchange notifications in the H5/C client
- `order-display-fields`: Render order role and server-provided asset identifier snapshots
### Modified Capabilities
- `personal-notifications`: This change consumes the notification endpoints and does not redefine their backend visibility, pagination, or read-state contract. Coordinate with the existing `add-personal-notifications` change.
## Impact
- Affected code: `pages/login/login.vue`, `pages/index/index.vue`, `pages/auth/auth.vue`, `pages/switch/switch.vue`, `pages/package-order/package-order.vue`, `pages/my-wallet/my-wallet.vue`, `pages/order-list/order-list.vue`, `pages/notifications/notifications.vue`, related notification/payment components, and `api/modules/{auth,asset,order,wallet,notification}.js`
- Affected APIs: `/api/c/v1/auth/verify-asset`, `/api/c/v1/asset/info`, `/api/c/v1/wallet/recharge-check`, `/api/c/v1/orders/create`, `/api/c/v1/wallet/recharge`, `/api/c/v1/orders`, `/api/c/v1/orders/{id}`, `/api/c/v1/notifications/unread-count`, `/api/c/v1/notifications`, and `/api/c/v1/notifications/{id}/read`
- No new backend endpoint is required; the client adopts the response fields and request rules described in `docs/所需接口文档/new-api.md` and the July H5/C change description.
- Existing WeChat payment parameter handling, Alipay payment-link handling, wallet payment, payment submit guards, and notification list APIs must remain compatible.

View File

@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Asset views SHALL display the estimated final package expiry
Asset detail and summary views using `/api/c/v1/asset/info` SHALL prefer `estimated_final_expires_at` over `current_package_expires_at` for the user-facing final expiry. The client SHALL use `expiry_estimate_status`, `days_until_final_expiry`, and `is_expiring` when those fields are returned, and SHALL NOT calculate a replacement final expiry locally.
#### Scenario: Exact final expiry is available
- **WHEN** `expiry_estimate_status = exact` and `estimated_final_expires_at` is present
- **THEN** the client SHALL display the estimated final expiry date
- **AND** the client SHALL display the returned remaining-day value when available
#### Scenario: Asset is approaching final expiry
- **WHEN** `is_expiring = true` or the backend returns an applicable expiry level
- **THEN** the client SHALL apply the existing expiry highlight/reminder presentation
- **AND** the client SHALL use the backend value rather than recalculating the threshold
#### Scenario: Final expiry cannot be estimated
- **WHEN** `expiry_estimate_status` is `none`, `waiting_activation`, or `invalid_data`, or the estimated date is null
- **THEN** the client SHALL show the corresponding empty/pending state
- **AND** the client SHALL not present `current_package_expires_at` as if it were the final calculated expiry

View File

@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: Real-name behavior SHALL use the effective backend asset policy
The H5/C client SHALL use `GET /api/c/v1/asset/info?identifier=...` as the source of truth for real-name behavior. It SHALL consume `effective_realname_policy`, `realname_required`, and `real_name_status` and SHALL NOT infer policy or final device real-name status from asset type, card type, device type, or frontend-only rules.
#### Scenario: Asset does not require real name
- **WHEN** asset information returns `effective_realname_policy = none` or `realname_required = false`
- **THEN** the client SHALL show the asset as not requiring real-name completion
- **AND** the client SHALL not block the normal order flow for real-name completion
#### Scenario: Real name is required before ordering
- **WHEN** asset information returns `effective_realname_policy = before_order`, `realname_required = true`, and `real_name_status` as not completed
- **THEN** the client SHALL show the real-name requirement
- **AND** the client SHALL prevent package order submission until the existing real-name flow completes
#### Scenario: Real name is required after ordering
- **WHEN** asset information returns `effective_realname_policy = after_order`
- **THEN** the client SHALL allow the policy-defined post-order flow to proceed without applying a before-order block
- **AND** the client SHALL display the returned real-name status and retain the existing real-name action entry when completion is needed
#### Scenario: Device status is resolved from server output
- **WHEN** a device has cards with different local-looking real-name states but the asset info response returns the effective device `real_name_status`
- **THEN** the client SHALL display and use that returned effective status
- **AND** the client SHALL not replace it with a card/device inference

View File

@@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Payment options SHALL be rendered from backend permissions
The H5/C client SHALL use `allowed_payment_methods` from asset information and the recharge-check response to determine which payment methods are visible and selectable. It SHALL support the backend values `wallet`, `wechat`, and `alipay` where returned, and SHALL not hard-code a card/device payment-method matrix.
#### Scenario: Asset payment methods are returned
- **WHEN** asset information returns a non-empty `allowed_payment_methods` list
- **THEN** the package payment UI SHALL render exactly the methods allowed by that list
- **AND** the client SHALL not offer a method absent from the list
#### Scenario: Recharge permissions differ from asset permissions
- **WHEN** `/api/c/v1/wallet/recharge-check` returns its own `allowed_payment_methods` value
- **THEN** the wallet recharge UI SHALL use the recharge-check value for that recharge attempt
- **AND** the client SHALL not reuse a stale package-payment method list
### Requirement: Payment creation SHALL submit the selected method and the required WeChat app type
The client SHALL submit `identifier`, the relevant package or amount fields, and the user-selected `payment_method` to the applicable creation endpoint. It SHALL submit `app_type` only when `payment_method = wechat`, using the endpoint-defined value `official_account` or `miniapp`.
#### Scenario: Create a package order
- **WHEN** the user submits an allowed package payment method
- **THEN** the client SHALL call `POST /api/c/v1/orders/create` with `identifier`, `package_ids`, and the selected `payment_method`
- **AND** the client SHALL include `app_type` for WeChat payment as required
#### Scenario: Create a wallet recharge
- **WHEN** the user submits an allowed recharge method and an amount within backend limits
- **THEN** the client SHALL call `POST /api/c/v1/wallet/recharge` with the integer amount in cents, identifier, and selected payment method
- **AND** the client SHALL include `app_type` only for WeChat payment
#### Scenario: User-facing currency conversion
- **WHEN** an amount is returned by an API in cents
- **THEN** the client SHALL display the corresponding yuan value
- **AND** the client SHALL submit the original integer-cent representation to the API
### Requirement: Recharge submission SHALL honor the backend pre-check
The client SHALL call `GET /api/c/v1/wallet/recharge-check?identifier=...` before starting a recharge. When `need_force_recharge = true`, it SHALL use `force_recharge_amount`, `min_amount`, `max_amount`, and `message` from the response to guide or block the recharge flow.
#### Scenario: Force recharge is required
- **WHEN** recharge-check returns `need_force_recharge = true`
- **THEN** the client SHALL present the backend force-recharge amount and message
- **AND** the client SHALL not submit a normal recharge that violates the returned requirement
#### Scenario: Recharge is permitted normally
- **WHEN** recharge-check returns `need_force_recharge = false` and the amount is within the returned range
- **THEN** the client SHALL allow the user to select an allowed method and submit the recharge order
### Requirement: Payment results SHALL be confirmed by backend order state
After order creation, the client SHALL preserve existing handling for `pay_config` and `payment_link`, and SHALL use an order or recharge status query to confirm completion. A successful frontend redirect or payment-link return alone SHALL not be treated as proof of payment.
#### Scenario: Backend returns WeChat payment parameters
- **WHEN** a creation response contains `pay_config`
- **THEN** the client SHALL invoke the existing WeChat payment flow
- **AND** the client SHALL refresh backend payment status after the flow returns
#### Scenario: Backend returns a web payment link
- **WHEN** a creation response contains `payment_link`
- **THEN** the client SHALL use the existing payment-link presentation/handling
- **AND** the client SHALL confirm the resulting order or recharge state through the backend

View File

@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: C-end login SHALL honor asset verification access decisions
The H5/C client SHALL call `POST /api/c/v1/auth/verify-asset` before continuing the asset login flow. A business failure or C-end login restriction returned by the endpoint SHALL be shown using the backend business message, and the client SHALL NOT continue to obtain, persist, or use an asset token for that login attempt.
#### Scenario: Asset verification allows login
- **WHEN** asset verification succeeds and returns an asset token
- **THEN** the client SHALL persist the identifier and returned asset token and continue the existing login flow
#### Scenario: Shop forbids a new C-end login
- **WHEN** asset verification returns a business failure indicating that the shop has forbidden C-end login
- **THEN** the client SHALL display the backend error message
- **AND** the client SHALL stop the current login flow before WeChat authorization or token persistence
- **AND** the client SHALL not revoke an already-issued token as a side effect
#### Scenario: Asset verification fails without a usable token
- **WHEN** the verification request returns an error response or no usable asset token
- **THEN** the client SHALL display the existing request/business error
- **AND** the client SHALL leave the current login attempt unauthenticated

View File

@@ -0,0 +1,55 @@
## ADDED Requirements
### Requirement: H5/C SHALL surface unread customer notifications
The H5/C client SHALL use `GET /api/c/v1/notifications/unread-count` for the homepage or notification-entry badge and `GET /api/c/v1/notifications` for the notification list. It SHALL consume the existing personal-notification contract and SHALL not add a separate API for expiry or exchange reminders.
#### Scenario: Unread notifications exist
- **WHEN** the unread-count endpoint returns a positive count
- **THEN** the homepage or notification entry SHALL display the unread indicator using that count
- **AND** the notification list entry SHALL remain available
#### Scenario: No unread notifications exist
- **WHEN** the unread-count endpoint returns zero
- **THEN** the client SHALL clear the unread indicator
- **AND** the notification entry SHALL not show a stale count
### Requirement: Expiry and exchange notices SHALL use the shared notification channel
The client SHALL display package-expiry and exchange-related notifications returned for the authenticated customer through the shared notification list or reminder popup. The backend notification data SHALL determine the expiry trigger and severity; the client SHALL not create independent timers that send business notifications.
#### Scenario: Package reaches a reminder threshold
- **WHEN** the notification service returns an unread package-expiry notice for the 15-day, 7-day, or 3-day threshold
- **THEN** the client SHALL display it through the existing H5/C notification entry or popup
- **AND** the client SHALL use the returned expiry level for presentation
#### Scenario: Critical expiry notice has highest priority
- **WHEN** unread expiry notices include a notice for 0 to 3 remaining days
- **THEN** the client SHALL give that notice the highest reminder priority
- **AND** the client SHALL not downgrade it based on a client-side date calculation
#### Scenario: Exchange notification is returned
- **WHEN** an exchange-related unread notice is returned after an exchange is created
- **THEN** the notification entry/list SHALL display the notice
- **AND** the client SHALL not call a separate marketing, ERP, or salesperson notification endpoint
### Requirement: Viewing a notification SHALL support marking it read
When the user views or activates a notification, the client SHALL call `PUT /api/c/v1/notifications/{id}/read` for that notification and reconcile the local unread count after a successful response. Repeated read actions SHALL remain safe according to the existing notification API contract.
#### Scenario: User reads an unread notice
- **WHEN** the user opens or advances to an unread expiry or exchange notification
- **THEN** the client SHALL mark that notification read through the existing endpoint
- **AND** the badge/list state SHALL reflect the read result
#### Scenario: Notification read request fails
- **WHEN** marking a notification read fails
- **THEN** the client SHALL retain the unread state or refresh it from the backend
- **AND** the failure SHALL not prevent the user from viewing other asset or notification content

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Discontinued packages SHALL remain renewable only for eligible existing customers
The H5/C client SHALL keep discontinued packages out of the ordinary package catalog shown to new customers and agents. An eligible existing customer SHALL be able to use a valid `current_package_id` from asset info or `package_ids` from a historical order to continue a package renewal.
#### Scenario: New customer opens the package catalog
- **WHEN** the package catalog contains a discontinued package that is not the customer's current package
- **THEN** the client SHALL not display that package as an ordinary purchase option
#### Scenario: Existing customer is using a discontinued current package
- **WHEN** asset info returns a discontinued package through `current_package_id`
- **THEN** the client SHALL expose that ID only in the eligible renewal context
- **AND** the client SHALL allow the customer to continue through the existing package-order flow
#### Scenario: Existing customer renews from a historical order
- **WHEN** a historical order returns one or more valid `package_ids`
- **THEN** the client SHALL use those IDs for the renewal selection when the existing renewal flow provides that entry
- **AND** the client SHALL preserve the historical order data unchanged
### Requirement: Renewal SHALL reuse the standard order creation contract
The client SHALL create a renewal by calling `POST /api/c/v1/orders/create` with the selected package ID or IDs, the asset `identifier`, and a currently allowed `payment_method`. It SHALL not invent or call a dedicated discontinued-package renewal endpoint.
#### Scenario: Submit a discontinued-package renewal
- **WHEN** an eligible customer confirms a renewal with an allowed payment method
- **THEN** the client SHALL submit the selected package IDs, identifier, and payment method to the standard order endpoint
- **AND** the client SHALL follow the standard payment response handling
#### Scenario: No eligible package ID is available
- **WHEN** neither asset info nor the relevant historical order supplies a valid package ID
- **THEN** the client SHALL show that renewal is unavailable
- **AND** the client SHALL not guess an ID from the ordinary package catalog

View File

@@ -0,0 +1,47 @@
## ADDED Requirements
### Requirement: Order views SHALL display backend role and asset snapshots
The H5/C client SHALL use `GET /api/c/v1/orders` and `GET /api/c/v1/orders/{id}` as the source of truth for order display. Order list and detail views SHALL display the backend `purchase_role` and `asset_identifier` values when present.
#### Scenario: Order includes a purchase role
- **WHEN** an order response returns `purchase_role`
- **THEN** the order list and detail view SHALL render that role
- **AND** the client SHALL not infer the role from the current user or asset type
#### Scenario: Card order includes an asset identifier
- **WHEN** a card order returns an `asset_identifier` snapshot
- **THEN** the client SHALL display the returned card identifier, such as ICCID, without replacing it with a current asset lookup
### Requirement: Device order identifiers SHALL prefer virtual number over IMEI
For device orders, the client SHALL display `virtual_no`/`VirtualNo` when it is non-empty, and SHALL use `imei` only when the virtual number is empty. The client SHALL not use `sn` as a substitute for the order asset identifier.
#### Scenario: Device has a virtual number
- **WHEN** a device order response contains a non-empty virtual number and an IMEI
- **THEN** the client SHALL display the virtual number
- **AND** the client SHALL not display SN as the order device identifier
#### Scenario: Device virtual number is empty
- **WHEN** a device order response has an empty virtual number and a non-empty IMEI
- **THEN** the client SHALL display the IMEI
#### Scenario: Historical identifier data is empty
- **WHEN** both the virtual number and IMEI are absent or empty
- **THEN** the client SHALL show the existing empty placeholder
- **AND** the client SHALL not fabricate an identifier from SN or a new asset-info request
### Requirement: Order amounts SHALL use the common currency display rule
Order list and detail views SHALL display `total_amount` and package prices converted from integer cents to yuan while preserving the integer-cent values in API requests and response state.
#### Scenario: Display an order amount
- **WHEN** an order response returns an amount in cents
- **THEN** the client SHALL render the corresponding yuan value
- **AND** the client SHALL not expose the raw cent value as the user-facing amount

View File

@@ -0,0 +1,49 @@
## 1. API adapters and shared rules
- [x] 1.1 Update asset/auth adapters and login handling for `verify-asset` business failures before asset-token persistence
- [x] 1.2 Normalize asset response fields for real-name policy/status, allowed payment methods, expiry estimate, and device/card identifiers
- [x] 1.3 Update order and wallet adapters so every create request includes the selected `payment_method`, and only WeChat requests include `app_type`
- [x] 1.4 Preserve cent-based request payloads and provide one shared yuan display formatter for amounts
- [x] 1.5 Confirm notification adapter parameters and response handling against the pending `add-personal-notifications` change
## 2. Login and asset state
- [x] 2.1 Show the backend business error and stop the current login flow when C-end login is forbidden
- [x] 2.2 Drive real-name prompts, status labels, and order gates from `effective_realname_policy`, `realname_required`, and `real_name_status`
- [x] 2.3 Remove local card/device real-name policy inference while preserving the existing real-name link flow
- [x] 2.4 Replace current-package-only expiry display with `estimated_final_expires_at` and apply backend expiry status/highlight fields
## 3. Package purchase and renewal
- [x] 3.1 Render payment options from `allowed_payment_methods` on package purchase and prevent unavailable methods from being selected
- [x] 3.2 Keep discontinued packages out of the ordinary package catalog for new customers and agents
- [x] 3.3 Add the eligible existing-customer renewal path using `current_package_id` or historical `package_ids`
- [x] 3.4 Reuse `POST /api/c/v1/orders/create` for renewal and preserve selected identifier, package IDs, payment method, and existing payment result handling
## 4. Wallet recharge and payment completion
- [x] 4.1 Call `/api/c/v1/wallet/recharge-check` before recharge and enforce backend force-recharge/min/max guidance in the UI
- [x] 4.2 Render recharge payment methods from the recharge-check response and submit the selected method
- [x] 4.3 Preserve existing WeChat `pay_config`, Alipay/other `payment_link`, wallet payment, and submit-guard behaviors
- [x] 4.4 Refresh and confirm order/recharge status from the backend after payment return or link completion
## 5. Customer notifications
- [x] 5.1 Refresh unread count at the homepage or notification entry using `/api/c/v1/notifications/unread-count`
- [x] 5.2 Display expiry and exchange notifications from `/api/c/v1/notifications`, including backend severity/expiry level
- [x] 5.3 Mark an item read with `/api/c/v1/notifications/{id}/read` when the user views or activates it, then reconcile the unread badge
- [x] 5.4 Keep notification failures non-blocking for asset loading and do not add WeCom, marketing, or ERP calls
## 6. Order display
- [x] 6.1 Render `purchase_role` from order list/detail responses
- [x] 6.2 Render `asset_identifier` snapshots, using device `virtual_no` first and `imei` second, never SN as a substitute
- [x] 6.3 Preserve blank historical data as an empty-state placeholder and format order amounts from cents to yuan
## 7. Verification
- [ ] 7.1 Add or update tests for login denial, all real-name policies, backend payment-method variations, and WeChat `app_type`
- [ ] 7.2 Add or update tests for force recharge, discontinued-package renewal, expiry estimate states, notification priority/read flow, and order identifier fallback
- [x] 7.3 Run the H5 compiler build and repository consistency checks; manual API fixture verification remains an integration follow-up
> Note: This H5 repository has no test script or test suite. Tasks 7.1 and 7.2 remain unchecked and require adding a test harness or backend/API fixture environment before they can be completed.

View File

@@ -44,6 +44,12 @@
"navigationBarTitleText": "我的订单" "navigationBarTitleText": "我的订单"
} }
}, },
{
"path": "pages/order-detail/order-detail",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{ {
"path": "pages/notifications/notifications", "path": "pages/notifications/notifications",
"style": { "style": {

View File

@@ -1,7 +1,8 @@
<template> <template>
<view class="container"> <view class="container">
<UserInfoCard :currentCardNo="currentCardNo" :deviceInfo="deviceInfo" :onlineStatus="onlineStatus" <UserInfoCard :currentCardNo="currentCardNo" :deviceInfo="deviceInfo" :onlineStatus="onlineStatus"
:isDevice="userInfo.isDevice" :networkStatus="deviceInfo.network_status" /> :isDevice="userInfo.isDevice" :networkStatus="deviceInfo.network_status"
:isExpiring="deviceInfo.isExpiring" :expiryDays="deviceInfo.expiryDays" />
<DeviceStatusCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" :isRealName="isRealName" <DeviceStatusCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" :isRealName="isRealName"
:isDevice="userInfo.isDevice" @authentication="enterDetail('authentication')" /> :isDevice="userInfo.isDevice" @authentication="enterDetail('authentication')" />
@@ -156,6 +157,9 @@
status: 1, status: 1,
packageName: '-', packageName: '-',
expireDate: '-', expireDate: '-',
isExpiring: false,
expiryDays: null,
expiryEstimateStatus: 'none',
walletBalance: 0, walletBalance: 0,
iccid: '-', iccid: '-',
currentIccid: '-', currentIccid: '-',
@@ -250,12 +254,16 @@
deviceInfo.asset_type = data.asset_type || 'device'; deviceInfo.asset_type = data.asset_type || 'device';
deviceInfo.bound_phone = data.bound_phone || ''; deviceInfo.bound_phone = data.bound_phone || '';
deviceInfo.packageName = data.current_package || '-'; deviceInfo.packageName = data.current_package || '-';
deviceInfo.expireDate = formatDate(data.current_package_expires_at); deviceInfo.expireDate = formatDate(data.estimated_final_expires_at);
deviceInfo.isExpiring = data.is_expiring === true;
deviceInfo.expiryDays = data.days_until_final_expiry ?? null;
deviceInfo.expiryEstimateStatus = data.expiry_estimate_status || 'none';
deviceInfo.iccid = data.iccid || '-'; deviceInfo.iccid = data.iccid || '-';
deviceInfo.walletBalance = data.wallet_balance ?? 0; deviceInfo.walletBalance = data.wallet_balance ?? 0;
const currentCard = resolveCurrentCard(data.cards || []); const currentCard = resolveCurrentCard(data.cards || []);
isRealName.value = !!currentCard?.real_name_at || currentCard?.real_name_status === 1 || data.real_name_status === 1; isRealName.value = Number(data.real_name_status) === 1;
realNameStatus.value = isRealName.value ? '已实名' : '未实名'; realNameStatus.value = isRealName.value ? '已实名' : '未实名';
userStore.setRealNameStatus(data.real_name_status);
boundPhone.value = data.bound_phone || ''; boundPhone.value = data.bound_phone || '';
alreadyBindPhone.value = !!data.bound_phone; alreadyBindPhone.value = !!data.bound_phone;
@@ -312,7 +320,7 @@
deviceInfo.signal_bad_reason = ''; deviceInfo.signal_bad_reason = '';
} }
// 到期时间由 TrafficCard 组件获取套餐信息时一并返回 // 最终到期时间和临期状态均以后端 asset/info 返回值为准。
return data; return data;
} catch (e) { } catch (e) {
console.error('加载资产信息失败', e); console.error('加载资产信息失败', e);
@@ -342,18 +350,6 @@
} }
}; };
// 处理套餐加载完成事件(从 TrafficCard 组件传递过来)
const handlePackageLoaded = (activePackage) => {
if (activePackage && activePackage.expires_at) {
// 格式化到期时间
const date = new Date(activePackage.expires_at);
deviceInfo.expireDate =
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
} else {
deviceInfo.expireDate = '-';
}
};
const modifyWifi = () => { const modifyWifi = () => {
wifi_info.ssid = deviceInfo.ssidName; wifi_info.ssid = deviceInfo.ssidName;
wifi_info.pwd = deviceInfo.ssidPwd; wifi_info.pwd = deviceInfo.ssidPwd;
@@ -810,7 +806,9 @@
const loadUnreadNotifications = async () => { const loadUnreadNotifications = async () => {
try { try {
const data = await notificationApi.getList(1, 50, false); const data = await notificationApi.getList(1, 50, false);
const items = (data?.items || []).filter(item => !item.is_read); const items = (data?.items || [])
.filter(item => !item.is_read)
.sort((a, b) => getNotificationPriority(b) - getNotificationPriority(a));
if (!items.length) return; if (!items.length) return;
notificationItems.value = items; notificationItems.value = items;
@@ -823,6 +821,17 @@
} }
}; };
const getNotificationPriority = (item) => {
const severityRank = { info: 10, warning: 20, error: 30, critical: 40 };
const remainingDays = Number(item?.days_until_expiry ?? item?.days_remaining ?? item?.remaining_days);
const expiryLevel = String(item?.expiry_level || '').toLowerCase();
if (item?.category === 'expiry' && ((Number.isFinite(remainingDays) && remainingDays >= 0 && remainingDays <= 3) ||
['0_3', '0-3', '0~3', 'critical'].includes(expiryLevel))) {
return 100;
}
return severityRank[item?.severity] || 0;
};
const onNotificationChange = (event) => { const onNotificationChange = (event) => {
const index = Number(event?.detail?.current ?? event?.current ?? 0); const index = Number(event?.detail?.current ?? event?.current ?? 0);
notificationPopupCurrent.value = index; notificationPopupCurrent.value = index;

View File

@@ -245,14 +245,27 @@
const doLogin = async () => { const doLogin = async () => {
loading.value = true; loading.value = true;
// 丢弃上一轮未完成授权留下的临时凭证,避免校验失败时继续复用。
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('assetToken');
}
try { try {
const verifyData = await authApi.verifyAsset(identifier.value); const verifyData = await authApi.verifyAsset(identifier.value);
if (!verifyData?.asset_token) {
throw { msg: '资产校验未返回有效登录凭证' };
}
userStore.setAssetToken(verifyData.asset_token); userStore.setAssetToken(verifyData.asset_token);
userStore.setIdentifier(identifier.value); userStore.setIdentifier(identifier.value);
await redirectToWxAuth(verifyData.asset_token); await redirectToWxAuth(verifyData.asset_token);
} catch (e) { } catch (e) {
console.error('登录失败', e); console.error('登录失败', e);
uni.showToast({
title: e?.msg || e?.message || '资产校验失败,请稍后重试',
icon: 'none',
duration: 2500
});
loading.value = false; loading.value = false;
} }
}; };

View File

@@ -155,13 +155,17 @@
</view> </view>
<view class="payment-methods"> <view class="payment-methods">
<view class="method-item" :class="{ active: rechargePaymentMethod === 'alipay' }" @tap="selectRechargePaymentMethod('alipay')"> <view v-for="method in paymentMethodOptions" :key="method.value" class="method-item"
:class="{ active: rechargePaymentMethod === method.value }" @tap="selectRechargePaymentMethod(method.value)">
<view class="method-left"> <view class="method-left">
<view class="method-icon method-badge method-badge-alipay"></view> <view v-if="method.value === 'alipay'" class="method-icon method-badge method-badge-alipay"></view>
<text class="method-name">支付宝支付</text> <view v-else-if="method.value === 'wechat'" class="method-icon method-badge"></view>
<image v-else class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
<text class="method-name">{{ method.label }}</text>
</view> </view>
<view class="method-radio" :class="{ checked: rechargePaymentMethod === 'alipay' }"></view> <view class="method-radio" :class="{ checked: rechargePaymentMethod === method.value }"></view>
</view> </view>
<view v-if="paymentMethodOptions.length === 0" class="method-empty">暂无可用支付方式</view>
</view> </view>
<view class="popup-footer"> <view class="popup-footer">
@@ -180,7 +184,7 @@
<script setup> <script setup>
import { ref, reactive, onMounted, computed } from 'vue'; import { ref, reactive, onMounted, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app'; import { onShow } from '@dcloudio/uni-app';
import { assetApi, walletApi } from '@/api/index.js'; import { walletApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js'; import { useUserStore } from '@/store/index.js';
import { import {
consumePendingPaymentRefresh, consumePendingPaymentRefresh,
@@ -192,6 +196,8 @@
showPaymentToast, showPaymentToast,
wechatH5Pay wechatH5Pay
} from '@/utils/payment.js'; } from '@/utils/payment.js';
import { formatMoney } from '@/utils/display.js';
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
const userStore = useUserStore(); const userStore = useUserStore();
@@ -221,11 +227,8 @@
const rechargePaymentMethod = ref('alipay'); const rechargePaymentMethod = ref('alipay');
const rechargeSubmitting = ref(false); const rechargeSubmitting = ref(false);
const rechargeOrderSubmittingKey = ref(null); const rechargeOrderSubmittingKey = ref(null);
const isDeviceAsset = ref(true); const rechargeAllowedPaymentMethods = ref([]);
let assetTypePromise = null; const paymentMethodOptions = computed(() => getPaymentMethodOptions(rechargeAllowedPaymentMethods.value));
const paymentMethodOptions = computed(() => [
{ label: '支付宝支付', value: 'alipay' }
]);
const rechargeAmounts = [ const rechargeAmounts = [
{ value: 1000, label: '10' }, { value: 1000, label: '10' },
{ value: 2000, label: '20' }, { value: 2000, label: '20' },
@@ -243,11 +246,6 @@
currentTab.value = e.detail.current; currentTab.value = e.detail.current;
}; };
const formatMoney = (amount) => {
if (!amount && amount !== 0) return '0.00';
return (amount / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
const formatDisplayMoney = (amount) => { const formatDisplayMoney = (amount) => {
if (!amount && amount !== 0) return '0'; if (!amount && amount !== 0) return '0';
return parseFloat(amount).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ','); return parseFloat(amount).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
@@ -298,34 +296,13 @@
return rechargeOrderSubmittingKey.value === getRechargeOrderSubmitKey(rechargeOrder); return rechargeOrderSubmittingKey.value === getRechargeOrderSubmitKey(rechargeOrder);
}; };
const getDefaultRechargePaymentMethod = () => 'alipay';
const normalizeRechargePaymentMethod = () => { const normalizeRechargePaymentMethod = () => {
const defaultMethod = getDefaultRechargePaymentMethod(); const defaultMethod = getDefaultPaymentMethod(rechargeAllowedPaymentMethods.value);
if (rechargePaymentMethod.value !== defaultMethod) { if (!paymentMethodOptions.value.some((item) => item.value === rechargePaymentMethod.value)) {
rechargePaymentMethod.value = defaultMethod; rechargePaymentMethod.value = defaultMethod;
} }
}; };
const loadAssetType = async () => {
if (!userStore.state.identifier) return;
if (assetTypePromise) return assetTypePromise;
assetTypePromise = assetApi.getInfo(userStore.state.identifier)
.then((data) => {
isDeviceAsset.value = data.asset_type === 'device';
normalizeRechargePaymentMethod();
})
.catch((error) => {
console.error('加载资产类型失败', error);
})
.finally(() => {
assetTypePromise = null;
});
return assetTypePromise;
};
const resetRechargeListAndLoad = () => { const resetRechargeListAndLoad = () => {
rechargePage.value = 1; rechargePage.value = 1;
rechargeNoMore.value = false; rechargeNoMore.value = false;
@@ -339,7 +316,6 @@
}; };
const syncWalletStatus = () => { const syncWalletStatus = () => {
loadAssetType();
loadWalletDetail(); loadWalletDetail();
resetRechargeListAndLoad(); resetRechargeListAndLoad();
resetTransactionListAndLoad(); resetTransactionListAndLoad();
@@ -359,10 +335,30 @@
}, 3000); }, 3000);
}; };
const applyRechargeCheck = (data) => {
if (Array.isArray(data?.allowed_payment_methods)) {
rechargeAllowedPaymentMethods.value = normalizePaymentMethods(data.allowed_payment_methods);
} else {
rechargeAllowedPaymentMethods.value = [];
}
normalizeRechargePaymentMethod();
};
const openRechargeModal = async () => { const openRechargeModal = async () => {
await loadAssetType(); try {
rechargePaymentMethod.value = getDefaultRechargePaymentMethod(); const data = await walletApi.rechargeCheck(userStore.state.identifier);
applyRechargeCheck(data);
if (!paymentMethodOptions.value.length) {
uni.showToast({ title: '暂无可用支付方式', icon: 'none' });
return;
}
rechargePaymentMethod.value = getDefaultPaymentMethod(
rechargeAllowedPaymentMethods.value
);
showRechargeModal.value = true; showRechargeModal.value = true;
} catch (error) {
console.error('充值前校验失败', error);
}
}; };
const selectAmount = (value) => { const selectAmount = (value) => {
@@ -377,7 +373,7 @@
}; };
const selectRechargePaymentMethod = (method) => { const selectRechargePaymentMethod = (method) => {
if (method !== getDefaultRechargePaymentMethod()) return; if (!paymentMethodOptions.value.some((item) => item.value === method)) return;
rechargePaymentMethod.value = method; rechargePaymentMethod.value = method;
}; };
@@ -504,6 +500,12 @@
}; };
const handleRechargeResult = async (rechargeData, paymentMethod) => { const handleRechargeResult = async (rechargeData, paymentMethod) => {
if (paymentMethod === 'wallet') {
showPaymentToast(true, '充值成功');
setTimeout(() => syncWalletStatus(), 1500);
return;
}
if (paymentMethod === 'wechat') { if (paymentMethod === 'wechat') {
if (!isValidWechatPayConfig(rechargeData?.pay_config)) { if (!isValidWechatPayConfig(rechargeData?.pay_config)) {
uni.showToast({ uni.showToast({
@@ -577,23 +579,31 @@
try { try {
const checkData = await walletApi.rechargeCheck(userStore.state.identifier); const checkData = await walletApi.rechargeCheck(userStore.state.identifier);
applyRechargeCheck(checkData);
if (!paymentMethodOptions.value.some((item) => item.value === rechargePaymentMethod.value)) {
throw { msg: '当前支付方式不可用,请重新选择' };
}
if (checkData.need_force_recharge && amount < checkData.force_recharge_amount) { const forceAmount = Number(checkData.force_recharge_amount || 0);
const minAmount = Number(checkData.min_amount || 0);
const maxAmount = Number(checkData.max_amount || Number.MAX_SAFE_INTEGER);
if (checkData.need_force_recharge && amount < forceAmount) {
uni.hideLoading(); uni.hideLoading();
uni.showModal({ uni.showModal({
title: '提示', title: '提示',
content: `${checkData.message || '当前需先充值最低金额'} ¥${(checkData.force_recharge_amount / 100).toFixed(2)}`, content: `${checkData.message || '当前需先充值最低金额'} ¥${formatMoney(forceAmount)}`,
showCancel: false showCancel: false
}); });
return; return;
} }
if (amount < checkData.min_amount || amount > checkData.max_amount) { if (amount < minAmount || amount > maxAmount) {
uni.hideLoading(); uni.hideLoading();
showRechargeModal.value = false; showRechargeModal.value = false;
setTimeout(() => { setTimeout(() => {
uni.showToast({ uni.showToast({
title: `充值金额范围:¥${(checkData.min_amount / 100).toFixed(2)} - ¥${(checkData.max_amount / 100).toFixed(2)}`, title: `充值金额范围:¥${formatMoney(minAmount)} - ¥${formatMoney(maxAmount)}`,
icon: 'none', icon: 'none',
duration: 2500 duration: 2500
}); });
@@ -628,7 +638,11 @@
const showRechargePaymentMethods = async (rechargeOrder) => { const showRechargePaymentMethods = async (rechargeOrder) => {
if (rechargeOrderSubmittingKey.value !== null) return; if (rechargeOrderSubmittingKey.value !== null) return;
await loadAssetType(); try {
applyRechargeCheck(await walletApi.rechargeCheck(userStore.state.identifier));
} catch (error) {
console.error('加载充值支付方式失败', error);
}
const options = paymentMethodOptions.value; const options = paymentMethodOptions.value;
uni.showActionSheet({ uni.showActionSheet({

View File

@@ -57,6 +57,7 @@
const getCategoryText = (category) => ({ const getCategoryText = (category) => ({
approval: '审批', approval: '审批',
expiry: '临期', expiry: '临期',
exchange: '换货',
sync: '同步', sync: '同步',
system: '系统' system: '系统'
}[category] || '通知'); }[category] || '通知');
@@ -68,6 +69,17 @@
critical: '严重' critical: '严重'
}[severity] || '提示'); }[severity] || '提示');
const getNotificationPriority = (item) => {
const severityRank = { info: 10, warning: 20, error: 30, critical: 40 };
const remainingDays = Number(item?.days_until_expiry ?? item?.days_remaining ?? item?.remaining_days);
const expiryLevel = String(item?.expiry_level || '').toLowerCase();
if (item?.category === 'expiry' && ((Number.isFinite(remainingDays) && remainingDays >= 0 && remainingDays <= 3) ||
['0_3', '0-3', '0~3', 'critical'].includes(expiryLevel))) {
return 100;
}
return severityRank[item?.severity] || 0;
};
const loadUnreadCount = async () => { const loadUnreadCount = async () => {
try { try {
const data = await notificationApi.getUnreadCount(); const data = await notificationApi.getUnreadCount();
@@ -82,7 +94,7 @@
loading.value = true; loading.value = true;
try { try {
const data = await notificationApi.getList(page.value, pageSize); const data = await notificationApi.getList(page.value, pageSize);
const items = data?.items || []; const items = (data?.items || []).sort((a, b) => getNotificationPriority(b) - getNotificationPriority(a));
if (append) { if (append) {
notifications.push(...items); notifications.push(...items);
} else { } else {

View File

@@ -0,0 +1,80 @@
<template>
<view class="container">
<view v-if="loading" class="state">加载中...</view>
<view v-else-if="!order" class="state">订单信息不存在</view>
<view v-else>
<view class="card">
<view class="card-title">订单信息</view>
<view class="info-row"><text>订单号</text><text>{{ order.order_no || '-' }}</text></view>
<view class="info-row"><text>购买角色</text><text>{{ order.purchase_role || '-' }}</text></view>
<view class="info-row"><text>资产标识</text><text>{{ order.asset_identifier || '-' }}</text></view>
<view class="info-row"><text>支付方式</text><text>{{ formatPaymentMethod(order.payment_method) }}</text></view>
<view class="info-row"><text>支付状态</text><text>{{ order.payment_status_name || getStatusText(order.payment_status) }}</text></view>
<view class="info-row"><text>订单金额</text><text class="amount">¥{{ formatMoney(order.total_amount) }}</text></view>
<view class="info-row"><text>创建时间</text><text>{{ formatDateTime(order.created_at) }}</text></view>
<view class="info-row"><text>支付时间</text><text>{{ formatDateTime(order.paid_at) }}</text></view>
<view class="info-row"><text>完成时间</text><text>{{ formatDateTime(order.completed_at) }}</text></view>
</view>
<view class="card">
<view class="card-title">套餐明细</view>
<view v-if="!order.packages?.length" class="empty-detail">暂无套餐明细</view>
<view v-for="item in order.packages || []" :key="item.package_id" class="package-row">
<view>
<view class="package-name">{{ item.package_name || '-' }}</view>
<view class="package-type">{{ item.package_type === 'addon' ? '加油包' : '正式套餐' }} × {{ item.quantity || 1 }}</view>
</view>
<view class="amount">¥{{ formatMoney(item.price) }}</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { orderApi } from '@/api/index.js';
import { formatDateTime, formatMoney } from '@/utils/display.js';
const order = ref(null);
const loading = ref(false);
const formatPaymentMethod = (method) => ({
wechat: '微信支付',
alipay: '支付宝支付',
wallet: '钱包支付'
}[method] || method || '-');
const getStatusText = (status) => ({
1: '待支付',
2: '已支付',
3: '已取消',
4: '已退款'
}[status] || '未知');
onLoad(async ({ id }) => {
if (!id) return;
loading.value = true;
try {
order.value = await orderApi.getDetail(id);
} catch (error) {
console.error('加载订单详情失败', error);
} finally {
loading.value = false;
}
});
</script>
<style lang="scss" scoped>
.container { min-height: 100vh; padding: 24rpx; background: var(--bg-secondary); box-sizing: border-box; }
.card { margin-bottom: 24rpx; padding: 28rpx; background: #fff; border-radius: 20rpx; }
.card-title { margin-bottom: 20rpx; font-size: 32rpx; font-weight: 600; color: var(--text-primary); }
.info-row, .package-row { display: flex; justify-content: space-between; gap: 24rpx; padding: 18rpx 0; border-bottom: 1rpx solid var(--gray-200); color: var(--text-secondary); font-size: 26rpx; }
.info-row:last-child, .package-row:last-child { border-bottom: 0; }
.info-row text:last-child { color: var(--text-primary); text-align: right; word-break: break-all; }
.amount { color: var(--danger) !important; font-weight: 600; }
.package-name { color: var(--text-primary); }
.package-type, .empty-detail, .state { color: var(--text-tertiary); }
.state { padding: 200rpx 0; text-align: center; }
</style>

View File

@@ -21,7 +21,7 @@
</view> </view>
<view v-else class="order-list"> <view v-else class="order-list">
<view class="order-card" v-for="item in orderList" :key="item.order_id"> <view class="order-card" v-for="item in orderList" :key="item.order_id" @tap="openOrderDetail(item)">
<view class="card-header"> <view class="card-header">
<view class="header-left"> <view class="header-left">
<view class="header-row"> <view class="header-row">
@@ -44,6 +44,14 @@
</view> </view>
</view> </view>
</view> </view>
<view class="info-item">
<view class="info-label">购买角色</view>
<view class="info-value">{{ item.purchase_role || '-' }}</view>
</view>
<view class="info-item">
<view class="info-label">资产标识</view>
<view class="info-value">{{ item.asset_identifier || '-' }}</view>
</view>
<view class="info-item"> <view class="info-item">
<view class="info-label">订单金额</view> <view class="info-label">订单金额</view>
<view class="info-value amount">¥{{ formatMoney(item.total_amount) }}</view> <view class="info-value amount">¥{{ formatMoney(item.total_amount) }}</view>
@@ -55,10 +63,13 @@
</view> </view>
<view v-if="item.payment_status === 1" class="card-footer"> <view v-if="item.payment_status === 1" class="card-footer">
<button class="btn-pay" :disabled="orderPayingId !== null" @tap="showOrderPaymentMethods(item)"> <button class="btn-pay" :disabled="orderPayingId !== null" @tap.stop="showOrderPaymentMethods(item)">
{{ isOrderPaying(item) ? '处理中...' : '立即支付' }} {{ isOrderPaying(item) ? '处理中...' : '立即支付' }}
</button> </button>
</view> </view>
<view v-else-if="item.payment_status === 2 && item.package_ids?.length" class="card-footer">
<button class="btn-pay btn-renew" @tap.stop="startHistoricalRenewal(item)">继续续费</button>
</view>
</view> </view>
</view> </view>
@@ -87,6 +98,8 @@
showPaymentToast, showPaymentToast,
wechatH5Pay wechatH5Pay
} from '@/utils/payment.js'; } from '@/utils/payment.js';
import { formatMoney } from '@/utils/display.js';
import { getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
const userStore = useUserStore(); const userStore = useUserStore();
@@ -97,12 +110,9 @@
const orderPayingId = ref(null); const orderPayingId = ref(null);
const pageSize = 10; const pageSize = 10;
const filterIndex = ref(0); const filterIndex = ref(0);
const isDeviceAsset = ref(true); const allowedPaymentMethods = ref([]);
let assetTypePromise = null; let assetTypePromise = null;
const paymentMethodOptions = computed(() => [ const paymentMethodOptions = computed(() => getPaymentMethodOptions(allowedPaymentMethods.value));
{ label: '支付宝支付', value: 'alipay' },
{ label: '钱包支付', value: 'wallet' }
]);
const filterOptions = [ const filterOptions = [
{ label: '全部', value: null }, { label: '全部', value: null },
{ label: '待支付', value: 1 }, { label: '待支付', value: 1 },
@@ -111,11 +121,6 @@
{ label: '已退款', value: 4 } { label: '已退款', value: 4 }
]; ];
const formatMoney = (amount) => {
if (!amount && amount !== 0) return '0.00';
return (amount / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
const getStatusClass = (status) => { const getStatusClass = (status) => {
const classMap = { const classMap = {
1: 'tag-warning', 1: 'tag-warning',
@@ -132,7 +137,7 @@
assetTypePromise = assetApi.getInfo(userStore.state.identifier) assetTypePromise = assetApi.getInfo(userStore.state.identifier)
.then((data) => { .then((data) => {
isDeviceAsset.value = data.asset_type === 'device'; allowedPaymentMethods.value = normalizePaymentMethods(data.allowed_payment_methods);
}) })
.catch((error) => { .catch((error) => {
console.error('加载资产类型失败', error); console.error('加载资产类型失败', error);
@@ -185,6 +190,7 @@
const newData = (data.items || []).map((item) => ({ const newData = (data.items || []).map((item) => ({
...item, ...item,
asset_identifier: item.asset_identifier || item.virtual_no || item.VirtualNo || item.imei || '',
payment_status_name: item.payment_status === 1 payment_status_name: item.payment_status === 1
? '待支付' ? '待支付'
: item.payment_status === 2 : item.payment_status === 2
@@ -223,10 +229,27 @@
const isOrderPaying = (order) => orderPayingId.value === order.order_id; const isOrderPaying = (order) => orderPayingId.value === order.order_id;
const openOrderDetail = (order) => {
if (!order?.order_id || orderPayingId.value !== null) return;
uni.navigateTo({ url: `/pages/order-detail/order-detail?id=${order.order_id}` });
};
const startHistoricalRenewal = (order) => {
const packageIds = Array.isArray(order?.package_ids) ? order.package_ids.filter(Boolean) : [];
if (!packageIds.length) return;
const packageNames = Array.isArray(order.package_names) ? order.package_names : [];
const query = `renewal_package_ids=${encodeURIComponent(packageIds.join(','))}&renewal_package_names=${encodeURIComponent(packageNames.join('|'))}`;
uni.navigateTo({ url: `/pages/package-order/package-order?${query}` });
};
const showOrderPaymentMethods = async (order) => { const showOrderPaymentMethods = async (order) => {
if (orderPayingId.value !== null || !order?.order_id) return; if (orderPayingId.value !== null || !order?.order_id) return;
await loadAssetType(); await loadAssetType();
const options = paymentMethodOptions.value; const options = paymentMethodOptions.value;
if (!options.length) {
uni.showToast({ title: '暂无可用支付方式', icon: 'none' });
return;
}
uni.showActionSheet({ uni.showActionSheet({
itemList: options.map((item) => item.label), itemList: options.map((item) => item.label),
@@ -253,7 +276,7 @@
uni.hideLoading(); uni.hideLoading();
if (paymentMethod === 'wallet') { if (paymentMethod === 'wallet') {
showPaymentToast(true, '支付成功'); await confirmOrderStatus(order);
setTimeout(() => { setTimeout(() => {
resetOrderListAndLoad(); resetOrderListAndLoad();
}, 1500); }, 1500);
@@ -271,7 +294,7 @@
try { try {
await wechatH5Pay(payData.pay_config); await wechatH5Pay(payData.pay_config);
showPaymentToast(true, '支付成功'); await confirmOrderStatus(order);
setTimeout(() => { setTimeout(() => {
resetOrderListAndLoad(); resetOrderListAndLoad();
}, 1500); }, 1500);
@@ -302,6 +325,17 @@
} }
}; };
const confirmOrderStatus = async (order) => {
try {
const detail = await orderApi.getDetail(order.order_id);
const status = detail?.payment_status ?? detail?.order?.payment_status;
showPaymentToast(status === 2, status === 2 ? '支付成功' : '支付结果确认中');
} catch (error) {
console.error('确认订单支付状态失败', error);
showPaymentToast(false, '支付结果确认失败,请稍后查看订单');
}
};
onShow(() => { onShow(() => {
if (!consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.ORDER_LIST)) { if (!consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.ORDER_LIST)) {
return; return;
@@ -508,6 +542,8 @@
&::after { &::after {
border: none; border: none;
} }
&.btn-renew { background: #52c41a; }
} }
} }
} }

View File

@@ -9,8 +9,8 @@
<view class="card package-card" v-for="item in packageList" :key="item.package_id"> <view class="card package-card" v-for="item in packageList" :key="item.package_id">
<view class="package-header"> <view class="package-header">
<view class="package-name">{{ item.package_name }}</view> <view class="package-name">{{ item.package_name }}</view>
<view class="tag-apple" :class="item.is_addon ? 'tag-warning' : 'tag-primary'"> <view class="tag-apple" :class="item.is_renewal ? 'tag-warning' : (item.is_addon ? 'tag-warning' : 'tag-primary')">
{{ item.is_addon ? '加油包' : '正式套餐' }} {{ item.is_renewal ? '续费套餐' : (item.is_addon ? '加油包' : '正式套餐') }}
</view> </view>
</view> </view>
<view class="package-main"> <view class="package-main">
@@ -27,7 +27,7 @@
<view class="package-footer"> <view class="package-footer">
<view class="price-block"> <view class="price-block">
<text class="price-symbol">¥</text> <text class="price-symbol">¥</text>
<text class="package-price">{{ formatMoney(item.retail_price) }}</text> <text class="package-price">{{ formatPackagePrice(item.retail_price) }}</text>
</view> </view>
<view class="btn"> <view class="btn">
<up-button type="primary" @click="buyPackage(item)">立即订购</up-button> <up-button type="primary" @click="buyPackage(item)">立即订购</up-button>
@@ -44,7 +44,7 @@
<view class="package-summary"> <view class="package-summary">
<view class="summary-name">{{ currentPackage?.package_name }}</view> <view class="summary-name">{{ currentPackage?.package_name }}</view>
<view class="summary-price">¥{{ formatMoney(currentPackage?.retail_price) }}</view> <view class="summary-price">¥{{ formatPackagePrice(currentPackage?.retail_price) }}</view>
<view class="summary-details"> <view class="summary-details">
<view class="detail-item"> <view class="detail-item">
<text class="detail-label">套餐流量</text> <text class="detail-label">套餐流量</text>
@@ -59,21 +59,17 @@
</view> </view>
<view class="payment-methods"> <view class="payment-methods">
<view class="method-item" :class="{ active: paymentMethod === 'alipay' }" @tap="selectPaymentMethod('alipay')"> <view v-for="method in paymentMethodOptions" :key="method.value" class="method-item"
:class="{ active: paymentMethod === method.value }" @tap="selectPaymentMethod(method.value)">
<view class="method-left"> <view class="method-left">
<view class="method-icon method-badge method-badge-alipay"></view> <view v-if="method.value === 'alipay'" class="method-icon method-badge method-badge-alipay"></view>
<text class="method-name">支付宝支付</text> <image v-else-if="method.value === 'wallet'" class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
<view v-else class="method-icon method-badge"></view>
<text class="method-name">{{ method.label }}<template v-if="method.value === 'wallet'">¥{{ formatMoney(walletBalance) }}</template></text>
</view> </view>
<view class="method-radio" :class="{ checked: paymentMethod === 'alipay' }"></view> <view class="method-radio" :class="{ checked: paymentMethod === method.value }"></view>
</view>
<view class="method-item" :class="{ active: paymentMethod === 'wallet' }" @tap="selectPaymentMethod('wallet')">
<view class="method-left">
<image class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
<text class="method-name">账户余额¥{{ formatMoney(walletBalance) }}</text>
</view>
<view class="method-radio" :class="{ checked: paymentMethod === 'wallet' }"></view>
</view> </view>
<view v-if="paymentMethodOptions.length === 0" class="method-empty">暂无可用支付方式</view>
</view> </view>
<view class="popup-footer"> <view class="popup-footer">
@@ -88,8 +84,8 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue'; import { ref, reactive, onMounted, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app'; import { onLoad, onShow } from '@dcloudio/uni-app';
import { assetApi, orderApi, walletApi } from '@/api/index.js'; import { assetApi, orderApi, walletApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js'; import { useUserStore } from '@/store/index.js';
import { import {
@@ -102,6 +98,8 @@
showPaymentToast, showPaymentToast,
wechatH5Pay wechatH5Pay
} from '@/utils/payment.js'; } from '@/utils/payment.js';
import { formatMoney } from '@/utils/display.js';
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
const userStore = useUserStore(); const userStore = useUserStore();
@@ -112,13 +110,12 @@
const paymentMethod = ref('alipay'); const paymentMethod = ref('alipay');
const walletBalance = ref(0); const walletBalance = ref(0);
const paySubmitting = ref(false); const paySubmitting = ref(false);
const isDeviceAsset = ref(true); const assetInfo = ref({});
const allowedPaymentMethods = ref([]);
const renewalPackageIds = ref([]);
const renewalPackageNames = ref([]);
let assetTypePromise = null; let assetTypePromise = null;
const paymentMethodOptions = computed(() => getPaymentMethodOptions(allowedPaymentMethods.value));
const formatMoney = (amount) => {
if (!amount && amount !== 0) return '0.00';
return (amount / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
const formatData = (allowance, unit) => { const formatData = (allowance, unit) => {
if (unit === 'MB') { if (unit === 'MB') {
@@ -127,18 +124,15 @@
return `${allowance} ${unit}`; return `${allowance} ${unit}`;
}; };
const getDefaultPaymentMethod = () => 'alipay'; const formatPackagePrice = (amount) => amount === null || amount === undefined ? '-' : formatMoney(amount);
const isPaymentMethodAvailable = (method) => { const isPaymentMethodAvailable = (method) => {
if (method === 'wallet') return true; return paymentMethodOptions.value.some((item) => item.value === method);
if (method === 'wechat') return false;
if (method === 'alipay') return true;
return false;
}; };
const normalizePaymentMethod = () => { const normalizePaymentMethod = () => {
if (!isPaymentMethodAvailable(paymentMethod.value)) { if (!isPaymentMethodAvailable(paymentMethod.value)) {
paymentMethod.value = getDefaultPaymentMethod(); paymentMethod.value = getDefaultPaymentMethod(allowedPaymentMethods.value);
} }
}; };
@@ -148,7 +142,8 @@
assetTypePromise = assetApi.getInfo(userStore.state.identifier) assetTypePromise = assetApi.getInfo(userStore.state.identifier)
.then((data) => { .then((data) => {
isDeviceAsset.value = data.asset_type === 'device'; assetInfo.value = data;
allowedPaymentMethods.value = normalizePaymentMethods(data.allowed_payment_methods);
normalizePaymentMethod(); normalizePaymentMethod();
}) })
.catch((error) => { .catch((error) => {
@@ -165,7 +160,31 @@
loading.value = true; loading.value = true;
try { try {
const data = await assetApi.getPackages(userStore.state.identifier); const data = await assetApi.getPackages(userStore.state.identifier);
packageList.splice(0, packageList.length, ...(data.packages || [])); const currentPackageId = Number(assetInfo.value.current_package_id || 0);
const packages = (data.packages || []).filter((item) => {
const status = String(item.status || '').toLowerCase();
const discontinued = item.is_on_sale === false || item.on_sale === false ||
['off_shelf', 'offline', 'discontinued', '下架'].includes(status);
return !discontinued || Number(item.package_id) === currentPackageId;
}).map((item) => ({
...item,
is_renewal: Number(item.package_id) === currentPackageId &&
(item.is_on_sale === false || item.on_sale === false ||
['off_shelf', 'offline', 'discontinued', '下架'].includes(String(item.status || '').toLowerCase()))
}));
const packageIdsInList = new Set(packages.map((item) => Number(item.package_id)));
const renewalOnlyPackages = renewalPackageIds.value
.filter((packageId) => !packageIdsInList.has(Number(packageId)))
.map((packageId, index) => ({
package_id: Number(packageId),
package_name: renewalPackageNames.value[index] || `历史套餐 ${packageId}`,
retail_price: null,
data_allowance: 0,
data_unit: 'MB',
validity_days: '-',
is_renewal: true
}));
packageList.splice(0, packageList.length, ...packages, ...renewalOnlyPackages);
} catch (error) { } catch (error) {
console.error('加载套餐列表失败', error); console.error('加载套餐列表失败', error);
} }
@@ -181,16 +200,32 @@
} }
}; };
const syncPackagePageState = () => { const syncPackagePageState = async () => {
loadAssetType(); await loadAssetType();
loadPackages(); await Promise.all([loadPackages(), loadWalletBalance()]);
loadWalletBalance();
}; };
const buyPackage = async (item) => { const buyPackage = async (item) => {
await loadAssetType(); await loadAssetType();
if (assetInfo.value.effective_realname_policy === 'before_order' &&
assetInfo.value.realname_required && Number(assetInfo.value.real_name_status) !== 1) {
uni.showModal({
title: '需要实名认证',
content: '当前资产下单前需要完成实名认证',
confirmText: '去实名',
cancelText: '取消',
success: ({ confirm }) => {
if (confirm) uni.navigateTo({ url: '/pages/auth/auth' });
}
});
return;
}
if (!paymentMethodOptions.value.length) {
uni.showToast({ title: '暂无可用支付方式', icon: 'none' });
return;
}
currentPackage.value = item; currentPackage.value = item;
paymentMethod.value = getDefaultPaymentMethod(); paymentMethod.value = getDefaultPaymentMethod(allowedPaymentMethods.value);
showModal.value = true; showModal.value = true;
}; };
@@ -204,10 +239,16 @@
isValidAlipayPaymentLink(paymentData?.payment_link); isValidAlipayPaymentLink(paymentData?.payment_link);
}; };
const handleWechatPay = async (payConfig, isForceRecharge = false) => { const handleWechatPay = async (payConfig, isForceRecharge = false, orderId = null) => {
try { try {
await wechatH5Pay(payConfig); await wechatH5Pay(payConfig);
if (orderId) {
const detail = await orderApi.getDetail(orderId);
const status = detail?.payment_status ?? detail?.order?.payment_status;
showPaymentToast(status === 2, status === 2 ? '支付成功' : '支付结果确认中');
} else {
showPaymentToast(true, isForceRecharge ? '充值成功,套餐将自动购买' : '支付成功'); showPaymentToast(true, isForceRecharge ? '充值成功,套餐将自动购买' : '支付成功');
}
setTimeout(() => { setTimeout(() => {
loadWalletBalance(); loadWalletBalance();
}, 1500); }, 1500);
@@ -216,9 +257,9 @@
} }
}; };
const handlePreparedPayment = async (paymentData, isForceRecharge = false) => { const handlePreparedPayment = async (paymentData, isForceRecharge = false, orderId = null) => {
if (isValidWechatPayConfig(paymentData?.pay_config)) { if (isValidWechatPayConfig(paymentData?.pay_config)) {
await handleWechatPay(paymentData.pay_config, isForceRecharge); await handleWechatPay(paymentData.pay_config, isForceRecharge, orderId);
return; return;
} }
@@ -234,11 +275,13 @@
}; };
const getCreateOrderPaymentMethod = () => { const getCreateOrderPaymentMethod = () => {
if (paymentMethod.value === 'alipay') return 'alipay'; return paymentMethod.value;
if (paymentMethod.value === 'wallet') return 'alipay';
return undefined;
}; };
const getCreateOrderPackageIds = () => renewalPackageIds.value.length
? renewalPackageIds.value
: [currentPackage.value.package_id];
const confirmPay = async () => { const confirmPay = async () => {
if (paySubmitting.value || !currentPackage.value) return; if (paySubmitting.value || !currentPackage.value) return;
@@ -253,7 +296,7 @@
try { try {
const orderResult = await orderApi.create( const orderResult = await orderApi.create(
userStore.state.identifier, userStore.state.identifier,
[currentPackage.value.package_id], getCreateOrderPackageIds(),
getCreateOrderPaymentMethod() getCreateOrderPaymentMethod()
); );
@@ -348,17 +391,20 @@
showModal.value = false; showModal.value = false;
if (paymentMethod.value === 'wallet') { if (paymentMethod.value === 'wallet') {
uni.showToast({ try {
title: '支付成功', const detail = await orderApi.getDetail(orderResult.order.order_id);
icon: 'success' const status = detail?.payment_status ?? detail?.order?.payment_status;
}); showPaymentToast(status === 2, status === 2 ? '支付成功' : '支付结果确认中');
} catch (error) {
showPaymentToast(false, '支付结果确认失败,请稍后查看订单');
}
setTimeout(() => { setTimeout(() => {
loadWalletBalance(); loadWalletBalance();
}, 1500); }, 1500);
return; return;
} }
await handlePreparedPayment(payResult); await handlePreparedPayment(payResult, false, orderResult.order.order_id);
} catch (error) { } catch (error) {
uni.hideLoading(); uni.hideLoading();
showModal.value = false; showModal.value = false;
@@ -408,6 +454,21 @@
}); });
}); });
onLoad((query = {}) => {
const ids = String(query.renewal_package_ids || '')
.split(',')
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0);
renewalPackageIds.value = [...new Set(ids)];
try {
renewalPackageNames.value = decodeURIComponent(String(query.renewal_package_names || ''))
.split('|')
.filter(Boolean);
} catch (error) {
renewalPackageNames.value = [];
}
});
onMounted(() => { onMounted(() => {
syncPackagePageState(); syncPackagePageState();
}); });

18
utils/display.js Normal file
View File

@@ -0,0 +1,18 @@
export const formatMoney = (amount) => {
if (amount === null || amount === undefined || amount === '') return '0.00';
const cents = Number(amount);
if (!Number.isFinite(cents)) return '0.00';
return (cents / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
export const formatDate = (value, fallback = '-') => {
if (!value) return fallback;
return String(value).replace('T', ' ').slice(0, 10);
};
export const formatDateTime = (value, fallback = '-') => {
if (!value) return fallback;
return String(value).replace('T', ' ').slice(0, 19);
};

21
utils/payment-methods.js Normal file
View File

@@ -0,0 +1,21 @@
export const PAYMENT_METHOD_LABELS = {
wechat: '微信支付',
alipay: '支付宝支付',
wallet: '钱包支付'
};
export const normalizePaymentMethods = (methods) => {
if (!Array.isArray(methods)) return [];
return [...new Set(methods.filter((method) => Object.prototype.hasOwnProperty.call(PAYMENT_METHOD_LABELS, method)))];
};
export const getPaymentMethodOptions = (methods, preferredOrder = ['alipay', 'wechat', 'wallet']) => {
const normalized = normalizePaymentMethods(methods);
return preferredOrder
.filter((method) => normalized.includes(method))
.map((value) => ({ value, label: PAYMENT_METHOD_LABELS[value] }));
};
export const getDefaultPaymentMethod = (methods, preferredOrder) =>
getPaymentMethodOptions(methods, preferredOrder)[0]?.value || '';