Compare commits

...

24 Commits

Author SHA1 Message Date
luo
d90a51ebf4 fix: 切换运营商
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 52s
2026-07-03 12:29:16 +08:00
luo
5b239c13b7 feat: 登录支持条形码
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 55s
2026-07-03 12:17:32 +08:00
luo
8397a51f8c fix: 切换运营商
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 55s
2026-07-03 11:47:30 +08:00
luo
a9fbbea1e6 fix: pay
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 56s
2026-07-03 09:07:28 +08:00
luo
6756ad5700 fix: 实名认证运营商分类
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 55s
2026-07-02 15:58:23 +08:00
luo
f581ccaa9b fix: 实名认证运营商分类
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 51s
2026-07-02 15:55:12 +08:00
luo
832c2852c7 fix: 实名认证运营商分类
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 47s
2026-07-02 15:40:49 +08:00
luo
3e81a216ad fix: 实名认证运营商分类
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 55s
2026-07-02 15:33:38 +08:00
luo
85cdeac804 fix:pay
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 52s
2026-06-26 18:01:01 +08:00
luo
59e4e1203e fix: pay
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 52s
2026-06-26 16:33:33 +08:00
luo
4dc86889c5 fix:bug scan
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 55s
2026-06-26 14:59:15 +08:00
luo
60fa91ea25 fix: tips
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m47s
2026-06-16 16:09:49 +08:00
sexygoat
50e4be4976 fix: 资产套餐历史流量显示逻辑
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 3m5s
2026-06-11 14:36:31 +08:00
sexygoat
5d78f24366 fix: 资产套餐历史流量显示逻辑
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 55s
2026-06-11 14:24:48 +08:00
sexygoat
069eab0462 fix: 换货
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m49s
2026-06-03 17:53:52 +08:00
sexygoat
c265dacafd fix: 修改套餐位置
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 52s
2026-05-28 14:26:49 +08:00
sexygoat
83c32279eb fix: 修改套餐位置
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 54s
2026-05-28 14:21:24 +08:00
sexygoat
169d2bdaad fix: 修改套餐位置
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 53s
2026-05-28 14:16:07 +08:00
sexygoat
368c0d75e7 fix:bug
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 55s
2026-05-21 16:40:43 +08:00
sexygoat
a0b2f69a8e fix: bug
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 11s
2026-05-21 16:21:23 +08:00
sexygoat
b566b0a5ea fix: bug
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m1s
2026-05-21 16:17:48 +08:00
sexygoat
4d9aa59254 fix:单卡设备显示字段
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 56s
2026-05-21 15:04:26 +08:00
sexygoat
1edd299f1b fix:单卡设备显示字段
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 12s
2026-05-21 14:44:46 +08:00
sexygoat
f9fead83cf fix:单卡设备显示字段
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Has been cancelled
2026-05-21 14:44:10 +08:00
35 changed files with 1799 additions and 260 deletions

View File

@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(npm list:*)"
"Bash(npm list:*)",
"Bash(find . -type f \\\\\\(-name *.openapi.* -o -name *-spec.yaml -o -name *-spec.yml \\\\\\))"
],
"deny": [],
"ask": []

1
.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_BASE_URL=https://cmp-api.boss160.cn

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_BASE_URL=https://cmp-api.xm-iot.cn

View File

@@ -58,6 +58,7 @@ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist/build/h5 /usr/share/nginx/html
COPY MP_verify_yU2Z8mp831jh6QX7.txt /usr/share/nginx/html/MP_verify_yU2Z8mp831jh6QX7.txt
COPY MP_verify_0UoX8yClVgREjgPj.txt /usr/share/nginx/html/MP_verify_0UoX8yClVgREjgPj.txt
COPY MP_verify_4E1dVbOcZ9KzTzoc.txt /usr/share/nginx/html/MP_verify_4E1dVbOcZ9KzTzoc.txt
# 暴露端口
EXPOSE 80

View File

@@ -1,11 +1,12 @@
import request from '@/utils/request.js';
export const realnameApi = {
getLink(identifier, iccid) {
getLink(identifier, iccid, options = {}) {
return request({
url: '/api/c/v1/realname/link',
method: 'GET',
data: { identifier, iccid }
data: { identifier, iccid },
...options
});
}
};
};

View File

@@ -86,17 +86,17 @@
</view>
<view class="function-name">{{ walletText }}</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'recover')"
role="button" tabindex="0">
<view class="function-icon">
<image src="/static/recover.png" mode="aspectFit" alt="重启设备"></image>
</view>
<view class="function-name">重启设备</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'restart')"
role="button" tabindex="0">
<view class="function-icon">
<image src="/static/restart.png" mode="aspectFit" alt="恢复出厂"></image>
<image src="/static/restart.png" mode="aspectFit" alt="重启设备"></image>
</view>
<view class="function-name">重启设备</view>
</view>
<view class="function-item interactive card-interactive" v-if="isDevice" @tap="$emit('enter', 'recover')"
role="button" tabindex="0">
<view class="function-icon">
<image src="/static/recover.png" mode="aspectFit" alt="恢复出厂"></image>
</view>
<view class="function-name">恢复出厂</view>
</view>

View File

@@ -5,7 +5,16 @@
<view class="flex-row-g20">
<view class="title">{{ currentCardNo || '-' }}</view>
</view>
<view class="caption">ICCID: {{ deviceInfo.iccid || '-' }}</view>
<view class="iccid-row caption">
<text>ICCID: {{ deviceInfo.iccid || '-' }}</text>
<button
v-if="deviceInfo.iccid && deviceInfo.iccid !== '-'"
class="copy-btn"
@tap.stop="$emit('copy', deviceInfo.iccid)"
>
复制
</button>
</view>
<view class="caption">套餐名称{{ deviceInfo.packageName || '-' }}</view>
<view class="caption">套餐到期时间{{ deviceInfo.expireDate || '-' }}</view>
</view>
@@ -27,6 +36,8 @@
networkStatus: { type: [String, Number], default: '离线' },
isDevice: { type: Boolean, default: true }
});
defineEmits(['copy']);
</script>
<style scoped lang="scss">
@@ -36,5 +47,29 @@
.user-details {
flex: 1;
}
.iccid-row {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
}
.copy-btn {
margin: 0;
padding: 0 12rpx;
min-height: 36rpx;
line-height: 36rpx;
border: none;
border-radius: 6rpx;
background: rgba(10, 132, 255, 0.12);
color: var(--primary);
font-size: 22rpx;
font-weight: 600;
}
.copy-btn::after {
border: none;
}
}
</style>

View File

@@ -18,6 +18,8 @@ server {
}
location / {
alias /usr/share/nginx/html/;
index index.html;
try_files $uri $uri/ /index.html;
}

436
docs/换货.md Normal file
View File

@@ -0,0 +1,436 @@
# 换货流程升级接口变更说明
## 一、数据库字段新增
### 表:`tb_exchange_order`
新增以下字段,所有换货单相关接口响应均会返回:
| 字段 | 类型 | 说明 |
| ------------ | ------------- | ---------------------------------------------------------- |
| flow_type | string | 流程类型:`shipping`(物流换货)或 `direct`(直接换货),历史数据为空时视为 `shipping` |
| shipped_at | string | null | 发货时间,仅 `shipping` 流程发货后有值 |
| completed_at | string | null | 换货完成时间,换货完成后有值 |
---
# 二、后台接口变更
## 1. 创建换货单
### POST /api/admin/exchanges
### 新增请求参数
```json
{
"old_asset_type": "iot_card",
"old_identifier": "xxx",
"exchange_reason": "设备损坏",
"flow_type": "shipping",
"new_identifier": "yyy",
"migrate_data": false,
"remark": null
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
| -------------- | ------------- | ---------- | ---------------------------------------- |
| flow_type | string | 否 | 流程类型:`shipping` / `direct`,默认 `shipping` |
| new_identifier | string | direct 时必填 | 新资产标识符 |
| migrate_data | boolean | 否 | 是否迁移数据,默认 false |
| remark | string | null | 否 | 备注 |
### 不同流程行为差异
| flow_type | 创建后状态(status) | completed_at |
| --------- | ------------- | ------------ |
| shipping | 1待填写信息 | null |
| direct | 4已完成 | 当前时间 |
### 说明
* shipping 为原有流程
* direct 创建后直接完成
* direct 不需要填写地址
* direct 不需要发货
* direct 不需要确认完成
* 非法 flow_type 返回参数错误
---
## 2. 换货单列表
### GET /api/admin/exchanges
### 新增查询参数
| 参数 | 类型 | 说明 |
| ---------- | ------ | ------------------------ |
| flow_type | string | 按流程类型筛选shipping/direct |
| identifier | string | 资产标识符模糊查询(同时匹配旧资产、新资产) |
### 响应新增字段
```json
{
"flow_type": "shipping",
"flow_type_name": "物流换货",
"shipped_at": null,
"completed_at": null
}
```
---
## 3. 换货单详情
### GET /api/admin/exchanges/:id
### 响应新增字段
```json
{
"flow_type": "shipping",
"flow_type_name": "物流换货",
"shipped_at": null,
"completed_at": null
}
```
### 说明
对于 direct 类型:
* 收货信息为空
* 物流信息为空
属于正常情况,前端需根据 flow_type 控制相关区块显示。
---
## 4. 发货
### POST /api/admin/exchanges/:id/ship
### 新增限制
仅支持:
```text
flow_type = shipping
```
### 不允许情况
```text
flow_type = direct
```
返回:
```json
{
"code": 400,
"message": "流程类型不支持该操作"
}
```
### 前端处理
```javascript
exchange.flow_type === 'shipping'
```
---
## 5. 确认完成
### POST /api/admin/exchanges/:id/complete
### 新增限制
仅支持:
```text
flow_type = shipping
status = 3
```
### 不允许情况
```text
flow_type = direct
```
返回错误。
---
## 6. 取消换货
### POST /api/admin/exchanges/:id/cancel
### 新增限制
允许取消:
```text
flow_type = shipping
status IN (1, 2)
```
### 不允许取消
#### direct 单据
```text
flow_type = direct
```
#### 已发货单据
```text
flow_type = shipping
status = 3
```
返回错误。
---
# 三、客户端接口变更
## 1. 查询进行中的换货单
### GET /api/c/v1/exchange/pending
### 变更后返回规则
仅返回:
```text
flow_type = shipping
status IN (1, 2, 3)
```
### 不返回
```text
flow_type = direct
```
客户端无需特殊处理。
---
## 2. 填写收货地址
### POST /api/c/v1/exchange/:id/shipping-info
### 新增限制
仅支持:
```text
flow_type = shipping
status = 1
```
### 不允许
```text
flow_type = direct
```
返回错误。
---
# 四、前端 UI 改造建议
## 换货单详情页
### 新增展示字段
* 流程类型flow_type_name
* 完成时间completed_at
* 发货时间shipped_at
### 按流程类型控制按钮
#### direct
不显示:
* 发货
* 确认完成
* 取消
#### shipping + status = 1
显示:
* 取消
#### shipping + status = 2
显示:
* 发货
* 取消
#### shipping + status = 3
显示:
* 确认完成
### 信息区块控制
#### direct
隐藏:
* 收货信息
* 物流信息
或者显示:
```text
不适用
```
---
## 创建换货单弹窗
### 新增字段
流程类型:
* 物流换货shipping
* 直接换货direct
### 选择 direct 时
显示:
* 新资产标识符(必填)
* 是否迁移数据(开关)
对应参数:
```json
{
"new_identifier": "xxx",
"migrate_data": true
}
```
### 选择 shipping 时
隐藏:
* 新资产标识符
* 是否迁移数据
---
## 换货单列表页
### 新增表格列
```text
流程类型
```
显示:
```text
物流换货
直接换货
```
### 新增筛选项
```text
流程类型
```
下拉选项:
```text
全部
物流换货shipping
直接换货direct
```
---
# 五、流程状态说明
## shipping物流换货
```text
创建
待填写信息1
待发货2
已发货3
已完成4
```
## direct直接换货
```text
创建
已完成4
```
---
# 六、状态与操作矩阵
| flow_type | status | 发货 | 确认完成 | 取消 |
| --------- | ------ | -- | ---- | -- |
| shipping | 1 | ❌ | ❌ | ✅ |
| shipping | 2 | ✅ | ❌ | ✅ |
| shipping | 3 | ❌ | ✅ | ❌ |
| shipping | 4 | ❌ | ❌ | ❌ |
| direct | 4 | ❌ | ❌ | ❌ |
---
# 七、枚举定义
## flow_type
```json
{
"shipping": "物流换货",
"direct": "直接换货"
}
```
## flow_type_name 示例
```json
{
"flow_type": "shipping",
"flow_type_name": "物流换货"
}
```
```json
{
"flow_type": "direct",
"flow_type_name": "直接换货"
}
```

View File

@@ -6,6 +6,7 @@
<title></title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans@4.1.0/lib/Normal/MiSans-Regular.min.css"/>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>window.__WX_ENTRY_URL__ = window.location.href.split('#')[0]</script>
</head>
<body>
<div id="app"><!--app-html--></div>

47
main.js
View File

@@ -1,11 +1,55 @@
import App from './App'
import uviewPlus from 'uview-plus'
const TAB_BAR_STYLE_NOT_FOUND_ERROR = 'setTabBarStyle:fail not TabBar page'
const shouldIgnoreTabBarStyleError = (error) => {
const message = typeof error === 'string'
? error
: error?.errMsg || error?.message || ''
return message.includes(TAB_BAR_STYLE_NOT_FOUND_ERROR)
}
const patchSafeSetTabBarStyle = () => {
if (typeof uni === 'undefined' || typeof uni.setTabBarStyle !== 'function') {
return
}
if (uni.setTabBarStyle.__upSafePatched) {
return
}
const originalSetTabBarStyle = uni.setTabBarStyle.bind(uni)
const safeSetTabBarStyle = (options = {}) => {
try {
const result = originalSetTabBarStyle(options)
if (result && typeof result.catch === 'function') {
return result.catch((error) => {
if (shouldIgnoreTabBarStyleError(error)) {
return undefined
}
return Promise.reject(error)
})
}
return result
} catch (error) {
if (shouldIgnoreTabBarStyleError(error)) {
return undefined
}
throw error
}
}
safeSetTabBarStyle.__upSafePatched = true
uni.setTabBarStyle = safeSetTabBarStyle
}
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
patchSafeSetTabBarStyle()
const app = new Vue({
...App
})
@@ -15,6 +59,7 @@ app.$mount()
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
patchSafeSetTabBarStyle()
const app = createSSRApp(App)
// 使用 uview-plus (版本 3.4.0+)
app.use(uviewPlus, () => {
@@ -34,4 +79,4 @@ export function createApp() {
app
}
}
// #endif
// #endif

View File

@@ -1,29 +1,29 @@
## 1. Shared Payment Branching
- [ ] 1.1 Add shared front-end handling for Alipay `payment_link` responses alongside the existing WeChat `pay_config` flow
- [ ] 1.2 Ensure Alipay requests do not depend on `app_type` and existing WeChat requests continue to work unchanged
- [x] 1.1 Add shared front-end handling for Alipay `payment_link` responses alongside the existing WeChat `pay_config` flow
- [x] 1.2 Ensure Alipay requests do not depend on `app_type` and existing WeChat requests continue to work unchanged
## 2. Package Purchase Flow
- [ ] 2.1 Add Alipay as a selectable payment method in `pages/package-order/package-order.vue`
- [ ] 2.2 Handle normal package order payment responses for `wechat`, `wallet`, and `alipay`
- [ ] 2.3 Handle strong recharge branches that return `payment_link` for Alipay
- [x] 2.1 Add Alipay as a selectable payment method in `pages/package-order/package-order.vue`
- [x] 2.2 Handle normal package order payment responses for `wechat`, `wallet`, and `alipay`
- [x] 2.3 Handle strong recharge branches that return `payment_link` for Alipay
## 3. Order List Flow
- [ ] 3.1 Add an order-list payment entry that lets the user continue pending orders with Alipay
- [ ] 3.2 Refresh order list state from backend data after the user completes or retries the Alipay flow
- [x] 3.1 Add an order-list payment entry that lets the user continue pending orders with Alipay
- [x] 3.2 Refresh order list state from backend data after the user completes or retries the Alipay flow
## 4. Wallet Flow
- [ ] 4.1 Add Alipay as a selectable payment method in the wallet recharge popup
- [ ] 4.2 Handle Alipay recharge creation responses in `confirmRecharge`
- [ ] 4.3 Handle Alipay payment retries for pending recharge orders in `handleRechargePayment`
- [ ] 4.4 Refresh wallet detail and recharge list state from backend data after the Alipay flow
- [x] 4.1 Add Alipay as a selectable payment method in the wallet recharge popup
- [x] 4.2 Handle Alipay recharge creation responses in `confirmRecharge`
- [x] 4.3 Handle Alipay payment retries for pending recharge orders in `handleRechargePayment`
- [x] 4.4 Refresh wallet detail and recharge list state from backend data after the Alipay flow
## 5. Regression Verification
- [ ] 5.1 Verify WeChat package payment still uses `pay_config`
- [ ] 5.2 Verify wallet-balance package payment remains unchanged
- [ ] 5.3 Verify Alipay package payment, order-list payment, wallet recharge, and recharge retry can all open a valid payment link flow
- [ ] 5.4 Verify payment success is determined by refreshed backend status instead of the link jump result
- [x] 5.1 Verify WeChat package payment still uses `pay_config`
- [x] 5.2 Verify wallet-balance package payment remains unchanged
- [x] 5.3 Verify Alipay package payment, order-list payment, wallet recharge, and recharge retry can all open a valid payment link flow
- [x] 5.4 Verify payment success is determined by refreshed backend status instead of the link jump result

View File

@@ -0,0 +1,136 @@
# Exchange Flow Type Support - Implementation Summary
## 实施完成 ✅
### 变更概述
根据后端换货流程升级,前端已完成对新增字段的支持:
- `flow_type`: 流程类型shipping/direct
- `flow_type_name`: 流程类型名称
- `shipped_at`: 发货时间
- `completed_at`: 完成时间
### 已完成的工作
#### 1. 创建 OpenSpec 文档
-`proposal.md` - 提案说明
-`specs/exchange-flow-type-display/spec.md` - 详细规格
-`tasks.md` - 任务清单
#### 2. 代码实现
**修改文件:** `pages/device-exchange/device-exchange.vue`
**新增显示内容:**
```vue
<!-- 换货类型 - 始终显示 -->
<view class="info-row flex-row-sb">
<view class="info-label">换货类型</view>
<view class="info-value">{{ exchangeData.flow_type_name || '物流换货' }}</view>
</view>
<!-- 发货时间 - 条件显示 -->
<view class="info-row flex-row-sb" v-if="exchangeData.shipped_at">
<view class="info-label">发货时间</view>
<view class="info-value">{{ formatTime(exchangeData.shipped_at) }}</view>
</view>
<!-- 完成时间 - 条件显示 -->
<view class="info-row flex-row-sb" v-if="exchangeData.completed_at && exchangeData.status === 4">
<view class="info-label">完成时间</view>
<view class="info-value">{{ formatTime(exchangeData.completed_at) }}</view>
</view>
```
### 关键设计决策
#### 1. 后端保证的行为
- `GET /api/c/v1/exchange/pending` 仅返回 `shipping` 类型的进行中换货单
- `direct` 类型创建后立即完成,不会出现在待处理列表
- 前端无需额外过滤或判断
#### 2. 向后兼容
- 历史数据没有 `flow_type_name` 时,默认显示"物流换货"
- 新增字段都是条件显示,不影响现有数据展示
- 原有的按钮和表单逻辑保持不变
#### 3. 无需修改的部分
- ✅ API 模块 (`api/modules/exchange.js`) - 自动透传所有响应字段
- ✅ 表单提交逻辑 - 后端负责流程类型校验
- ✅ 按钮显示逻辑 - 因为 direct 类型不会进入待处理状态
### 显示逻辑
| 字段 | 显示条件 | 默认值 |
|------|---------|--------|
| 换货类型 | 始终显示 | "物流换货"(当 `flow_type_name` 为空时) |
| 发货时间 | `shipped_at` 不为空 | 不显示 |
| 完成时间 | `completed_at` 不为空 且 `status === 4` | 不显示 |
### 测试场景
需要测试的场景(待后端接口就绪后验证):
1. **待填写信息** (`status: 1`)
- 显示:换货类型、换货原因、申请时间
- 显示"填写信息"按钮
2. **待发货** (`status: 2`)
- 显示:换货类型、换货原因、申请时间、收件信息
- 不显示发货时间
3. **已发货** (`status: 3`)
- 显示:换货类型、换货原因、申请时间、发货时间、收件信息
- 不显示按钮
4. **已完成** (`status: 4`)
- 显示:换货类型、换货原因、申请时间、发货时间、完成时间、收件信息
- 不显示按钮
5. **无待处理换货单**
- 显示空状态:"暂无换货记录"
### API 响应示例
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 123,
"exchange_no": "EXC20260603001",
"old_asset_type": "iot_card",
"old_identifier": "8986012345678901234",
"exchange_reason": "设备损坏",
"status": 2,
"status_text": "待发货",
"flow_type": "shipping",
"flow_type_name": "物流换货",
"shipped_at": null,
"completed_at": null,
"created_at": "2026-06-03T10:00:00+08:00",
"recipient_name": "张三",
"recipient_phone": "13800138000",
"recipient_address": "北京市朝阳区xxx路xxx号"
}
}
```
### 影响范围
- **修改文件**: 1 个 (`pages/device-exchange/device-exchange.vue`)
- **新增文档**: 3 个proposal, spec, tasks
- **影响用户**: 使用换货功能的客户端用户
- **兼容性**: 完全向后兼容,不影响现有流程
### 后续工作
- [ ] 等待后端接口部署
- [ ] 在测试环境验证所有测试场景
- [ ] 确认不同状态下的显示效果
- [ ] 验证历史数据的兼容性
---
**实施日期**: 2026-06-03
**实施人员**: Claude
**状态**: ✅ 代码实现完成,待测试验证

View File

@@ -0,0 +1,29 @@
## Why
The backend exchange system now supports two flow types: **shipping** (logistics-based exchange requiring address collection and shipment) and **direct** (immediate completion without logistics). The current frontend client only handles the original shipping flow and does not process the new `flow_type`, `flow_type_name`, `shipped_at`, and `completed_at` response fields.
This prevents the frontend from correctly displaying exchange records that may include both flow types and from adapting UI behavior based on flow type constraints.
## What Changes
- Update the exchange pending query and shipping info submission to handle new response fields: `flow_type`, `flow_type_name`, `shipped_at`, `completed_at`
- Preserve existing behavior where the pending exchange API only returns `shipping` type exchanges (status 1/2/3), as `direct` exchanges are completed immediately and never appear in pending state
- Display flow type information in the exchange detail view
- Ensure shipping info submission validates that the exchange is `flow_type = shipping` and `status = 1` (backend enforces this, frontend should handle error gracefully)
- No UI flow changes required since `direct` type exchanges will not appear in the pending list by design
## Capabilities
### New Capabilities
- `exchange-flow-type-display`: Display flow type and completion timestamps in exchange records
### Modified Capabilities
- `exchange-pending-query`: Handle new response fields from `/api/c/v1/exchange/pending`
- `exchange-shipping-submission`: Handle new response fields from `/api/c/v1/exchange/{id}/shipping-info`
## Impact
- Affected code: `pages/device-exchange/device-exchange.vue`, `api/modules/exchange.js`
- Affected API usage: `/api/c/v1/exchange/pending`, `/api/c/v1/exchange/{id}/shipping-info`
- No breaking changes: existing shipping flow behavior remains unchanged
- Backend ensures `direct` type exchanges are not returned by pending query, so frontend maintains current UX

View File

@@ -0,0 +1,195 @@
# Exchange Flow Type Display Specification
## Overview
Update the exchange display to show the new flow type and timestamp fields returned by the backend exchange APIs.
## API Response Changes
### GET /api/c/v1/exchange/pending
**New Response Fields:**
```json
{
"code": 0,
"msg": "success",
"timestamp": "2026-06-03T10:00:00+08:00",
"data": {
"id": 1,
"exchange_no": "EXC20260603001",
"order_no": "EXC20260603001",
"old_asset_type": "iot_card",
"old_identifier": "8986012345678901234",
"exchange_reason": "设备损坏",
"status": 2,
"status_text": "待发货",
"flow_type": "shipping",
"flow_type_name": "物流换货",
"shipped_at": null,
"completed_at": null,
"created_at": "2026-06-03T10:00:00+08:00",
"recipient_name": "张三",
"recipient_phone": "13800138000",
"recipient_address": "北京市朝阳区xxx路xxx号"
}
}
```
**Field Descriptions:**
| Field | Type | Description |
|-------|------|-------------|
| `flow_type` | string | 流程类型: `shipping` (物流换货) or `direct` (直接换货)。历史数据为空时视为 `shipping` |
| `flow_type_name` | string | 流程类型名称: "物流换货" or "直接换货" |
| `shipped_at` | string\|null | 发货时间,仅 `shipping` 流程发货后有值 |
| `completed_at` | string\|null | 换货完成时间,换货完成后有值 |
**Backend Behavior:**
- Only returns exchanges where `flow_type = shipping` AND `status IN (1, 2, 3)`
- `direct` type exchanges are never returned (they complete immediately on creation)
- If no pending exchange exists, returns `data: null`
### POST /api/c/v1/exchange/{id}/shipping-info
**Response includes the same new fields as above**
**Backend Constraints:**
- Only accepts `flow_type = shipping` AND `status = 1`
- Returns 400 error if `flow_type = direct`: `{"code": 400, "message": "流程类型不支持该操作"}`
- Returns 400 error if status is not 1: `{"code": 400, "message": "当前状态不允许填写收货信息"}`
## UI Changes
### Exchange Detail Card
Add flow type display in the exchange info section:
**Before:**
```
换货原因: 设备损坏
申请时间: 2026-06-03 10:00:00
收件人: 张三
联系电话: 13800138000
收货地址: 北京市朝阳区xxx路xxx号
```
**After:**
```
换货类型: 物流换货
换货原因: 设备损坏
申请时间: 2026-06-03 10:00:00
发货时间: 2026-06-04 15:30:00 (仅已发货时显示)
完成时间: 2026-06-05 10:00:00 (仅已完成时显示)
收件人: 张三
联系电话: 13800138000
收货地址: 北京市朝阳区xxx路xxx号
```
**Display Rules:**
1. **换货类型**: Always display `flow_type_name` if present, fallback to "物流换货" if empty
2. **发货时间**: Only display if `shipped_at` is not null
3. **完成时间**: Only display if `completed_at` is not null and `status === 4`
### Error Handling
When submitting shipping info fails with flow type error:
```javascript
// Existing error handling should catch and display backend error message
catch (error) {
console.error('提交换货信息失败', error);
// Backend will return: {"code": 400, "message": "流程类型不支持该操作"}
// Default error toast will show this message
}
```
## Implementation
### 1. Update API Module Types
No changes needed to `api/modules/exchange.js` - it already passes through all response fields.
### 2. Update Exchange Page Template
File: `pages/device-exchange/device-exchange.vue`
Add flow type and timestamp displays:
```vue
<view class="exchange-info">
<view class="info-row flex-row-sb">
<view class="info-label">换货类型</view>
<view class="info-value">{{ exchangeData.flow_type_name || '物流换货' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">换货原因</view>
<view class="info-value">{{ exchangeData.exchange_reason }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">申请时间</view>
<view class="info-value">{{ formatTime(exchangeData.created_at) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.shipped_at">
<view class="info-label">发货时间</view>
<view class="info-value">{{ formatTime(exchangeData.shipped_at) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.completed_at && exchangeData.status === 4">
<view class="info-label">完成时间</view>
<view class="info-value">{{ formatTime(exchangeData.completed_at) }}</view>
</view>
<!-- Existing recipient fields -->
</view>
```
### 3. No Button Logic Changes
Since `direct` type exchanges never appear in pending (they complete immediately), the existing button visibility logic remains valid:
```vue
<view class="exchange-actions" v-if="exchangeData.status === 1">
<button class="btn-apple btn-primary" @tap="openPopup">填写信息</button>
</view>
```
Backend enforces that only `shipping` + status 1 can submit shipping info, so frontend doesn't need additional flow type checks.
## Testing Scenarios
### 1. Shipping Flow Exchange (Existing Behavior)
- Load pending exchange with `flow_type: "shipping"`, `status: 1`
- Verify "换货类型: 物流换货" displays
- Verify "填写信息" button shows
- Submit shipping info successfully
- Verify response includes new fields
### 2. Shipped Exchange
- Load exchange with `flow_type: "shipping"`, `status: 3`, `shipped_at: "2026-06-04T15:30:00+08:00"`
- Verify "发货时间" row displays
- Verify no action buttons show
### 3. Completed Exchange
- Load exchange with `flow_type: "shipping"`, `status: 4`, `completed_at: "2026-06-05T10:00:00+08:00"`
- Verify "完成时间" row displays
- Verify no action buttons show
### 4. No Pending Exchange
- Call API with no pending exchange
- Verify empty state shows: "暂无换货记录"
### 5. Error Handling (Edge Case)
If somehow a `direct` type exchange appeared and user tried to submit shipping info (should not happen), backend returns 400 error which existing error handler will toast.
## Backward Compatibility
- Historical data without `flow_type` will be treated as `shipping` (per backend spec)
- All new fields are optional in frontend display logic
- Existing shipping submission flow is unchanged

View File

@@ -0,0 +1,17 @@
# Tasks
## 1. Update Exchange Detail Display
- [x] Add flow type display row in exchange info section
- [x] Add shipped_at display row (conditional on value)
- [x] Add completed_at display row (conditional on value and status)
- [x] Ensure backward compatibility for historical data without flow_type
## 2. Testing
- [ ] Test with shipping flow exchange (status 1, 2, 3)
- [ ] Test with completed exchange showing completion time
- [ ] Test with shipped exchange showing ship time
- [ ] Test empty state when no pending exchange
- [ ] Test shipping info submission success with new response fields
## Files Modified
- [x] `pages/device-exchange/device-exchange.vue`: Added new field displays

28
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "c-xmot-iot",
"name": "device-voice-h5",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -66,6 +66,7 @@
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.25.2.tgz",
"integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
@@ -1917,7 +1918,8 @@
"version": "3.4.19",
"resolved": "https://registry.npmmirror.com/@dcloudio/types/-/types-3.4.19.tgz",
"integrity": "sha512-1foayOFEAQ+jnQLt3ACsovCNjer3/fXn1I2VBpmDOzs2nk/n4UHwRLAxZV/RpxRqaGOPEvKrO/Pq+VI6sAmuRw==",
"license": "Apache-2.0"
"license": "Apache-2.0",
"peer": true
},
"node_modules/@dcloudio/uni-app": {
"version": "3.0.0-4080720251210001",
@@ -3268,18 +3270,6 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.3",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.0.3.tgz",
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@vitejs/plugin-legacy": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz",
@@ -3923,6 +3913,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5724,6 +5715,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6130,6 +6122,7 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -6232,6 +6225,7 @@
"integrity": "sha512-u56TU0AIFqMtauKl/OJ1AeFsXqRHkgO7nCWmHaDwfxDo9GUMSqBA4NEh6GMuh1CYVM7zuROYtZrHzPc2ixK+ww==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@@ -6620,6 +6614,7 @@
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@@ -6696,8 +6691,7 @@
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
@@ -6967,6 +6961,7 @@
"integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.20.1",
"postcss": "^8.4.38",
@@ -7022,6 +7017,7 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.4.21.tgz",
"integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-sfc": "3.4.21",

View File

@@ -1,7 +1,8 @@
{
"scripts": {
"dev:h5": "uni",
"build:h5": "cross-env UNI_INPUT_DIR=. uni build -p h5"
"build:h5": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode development",
"build:h5:production": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode production"
},
"devDependencies": {
"@dcloudio/types": "3.4.19",

View File

@@ -75,6 +75,12 @@
"navigationBarTitleText": "我的钱包"
}
},
{
"path": "pages/alipay-payment/alipay-payment",
"style": {
"navigationBarTitleText": "支付宝支付"
}
},
{
"path": "pages/error/error",
"style": {
@@ -89,4 +95,4 @@
"backgroundColor": "#F8F8F8"
},
"uniIdRouter": {}
}
}

View File

@@ -0,0 +1,173 @@
<template>
<view class="container alipay-page">
<view class="pay-content">
<view class="method-card">
<view class="method-header">
<view class="method-index">方式一</view>
<view class="method-title">浏览器链接支付宝支付</view>
</view>
<view class="browser-tip">点击右上角三个点使用浏览器打开</view>
<image class="tips-image" src="/static/tips.png" mode="widthFix"></image>
</view>
<view class="method-card">
<view class="method-header">
<view class="method-index">方式二</view>
<view class="method-title">支付宝扫码支付</view>
</view>
<view class="qr-wrap">
<u-qrcode v-if="qrUrl" :val="qrUrl" :size="220" unit="px" :show-loading="false"></u-qrcode>
</view>
<view class="qr-desc">截图保存二维码打开支付宝扫一扫即可支付</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { getPendingAlipayPaymentData } from '@/utils/payment.js';
const browserUrl = ref('');
const qrUrl = ref('');
const hasRedirected = ref(false);
const isInWechat = () => /micromessenger/i.test(navigator.userAgent);
const decodePaymentUrl = (value) => {
if (!value) {
return '';
}
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - normalized.length % 4) % 4);
return decodeURIComponent(escape(atob(normalized + padding)));
} catch (e) {
return '';
}
};
const getQueryParam = (key) => {
// #ifdef H5
const params = new URLSearchParams(window.location.search);
return params.get(key) || '';
// #endif
// #ifndef H5
return '';
// #endif
};
const getPayUrlFromQuery = () => {
return decodePaymentUrl(getQueryParam('pay64'));
};
const getQrUrlFromQuery = () => {
return decodePaymentUrl(getQueryParam('qr64'));
};
const redirectToAlipayInBrowser = () => {
// #ifdef H5
if (hasRedirected.value || isInWechat() || !browserUrl.value) {
return;
}
hasRedirected.value = true;
window.location.href = browserUrl.value;
// #endif
};
const loadPaymentData = () => {
const data = getPendingAlipayPaymentData();
browserUrl.value = getPayUrlFromQuery() || data?.copyUrl || data?.qrUrl || '';
qrUrl.value = getQrUrlFromQuery() || data?.qrUrl || data?.copyUrl || browserUrl.value;
redirectToAlipayInBrowser();
};
onShow(() => {
loadPaymentData();
});
</script>
<style lang="scss" scoped>
.alipay-page {
min-height: 100vh;
box-sizing: border-box;
padding: 28rpx 24rpx 48rpx;
background: linear-gradient(180deg, #eaf4ff 0%, var(--bg-secondary) 42%);
}
.pay-content {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.method-card {
background: var(--bg-primary);
border-radius: 28rpx;
padding: 32rpx;
box-shadow: 0 16rpx 40rpx rgba(10, 132, 255, 0.08);
border: 1rpx solid rgba(10, 132, 255, 0.08);
}
.qr-desc {
margin-top: 14rpx;
font-size: 26rpx;
line-height: 1.55;
color: var(--text-tertiary);
}
.method-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 28rpx;
}
.method-index {
padding: 8rpx 14rpx;
border-radius: 12rpx;
background: var(--primary);
color: var(--text-inverse);
font-size: 22rpx;
font-weight: 700;
}
.method-title {
font-size: 30rpx;
font-weight: 700;
color: var(--text-primary);
}
.browser-tip {
padding: 22rpx 24rpx;
border-radius: 18rpx;
background: rgba(10, 132, 255, 0.08);
color: var(--primary);
font-size: 28rpx;
font-weight: 600;
line-height: 1.45;
text-align: center;
}
.tips-image {
display: block;
width: 100%;
margin-top: 28rpx;
border-radius: 18rpx;
background: var(--gray-100);
}
.qr-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 28rpx;
border-radius: 24rpx;
background: #fff;
border: 1rpx dashed rgba(10, 132, 255, 0.24);
}
</style>

View File

@@ -15,30 +15,14 @@
</view>
<view class="package-info">
<!-- <view class="info-row flex-row-sb">
<view class="info-label">套餐类型</view>
<view class="info-value">{{ item.package_type === 'formal' ? '普通套餐' : '加油包' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">使用类型</view>
<view class="info-value">{{ item.usage_type === 'single_card' ? '单卡' : '设备' }}</view>
</view> -->
<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.created_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 class="info-row flex-row-sb">
<view class="info-label">优先级</view>
<view class="info-value">{{ item.priority }}</view>
</view> -->
</view>
<view class="divider"></view>
@@ -124,7 +108,9 @@
// 获取剩余流量
const getRemainFlow = (item) => {
const remain = (item.real_total_mb || 0) - (item.real_used_mb || 0);
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);
};

View File

@@ -9,12 +9,12 @@
<view class="card" v-for="item in list" :key="item.iccid">
<view class="flex-row-g20">
<view class="logo">
<image :src="getLogo(item.category).logo" mode="aspectFit"></image>
<image :src="getCarrier(item.carrier_type).logo" mode="aspectFit"></image>
</view>
<view class="flex-col-g20">
<view class="iccid">ICCID: {{ item.iccid }}</view>
<view class="operator">运营商: {{ getLogo(item.category).name }}</view>
<view class="slot">卡槽位: {{ item.slot_position || '-' }}</view>
<view class="operator">运营商: {{ getCarrier(item.carrier_type).name }}</view>
<view v-if="item.isDevice" class="slot">卡槽位: {{ item.slot_position || '-' }}</view>
</view>
</view>
@@ -39,7 +39,7 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { assetApi, deviceApi, realnameApi } from '@/api/index.js';
import { assetApi, realnameApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
const userStore = useUserStore();
@@ -50,15 +50,25 @@
let currentModalIccid = ref('');
let currentCard = ref(null);
let opratorList = reactive([
{ category: '124', logo: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500', name: '中国电信' },
{ category: '125', logo: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500', name: '中国联通' },
{ category: '126', logo: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182', name: '中国移动' }
]);
const carrierMap = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
};
const getLogo = (category) => {
const operator = opratorList.find(item => item.category === category);
return operator || opratorList[0];
const carrierLogoMap = {
CMCC: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182',
CUCC: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
CTCC: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500',
CBN: 'https://img1.baidu.com/it/u=3160680953,3401650303&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
};
const getCarrier = (carrierType) => {
return {
name: carrierMap[carrierType] || '-',
logo: carrierLogoMap[carrierType] || carrierLogoMap.CMCC
};
};
const loadCards = async () => {
@@ -66,28 +76,23 @@
try {
const assetData = await assetApi.getInfo(userStore.state.identifier);
// 判断是否为设备
if (assetData.asset_type === 'device') {
// 是设备,调用设备卡列表接口
const data = await deviceApi.getCards(userStore.state.identifier);
if (data.cards && data.cards.length > 0) {
list.splice(0, list.length, ...data.cards.map(card => ({
iccid: card.iccid,
category: getCarrierCategory(card.carrier_name),
slot_position: card.slot_position,
isRealName: card.real_name_status === 1
})));
}
list.splice(0, list.length, ...(assetData.cards || []).map(card => ({
iccid: card.iccid,
carrier_type: card.carrier_type,
slot_position: card.slot_position,
isDevice: true,
isRealName: card.real_name_status === 1
})));
} else if (assetData.iccid) {
list.splice(0, list.length, {
iccid: assetData.iccid,
carrier_type: assetData.carrier_type,
isDevice: false,
isRealName: assetData.real_name_status === 1
});
} else {
// 不是设备(单卡),直接使用 asset info 数据
if (assetData.identifier) {
list.splice(0, list.length, {
iccid: assetData.identifier,
category: getCarrierCategory(assetData.carrier_name),
slot_position: 1,
isRealName: assetData.real_name_status === 1
});
}
list.splice(0, list.length);
}
} catch (e) {
console.error('加载卡列表失败', e);
@@ -95,14 +100,6 @@
loading.value = false;
};
const getCarrierCategory = (carrierName) => {
if (!carrierName) return '126';
if (carrierName.includes('电信')) return '124';
if (carrierName.includes('联通')) return '125';
if (carrierName.includes('移动')) return '126';
return '126';
};
const toReal = async (card) => {
currentCard.value = card;
currentModalIccid.value = card.iccid;
@@ -114,22 +111,25 @@
};
const doRealName = async () => {
const iccid = currentModalIccid.value;
closeModal();
try {
const data = await realnameApi.getLink(userStore.state.identifier, currentModalIccid.value);
const data = await realnameApi.getLink(userStore.state.identifier, iccid, { showError: false });
if (data.realname_url) {
uni.setClipboardData({
data: currentModalIccid.value,
data: iccid,
success: () => {
uni.showToast({ title: 'ICCID已复制', icon: 'success' });
}
});
closeModal();
setTimeout(() => {
window.location.href = data.realname_url;
}, 1500);
}
} catch (e) {
console.error('获取实名链接失败', e);
uni.showToast({ title: e?.msg || '获取实名链接失败', icon: 'none' });
}
};

View File

@@ -15,6 +15,10 @@
</view>
<view class="exchange-info">
<view class="info-row flex-row-sb">
<view class="info-label">换货类型</view>
<view class="info-value">{{ exchangeData.flow_type_name || '物流换货' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">换货原因</view>
<view class="info-value">{{ exchangeData.exchange_reason }}</view>
@@ -23,6 +27,14 @@
<view class="info-label">申请时间</view>
<view class="info-value">{{ formatTime(exchangeData.created_at) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.shipped_at">
<view class="info-label">发货时间</view>
<view class="info-value">{{ formatTime(exchangeData.shipped_at) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.completed_at && exchangeData.status === 4">
<view class="info-label">完成时间</view>
<view class="info-value">{{ formatTime(exchangeData.completed_at) }}</view>
</view>
<view class="info-row flex-row-sb" v-if="exchangeData.recipient_name">
<view class="info-label">收件人</view>
<view class="info-value">{{ exchangeData.recipient_name }}</view>

View File

@@ -1,7 +1,7 @@
<template>
<view class="container">
<UserInfoCard :currentCardNo="currentCardNo" :deviceInfo="deviceInfo" :onlineStatus="onlineStatus"
:isDevice="userInfo.isDevice" :networkStatus="deviceInfo.network_status" />
:isDevice="userInfo.isDevice" :networkStatus="deviceInfo.network_status" @copy="copy" />
<DeviceStatusCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" :isRealName="isRealName"
:isDevice="userInfo.isDevice" @authentication="enterDetail('authentication')" />
@@ -216,19 +216,10 @@
return dateStr.split('T')[0] || '-';
};
const resolveAssetRealName = (assetData) => {
const cards = Array.isArray(assetData?.cards) ? assetData.cards : [];
if (cards.length === 1) {
return !!cards[0]?.real_name_at;
}
if (cards.length > 1) {
const currentCard = cards.find(card => card.is_current);
return !!currentCard?.real_name_at;
}
return assetData?.real_name_status === 1;
const resolveCurrentCard = (cards = []) => {
if (cards.length === 1) return cards[0];
if (cards.length > 1) return cards.find(card => card.is_current) || null;
return null;
};
const loadAssetInfo = async () => {
@@ -251,7 +242,8 @@
deviceInfo.expireDate = formatDate(data.current_package_expires_at);
deviceInfo.iccid = data.iccid || '-';
deviceInfo.walletBalance = data.wallet_balance ?? 0;
isRealName.value = resolveAssetRealName(data);
const currentCard = resolveCurrentCard(data.cards || []);
isRealName.value = !!currentCard?.real_name_at || currentCard?.real_name_status === 1 || data.real_name_status === 1;
realNameStatus.value = isRealName.value ? '已实名' : '未实名';
boundPhone.value = data.bound_phone || '';
alreadyBindPhone.value = !!data.bound_phone;
@@ -259,10 +251,8 @@
// 流量信息已由 TrafficCard 组件独立获取,这里不再处理
// 当前卡信息
let currentCard = null;
if (data.cards && data.cards.length > 0) {
currentCard = data.cards.find(card => card.is_current) || '';
deviceInfo.currentIccid = currentCard.iccid || '-';
deviceInfo.currentIccid = currentCard?.iccid || '-';
// 卡列表
deviceInfo.mchList = data.cards.map(card => ({
@@ -766,7 +756,7 @@
});
break;
case 'recover':
restartShow.value = true;
recoverShow.value = true;
break;
case 'restart':
restartShow.value = true;

View File

@@ -186,6 +186,23 @@
doLogin();
};
const parseScanIdentifier = (resultStr) => {
const scanValue = (resultStr || '').trim();
if (!scanValue) return '';
const barcodeMatch = scanValue.match(/^[A-Z0-9_]+,(.+)$/);
if (barcodeMatch) return barcodeMatch[1].trim();
if (scanValue.includes('?')) {
const params = new URLSearchParams(scanValue.split('?')[1]);
const queryValue = params.get('cardNum') || params.get('identifier');
if (queryValue) return queryValue;
}
const match = scanValue.match(/=([^=]+)$/);
return match ? match[1].trim() : scanValue;
};
const handleScanLogin = async () => {
// #ifdef H5
try {
@@ -199,24 +216,13 @@
const resultStr = await wxScan();
console.log('扫码结果:', resultStr);
let value = '';
// 解析二维码内容
if (resultStr.includes('?')) {
const params = new URLSearchParams(resultStr.split('?')[1]);
value = params.get('cardNum') || '';
}
if (!value) {
const match = resultStr.match(/=([^=]+)$/);
value = match ? match[1] : '';
}
const value = parseScanIdentifier(resultStr);
if (value) {
identifier.value = value;
handleLogin();
} else {
uni.showToast({ title: '无效的二维码', icon: 'none' });
uni.showToast({ title: '无效的扫码内容', icon: 'none' });
}
} catch (err) {
console.error('扫码错误:', err);

View File

@@ -155,7 +155,7 @@
</view>
<view class="payment-methods">
<view class="method-item" :class="{ active: rechargePaymentMethod === 'wechat' }" @tap="selectRechargePaymentMethod('wechat')">
<view v-if="isDeviceAsset" class="method-item" :class="{ active: rechargePaymentMethod === 'wechat' }" @tap="selectRechargePaymentMethod('wechat')">
<view class="method-left">
<image class="method-icon" src="/static/wechat.png" mode="aspectFit"></image>
<text class="method-name">微信支付</text>
@@ -163,7 +163,7 @@
<view class="method-radio" :class="{ checked: rechargePaymentMethod === 'wechat' }"></view>
</view>
<view class="method-item" :class="{ active: rechargePaymentMethod === 'alipay' }" @tap="selectRechargePaymentMethod('alipay')">
<view v-else class="method-item" :class="{ active: rechargePaymentMethod === 'alipay' }" @tap="selectRechargePaymentMethod('alipay')">
<view class="method-left">
<view class="method-icon method-badge method-badge-alipay"></view>
<text class="method-name">支付宝支付</text>
@@ -186,9 +186,9 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, onMounted, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { walletApi } from '@/api/index.js';
import { assetApi, walletApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import {
consumePendingPaymentRefresh,
@@ -229,10 +229,13 @@
const rechargePaymentMethod = ref('wechat');
const rechargeSubmitting = ref(false);
const rechargeOrderSubmittingKey = ref(null);
const paymentMethodOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝支付', value: 'alipay' }
];
const isDeviceAsset = ref(true);
let assetTypePromise = null;
const paymentMethodOptions = computed(() => [
isDeviceAsset.value
? { label: '微信支付', value: 'wechat' }
: { label: '支付宝支付', value: 'alipay' }
]);
const rechargeAmounts = [
{ value: 1000, label: '10' },
{ value: 2000, label: '20' },
@@ -305,6 +308,34 @@
return rechargeOrderSubmittingKey.value === getRechargeOrderSubmitKey(rechargeOrder);
};
const getDefaultRechargePaymentMethod = () => isDeviceAsset.value ? 'wechat' : 'alipay';
const normalizeRechargePaymentMethod = () => {
const defaultMethod = getDefaultRechargePaymentMethod();
if (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 = () => {
rechargePage.value = 1;
rechargeNoMore.value = false;
@@ -318,6 +349,7 @@
};
const syncWalletStatus = () => {
loadAssetType();
loadWalletDetail();
resetRechargeListAndLoad();
resetTransactionListAndLoad();
@@ -337,8 +369,9 @@
}, 3000);
};
const openRechargeModal = () => {
rechargePaymentMethod.value = 'wechat';
const openRechargeModal = async () => {
await loadAssetType();
rechargePaymentMethod.value = getDefaultRechargePaymentMethod();
showRechargeModal.value = true;
};
@@ -354,6 +387,7 @@
};
const selectRechargePaymentMethod = (method) => {
if (method !== getDefaultRechargePaymentMethod()) return;
rechargePaymentMethod.value = method;
};
@@ -602,13 +636,15 @@
}
};
const showRechargePaymentMethods = (rechargeOrder) => {
const showRechargePaymentMethods = async (rechargeOrder) => {
if (rechargeOrderSubmittingKey.value !== null) return;
await loadAssetType();
const options = paymentMethodOptions.value;
uni.showActionSheet({
itemList: paymentMethodOptions.map((item) => item.label),
itemList: options.map((item) => item.label),
success: ({ tapIndex }) => {
const selectedMethod = paymentMethodOptions[tapIndex]?.value;
const selectedMethod = options[tapIndex]?.value;
if (selectedMethod) {
handleRechargePayment(rechargeOrder, selectedMethod);
}

View File

@@ -73,9 +73,9 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, onMounted, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { orderApi } from '@/api/index.js';
import { assetApi, orderApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import {
consumePendingPaymentRefresh,
@@ -97,10 +97,14 @@
const orderPayingId = ref(null);
const pageSize = 10;
const filterIndex = ref(0);
const paymentMethodOptions = [
{ label: '微信支付', value: 'wechat' },
{ label: '支付宝支付', value: 'alipay' }
];
const isDeviceAsset = ref(true);
let assetTypePromise = null;
const paymentMethodOptions = computed(() => [
isDeviceAsset.value
? { label: '微信支付', value: 'wechat' }
: { label: '支付宝支付', value: 'alipay' },
{ label: '钱包支付', value: 'wallet' }
]);
const filterOptions = [
{ label: '全部', value: null },
{ label: '待支付', value: 1 },
@@ -124,6 +128,24 @@
return classMap[status] || '';
};
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';
})
.catch((error) => {
console.error('加载资产类型失败', error);
})
.finally(() => {
assetTypePromise = null;
});
return assetTypePromise;
};
const resetOrderListAndLoad = () => {
page.value = 1;
noMore.value = false;
@@ -203,13 +225,15 @@
const isOrderPaying = (order) => orderPayingId.value === order.order_id;
const showOrderPaymentMethods = (order) => {
const showOrderPaymentMethods = async (order) => {
if (orderPayingId.value !== null || !order?.order_id) return;
await loadAssetType();
const options = paymentMethodOptions.value;
uni.showActionSheet({
itemList: paymentMethodOptions.map((item) => item.label),
itemList: options.map((item) => item.label),
success: ({ tapIndex }) => {
const selectedMethod = paymentMethodOptions[tapIndex]?.value;
const selectedMethod = options[tapIndex]?.value;
if (selectedMethod) {
handleOrderPayment(order, selectedMethod);
}
@@ -230,6 +254,14 @@
const payData = await orderApi.pay(order.order_id, paymentMethod);
uni.hideLoading();
if (paymentMethod === 'wallet') {
showPaymentToast(true, '支付成功');
setTimeout(() => {
resetOrderListAndLoad();
}, 1500);
return;
}
if (paymentMethod === 'wechat') {
if (!isValidWechatPayConfig(payData?.pay_config)) {
uni.showToast({
@@ -281,6 +313,7 @@
});
onMounted(() => {
loadAssetType();
loadOrderList();
});
</script>

View File

@@ -7,19 +7,28 @@
</view>
<view class="card package-card" v-for="item in packageList" :key="item.package_id">
<view class="package-header flex-row-sb">
<view class="package-header">
<view class="package-name">{{ item.package_name }}</view>
<view class="header-right">
<view class="validity-tag">有效期 {{ item.validity_days }} </view>
<view class="tag-apple" :class="item.is_addon ? 'tag-warning' : 'tag-primary'">
{{ item.is_addon ? '加油包' : '正式套餐' }}
</view>
<view class="tag-apple" :class="item.is_addon ? 'tag-warning' : 'tag-primary'">
{{ item.is_addon ? '加油包' : '正式套餐' }}
</view>
</view>
<view class="package-data">{{ formatData(item.data_allowance, item.data_unit) }}</view>
<view class="package-desc">{{ item.description }}</view>
<view class="package-footer flex-row-sb">
<view class="package-price">¥{{ formatMoney(item.retail_price) }}</view>
<view class="package-main">
<view class="data-block">
<view class="data-label">套餐流量</view>
<view class="package-data">{{ formatData(item.data_allowance, item.data_unit) }}</view>
</view>
<view class="validity-box">
<view class="validity-value">{{ item.validity_days }}</view>
<view class="validity-label">天有效期</view>
</view>
</view>
<view class="package-desc" v-if="item.description">{{ item.description }}</view>
<view class="package-footer">
<view class="price-block">
<text class="price-symbol">¥</text>
<text class="package-price">{{ formatMoney(item.retail_price) }}</text>
</view>
<view class="btn">
<up-button type="primary" @click="buyPackage(item)">立即订购</up-button>
</view>
@@ -50,7 +59,7 @@
</view>
<view class="payment-methods">
<view class="method-item" :class="{ active: paymentMethod === 'wechat' }" @tap="selectPaymentMethod('wechat')">
<view v-if="isDeviceAsset" class="method-item" :class="{ active: paymentMethod === 'wechat' }" @tap="selectPaymentMethod('wechat')">
<view class="method-left">
<image class="method-icon" src="/static/wechat.png" mode="aspectFit"></image>
<text class="method-name">微信支付</text>
@@ -58,7 +67,7 @@
<view class="method-radio" :class="{ checked: paymentMethod === 'wechat' }"></view>
</view>
<view class="method-item" :class="{ active: paymentMethod === 'alipay' }" @tap="selectPaymentMethod('alipay')">
<view v-else class="method-item" :class="{ active: paymentMethod === 'alipay' }" @tap="selectPaymentMethod('alipay')">
<view class="method-left">
<view class="method-icon method-badge method-badge-alipay"></view>
<text class="method-name">支付宝支付</text>
@@ -111,6 +120,8 @@
const paymentMethod = ref('wechat');
const walletBalance = ref(0);
const paySubmitting = ref(false);
const isDeviceAsset = ref(true);
let assetTypePromise = null;
const formatMoney = (amount) => {
if (!amount && amount !== 0) return '0.00';
@@ -124,6 +135,40 @@
return `${allowance} ${unit}`;
};
const getDefaultPaymentMethod = () => isDeviceAsset.value ? 'wechat' : 'alipay';
const isPaymentMethodAvailable = (method) => {
if (method === 'wallet') return true;
if (method === 'wechat') return isDeviceAsset.value;
if (method === 'alipay') return !isDeviceAsset.value;
return false;
};
const normalizePaymentMethod = () => {
if (!isPaymentMethodAvailable(paymentMethod.value)) {
paymentMethod.value = getDefaultPaymentMethod();
}
};
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';
normalizePaymentMethod();
})
.catch((error) => {
console.error('加载资产类型失败', error);
})
.finally(() => {
assetTypePromise = null;
});
return assetTypePromise;
};
const loadPackages = async () => {
loading.value = true;
try {
@@ -145,17 +190,20 @@
};
const syncPackagePageState = () => {
loadAssetType();
loadPackages();
loadWalletBalance();
};
const buyPackage = (item) => {
const buyPackage = async (item) => {
await loadAssetType();
currentPackage.value = item;
paymentMethod.value = 'wechat';
paymentMethod.value = getDefaultPaymentMethod();
showModal.value = true;
};
const selectPaymentMethod = (method) => {
if (!isPaymentMethodAvailable(method)) return;
paymentMethod.value = method;
};
@@ -194,7 +242,9 @@
};
const getCreateOrderPaymentMethod = () => {
return paymentMethod.value === 'alipay' ? 'alipay' : undefined;
if (paymentMethod.value === 'alipay') return 'alipay';
if (paymentMethod.value === 'wallet' && !isDeviceAsset.value) return 'alipay';
return undefined;
};
const confirmPay = async () => {
@@ -404,56 +454,117 @@
.package-card {
margin-bottom: var(--space-md);
padding: 32rpx;
border: 1rpx solid rgba(0, 122, 255, 0.08);
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.06);
.package-header {
margin-bottom: var(--space-sm);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 28rpx;
.package-name {
font-size: 32rpx;
font-weight: 600;
font-weight: 700;
color: var(--text-primary);
}
.header-right {
display: flex;
align-items: center;
gap: 12rpx;
.validity-tag {
font-size: 24rpx;
color: var(--text-secondary);
padding: 4rpx 12rpx;
background: var(--gray-100);
border-radius: 8rpx;
}
line-height: 1.35;
flex: 1;
}
}
.package-data {
font-size: 48rpx;
font-weight: 700;
color: var(--primary);
margin-bottom: var(--space-sm);
letter-spacing: -1rpx;
.package-main {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 20rpx;
padding: 26rpx;
margin-bottom: 22rpx;
background: linear-gradient(135deg, rgba(0, 122, 255, 0.08) 0%, rgba(0, 122, 255, 0.02) 100%);
border-radius: 24rpx;
.data-block {
flex: 1;
.data-label {
font-size: 24rpx;
color: var(--text-tertiary);
margin-bottom: 8rpx;
}
}
.package-data {
font-size: 52rpx;
font-weight: 800;
color: var(--primary);
line-height: 1.1;
letter-spacing: -1rpx;
}
.validity-box {
min-width: 132rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 18rpx 16rpx;
background: rgba(255, 255, 255, 0.78);
border: 1rpx solid rgba(0, 122, 255, 0.08);
border-radius: 20rpx;
.validity-value {
font-size: 34rpx;
font-weight: 800;
color: var(--text-primary);
line-height: 1;
}
.validity-label {
font-size: 22rpx;
color: var(--text-tertiary);
margin-top: 8rpx;
}
}
}
.package-desc {
font-size: 26rpx;
color: var(--text-secondary);
margin-bottom: var(--space-md);
line-height: 1.55;
margin-bottom: 24rpx;
}
.package-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid var(--border-light);
.package-price {
font-size: 40rpx;
font-weight: 700;
.price-block {
display: flex;
align-items: baseline;
color: var(--warning);
white-space: nowrap;
.price-symbol {
font-size: 26rpx;
font-weight: 700;
margin-right: 4rpx;
}
.package-price {
font-size: 44rpx;
font-weight: 800;
letter-spacing: -1rpx;
}
}
.btn {
flex-shrink: 0;
min-width: 180rpx;
}
}
}

View File

@@ -10,19 +10,22 @@
<view class="flex-row-sb mt-30">
<view class="flex-row-g20">
<view class="logo">
<image :src="getLogo(item.category).logo" mode="aspectFit"></image>
<image :src="getCarrier(item.carrier_type).logo" mode="aspectFit"></image>
</view>
<view class="flex-col-g20">
<view class="flex-row-g20">
<view class="iccid">{{ item.iccid }}</view>
<view class="operator">
<up-tag type="success" size="mini">{{ getLogo(item.category).name }}</up-tag>
<up-tag type="success" size="mini">{{ getCarrier(item.carrier_type).name }}</up-tag>
</view>
</view>
<view class="flex-row-g20">
<view class="operator">
<up-tag :type="item.real_name_status === 1 ? 'primary' : 'success'" size="mini">{{ item.real_name_status === 1 ? '已实名' : '未实名' }}</up-tag>
</view>
<view v-if="item.network_status" class="operator">
<up-tag type="info" size="mini">{{ item.network_status }}</up-tag>
</view>
</view>
<view class="slot">卡槽位: {{ item.slot_position || '-' }}</view>
</view>
@@ -35,6 +38,20 @@
<up-button class="btn-apple btn-success" v-else type="success" @tap="switchOperator(item)" :loading="switching">
切换此运营商
</up-button>
<up-button class="btn-apple btn-success" v-if="item.real_name_status !== 1" type="success" @tap="toReal(item)">
去实名
</up-button>
</view>
</view>
<view class="modal-overlay" v-if="showIccidModal" @tap="closeModal">
<view class="modal-content" @tap.stop>
<view class="modal-close" @tap="closeModal"></view>
<view class="modal-body">
<view class="modal-title">实名认证</view>
<view class="iccid-value">{{ currentModalIccid }}</view>
<view class="modal-button" @tap="doRealName">点击复制ICCID并跳转实名</view>
</view>
</view>
</view>
</view>
@@ -42,7 +59,7 @@
<script setup>
import { reactive, ref, onMounted } from 'vue';
import { assetApi, deviceApi } from '@/api/index.js';
import { assetApi, deviceApi, realnameApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
const userStore = useUserStore();
@@ -50,16 +67,28 @@
let mchList = reactive([]);
let loading = ref(false);
let switching = ref(false);
let showIccidModal = ref(false);
let currentModalIccid = ref('');
let opratorList = reactive([
{ category: '124', logo: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500', name: '中国电信' },
{ category: '125', logo: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500', name: '中国联通' },
{ category: '126', logo: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182', name: '中国移动' }
]);
const carrierMap = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
};
const getLogo = (category) => {
const operator = opratorList.find(item => item.category === category);
return operator || opratorList[0];
const carrierLogoMap = {
CMCC: 'https://img2.baidu.com/it/u=915783975,1594870591&fm=253&fmt=auto&app=120&f=PNG?w=182&h=182',
CUCC: 'https://img1.baidu.com/it/u=2816777816,1756344384&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
CTCC: 'https://img2.baidu.com/it/u=139558247,3893370039&fm=253&fmt=auto?w=529&h=500',
CBN: 'https://img1.baidu.com/it/u=3160680953,3401650303&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
};
const getCarrier = (carrierType) => {
return {
name: carrierMap[carrierType] || '-',
logo: carrierLogoMap[carrierType] || carrierLogoMap.CMCC
};
};
const loadCards = async () => {
@@ -74,11 +103,12 @@
if (data.cards && data.cards.length > 0) {
mchList.splice(0, mchList.length, ...data.cards.map(card => ({
iccid: card.iccid,
carrier_type: card.carrier_type,
carrier_name: card.carrier_name,
is_current: card.is_active,
real_name_status: card.real_name_status,
slot_position: card.slot_position,
category: getCarrierCategory(card.carrier_name)
network_status: card.network_status,
slot_position: card.slot_position
})));
}
} else {
@@ -86,11 +116,12 @@
if (assetData.identifier) {
mchList.splice(0, mchList.length, {
iccid: assetData.identifier,
carrier_type: assetData.carrier_type,
carrier_name: assetData.carrier_name,
is_current: true,
real_name_status: assetData.real_name_status,
slot_position: 1,
category: getCarrierCategory(assetData.carrier_name)
network_status: assetData.network_status,
slot_position: 1
});
}
}
@@ -100,14 +131,6 @@
loading.value = false;
};
const getCarrierCategory = (carrierName) => {
if (!carrierName) return '126';
if (carrierName.includes('电信')) return '124';
if (carrierName.includes('联通')) return '125';
if (carrierName.includes('移动')) return '126';
return '126';
};
const switchOperator = async (item) => {
switching.value = true;
try {
@@ -120,6 +143,38 @@
switching.value = false;
};
const toReal = (card) => {
currentModalIccid.value = card.iccid;
showIccidModal.value = true;
};
const closeModal = () => {
showIccidModal.value = false;
};
const doRealName = async () => {
const iccid = currentModalIccid.value;
closeModal();
try {
const data = await realnameApi.getLink(userStore.state.identifier, iccid, { showError: false });
if (data.realname_url) {
uni.setClipboardData({
data: iccid,
success: () => {
uni.showToast({ title: 'ICCID已复制', icon: 'success' });
}
});
setTimeout(() => {
window.location.href = data.realname_url;
}, 1500);
}
} catch (e) {
console.error('获取实名链接失败', e);
uni.showToast({ title: e?.msg || '获取实名链接失败', icon: 'none' });
}
};
onMounted(() => {
loadCards();
});
@@ -153,12 +208,83 @@
background: var(--gray-100);
color: var(--text-primary);
&.btn-primary {
background: var(--primary);
color: var(--text-inverse);
}
&.btn-success {
background: var(--success);
color: var(--text-inverse);
}
}
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-content {
background: var(--bg-primary);
border-radius: var(--radius-large);
width: 620rpx;
max-width: 90%;
overflow: hidden;
position: relative;
}
.modal-close {
position: absolute;
top: 24rpx; left: 24rpx;
width: 48rpx; height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: var(--gray-600);
cursor: pointer;
}
.modal-body {
padding: 60rpx 32rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 32rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: var(--text-primary);
text-align: center;
}
.iccid-value {
font-size: 40rpx;
font-weight: bold;
color: var(--primary);
text-align: center;
word-break: break-all;
line-height: 1.4;
}
.modal-button {
padding: 24rpx 32rpx;
background: var(--success);
color: var(--text-inverse);
font-size: 28rpx;
font-weight: 500;
text-align: center;
border-radius: var(--radius-small);
cursor: pointer;
}
.empty-state {
display: flex;
flex-direction: column;

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
allowBuilds:
'@parcel/watcher': set this to true or false
core-js: set this to true or false
esbuild: set this to true or false

BIN
static/tips.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,3 +1,3 @@
export const BASE_URL = 'https://cmp-api.boss160.cn';
export const BASE_URL = import.meta.env.VITE_BASE_URL;
export const APP_TYPE = 'official_account';

View File

@@ -1,4 +1,5 @@
const ALIPAY_PENDING_REFRESH_KEY = 'pending_alipay_payment_refresh';
const ALIPAY_PAYMENT_DATA_KEY = 'pending_alipay_payment_data';
export const PAYMENT_REFRESH_TARGETS = {
PACKAGE_ORDER: 'package-order',
@@ -13,8 +14,22 @@ export function isValidWechatPayConfig(payConfig) {
}
export function getAlipayPaymentUrl(paymentLink) {
if (!paymentLink) return '';
return paymentLink.copy_link || paymentLink.qr_link || '';
return getAlipayPaymentUrls(paymentLink).copyUrl;
}
export function getAlipayPaymentUrls(paymentLink) {
if (!paymentLink) {
return { copyUrl: '', qrUrl: '' };
}
if (typeof paymentLink === 'string') {
return { copyUrl: paymentLink, qrUrl: paymentLink };
}
return {
copyUrl: paymentLink.copy_link || paymentLink.qr_link || '',
qrUrl: paymentLink.qr_link || paymentLink.copy_link || ''
};
}
export function isValidAlipayPaymentLink(paymentLink) {
@@ -39,40 +54,108 @@ export function consumePendingPaymentRefresh(target) {
return true;
}
export function getPendingAlipayPaymentData() {
return uni.getStorageSync(ALIPAY_PAYMENT_DATA_KEY) || null;
}
function encodePaymentUrl(value) {
try {
return btoa(unescape(encodeURIComponent(value)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
} catch (e) {
return '';
}
}
function getAlipayPaymentPageUrl(refreshTarget, payUrl, qrUrl) {
const params = [`t=${Date.now()}`];
if (refreshTarget) {
params.push(`from=${encodeURIComponent(refreshTarget)}`);
}
if (payUrl) {
const encodedPayUrl = encodePaymentUrl(payUrl);
if (encodedPayUrl) {
params.push(`pay64=${encodedPayUrl}`);
}
}
if (qrUrl) {
const encodedQrUrl = encodePaymentUrl(qrUrl);
if (encodedQrUrl) {
params.push(`qr64=${encodedQrUrl}`);
}
}
return `/pages/alipay-payment/alipay-payment?${params.join('&')}`;
}
function getAlipayPaymentBrowserUrl(pageUrl) {
if (typeof window === 'undefined' || !window.location) {
return pageUrl;
}
const { origin, pathname } = window.location;
const pagePath = pageUrl.replace(/^\//, '');
const basePath = pathname.includes('/pages/')
? pathname.slice(0, pathname.indexOf('/pages/'))
: pathname.replace(/\/[^/]*$/, '');
return `${origin}${basePath}/${pagePath}`;
}
function navigateToAlipayPaymentPage(pageUrl, resolve, reject) {
uni.navigateTo({
url: pageUrl,
success: () => resolve({ redirecting: true }),
fail: () => {
uni.redirectTo({
url: pageUrl,
success: () => resolve({ redirecting: true }),
fail: () => {
if (typeof window !== 'undefined' && window.location) {
window.location.href = getAlipayPaymentBrowserUrl(pageUrl);
resolve({ redirecting: true });
return;
}
reject(new Error('\u6253\u5f00\u652f\u4ed8\u5b9d\u652f\u4ed8\u9875\u5931\u8d25'));
}
});
}
});
}
export function openAlipayPayment(paymentLink, refreshTarget) {
return new Promise((resolve, reject) => {
const paymentUrl = getAlipayPaymentUrl(paymentLink);
const { copyUrl, qrUrl } = getAlipayPaymentUrls(paymentLink);
if (!paymentUrl) {
reject(new Error('Invalid payment link'));
if (!copyUrl && !qrUrl) {
reject(new Error('\u652f\u4ed8\u94fe\u63a5\u65e0\u6548'));
return;
}
markPendingPaymentRefresh(refreshTarget);
if (typeof window !== 'undefined' && window.location) {
window.location.href = paymentUrl;
resolve({ redirecting: true });
return;
}
uni.setClipboardData({
data: paymentUrl,
success: () => resolve({ copied: true }),
fail: () => reject(new Error('Unable to open payment link in this environment'))
uni.setStorageSync(ALIPAY_PAYMENT_DATA_KEY, {
copyUrl,
qrUrl,
refreshTarget,
timestamp: Date.now()
});
navigateToAlipayPaymentPage(getAlipayPaymentPageUrl(refreshTarget, copyUrl, qrUrl), resolve, reject);
});
}
export function wechatH5Pay(payConfig) {
return new Promise((resolve, reject) => {
if (!isValidWechatPayConfig(payConfig)) {
reject(new Error('Invalid payment params'));
reject(new Error('\u652f\u4ed8\u53c2\u6570\u65e0\u6548'));
return;
}
if (typeof WeixinJSBridge === 'undefined') {
reject(new Error('Please open in WeChat'));
reject(new Error('\u8bf7\u5728\u5fae\u4fe1\u4e2d\u6253\u5f00'));
return;
}
@@ -87,17 +170,17 @@ export function wechatH5Pay(payConfig) {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
resolve({
success: true,
message: 'Payment successful'
message: '\u652f\u4ed8\u6210\u529f'
});
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
reject({
cancelled: true,
message: 'Payment cancelled'
message: '\u5df2\u53d6\u6d88\u652f\u4ed8'
});
} else {
reject({
success: false,
message: 'Payment failed',
message: '\u652f\u4ed8\u5931\u8d25',
error: res
});
}
@@ -107,7 +190,7 @@ export function wechatH5Pay(payConfig) {
export function showPaymentToast(success, message) {
uni.showToast({
title: message || (success ? 'Payment successful' : 'Payment failed'),
title: message || (success ? '\u652f\u4ed8\u6210\u529f' : '\u652f\u4ed8\u5931\u8d25'),
icon: success ? 'success' : 'none',
duration: 2000
});
@@ -115,9 +198,9 @@ export function showPaymentToast(success, message) {
export function handlePaymentError(error) {
if (error.cancelled) {
showPaymentToast(false, 'Payment cancelled');
showPaymentToast(false, '\u5df2\u53d6\u6d88\u652f\u4ed8');
return;
}
showPaymentToast(false, error.message || 'Payment failed');
showPaymentToast(false, error.message || '\u652f\u4ed8\u5931\u8d25');
}

View File

@@ -1,16 +1,47 @@
// utils/wxsdk.js —— 封装微信扫一扫 不用旧的了
import { wechatApi } from '@/api/index.js'
const wx = window.jWeixin
const getWx = () => window.jWeixin || window.wx
const entryUrl = window.__WX_ENTRY_URL__ || window.location.href.split('#')[0]
let configPromise = null
let configUrl = ''
let configReady = false
const isIOS = () => /iphone|ipad|ipod/i.test(navigator.userAgent)
const getConfigUrl = () => {
// iOS 微信 WebView 的 JS-SDK 签名 URL 需要使用 SPA 首次进入的地址。
if (isIOS()) {
return entryUrl
}
return window.location.href.split('#')[0]
}
/**
* 初始化微信 JS-SDK
*/
export async function initWxConfig() {
const url = window.location.href.split('#')[0]
const wx = getWx()
if (!wx) {
throw new Error('微信 SDK 未加载')
}
const url = getConfigUrl()
console.log(url);
try {
const data = await wechatApi.getJssdkConfig(url)
if (configReady && configUrl === url) {
return
}
if (configPromise && configUrl === url) {
return configPromise
}
configUrl = url
configReady = false
configPromise = wechatApi.getJssdkConfig(url).then((data) => {
const { app_id, timestamp, nonce_str, signature } = data
return new Promise((resolve, reject) => {
@@ -24,24 +55,37 @@ export async function initWxConfig() {
})
wx.ready(() => {
console.log('微信 SDK 就绪')
configReady = true
resolve()
})
wx.error((err) => {
console.error('wx.config 失败', err)
configReady = false
configPromise = null
reject(err)
})
})
} catch (e) {
}).catch((e) => {
console.error('获取微信签名失败', e)
configPromise = null
configReady = false
throw e
}
})
return configPromise
}
/**
* 调起微信扫一扫
* @returns {Promise<string>} 扫码结果字符串
*/
export function wxScan() {
export async function wxScan() {
await initWxConfig()
const wx = getWx()
if (!wx) {
throw new Error('微信 SDK 未加载')
}
return new Promise((resolve, reject) => {
wx.scanQRCode({
needResult: 1,
@@ -63,4 +107,4 @@ export function wxScan() {
*/
export function isInWechat() {
return /micromessenger/i.test(navigator.userAgent)
}
}

View File

@@ -1,8 +1,39 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
const mpVerifyFiles = [
'MP_verify_0UoX8yClVgREjgPj.txt',
'MP_verify_4E1dVbOcZ9KzTzoc.txt',
'MP_verify_yU2Z8mp831jh6QX7.txt'
]
function copyMpVerifyFiles() {
return {
name: 'copy-mp-verify-files',
closeBundle() {
const outputDir = resolve(process.cwd(), 'dist/build/h5')
if (!existsSync(outputDir)) {
return
}
mkdirSync(outputDir, { recursive: true })
mpVerifyFiles.forEach((file) => {
const source = resolve(process.cwd(), file)
if (existsSync(source)) {
copyFileSync(source, resolve(outputDir, file))
}
})
}
}
}
export default defineConfig({
plugins: [uni()],
plugins: [uni(), copyMpVerifyFiles()],
root: process.cwd(),
css: {
preprocessorOptions: {
@@ -11,4 +42,4 @@ export default defineConfig({
}
}
}
})
})