merge develop into main
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.html linguist-detectable=false
|
||||
*.vue linguist-detectable=true
|
||||
@@ -3,7 +3,7 @@ name: 构建并部署前端到生产环境
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- dev
|
||||
- test
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: 设置镜像标签
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||
@@ -52,8 +52,8 @@ jobs:
|
||||
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
||||
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
|
||||
- name: 部署到本地(仅 main 分支)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
- name: 部署到本地(仅 develop 分支)
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
run: |
|
||||
mkdir -p ${{ env.DEPLOY_DIR }}
|
||||
|
||||
|
||||
15
.gitignore
vendored
@@ -2,9 +2,22 @@
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
npminstall-debug.log
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Common local files
|
||||
dist-ssr
|
||||
*.local
|
||||
.cursorrules
|
||||
.claude
|
||||
.codex
|
||||
.env.development
|
||||
.env.production
|
||||
.scratch
|
||||
.agents
|
||||
Thumbs.db
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
@@ -91,4 +104,4 @@ dist/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
|
||||
19
App.vue
@@ -7,13 +7,18 @@
|
||||
<style lang="scss">
|
||||
:root {
|
||||
/* 主色 */
|
||||
--primary: #0A84FF;
|
||||
--primary-light: #5AC8FA;
|
||||
--primary-dark: #0066CC;
|
||||
--primary: #55ab5c;
|
||||
--primary-light: #8bc98f;
|
||||
--primary-dark: #3f8f47;
|
||||
--up-primary: #55ab5c;
|
||||
--up-primary-light: #eaf6ec;
|
||||
--up-primary-dark: #3f8f47;
|
||||
|
||||
/* 辅助色 */
|
||||
--success: #30D158;
|
||||
--success-light: rgba(48, 209, 88, 0.12);
|
||||
--success: #55ab5c;
|
||||
--success-light: rgba(85, 171, 92, 0.12);
|
||||
--up-success: #55ab5c;
|
||||
--up-success-light: #eaf6ec;
|
||||
--warning: #FF9F0A;
|
||||
--warning-light: rgba(255, 159, 10, 0.12);
|
||||
--danger: #FF453A;
|
||||
@@ -235,7 +240,7 @@
|
||||
}
|
||||
|
||||
&.tag-primary {
|
||||
background: rgba(10, 132, 255, 0.12);
|
||||
background: rgba(85, 171, 92, 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -289,7 +294,7 @@
|
||||
}
|
||||
|
||||
&.btn-success {
|
||||
background: var(--success);
|
||||
background: var(--primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
|
||||
20
Dockerfile
@@ -9,14 +9,17 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
# 设置工作目录
|
||||
WORKDIR /build
|
||||
|
||||
# 设置 npm 镜像源
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
# 安装 pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
# 复制 package.json(利用 Docker 缓存)
|
||||
COPY package.json ./
|
||||
# 设置 pnpm 镜像源
|
||||
RUN pnpm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 复制 package.json 和 pnpm-lock.yaml(利用 Docker 缓存)
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# 安装所有依赖
|
||||
RUN npm install --legacy-peer-deps
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 安装 git (用于获取 commit 信息)
|
||||
RUN apk add --no-cache git
|
||||
@@ -24,21 +27,18 @@ RUN apk add --no-cache git
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 列出文件确认复制成功
|
||||
RUN ls -la
|
||||
|
||||
# 创建 src 目录软链接 (兼容 uni-app 新版本)
|
||||
RUN mkdir -p src && \
|
||||
ln -sf /build/manifest.json /build/src/manifest.json && \
|
||||
ln -sf /build/pages.json /build/src/pages.json
|
||||
|
||||
# 构建 H5 生产版本
|
||||
RUN npm run build:h5
|
||||
RUN pnpm run build:h5
|
||||
|
||||
# ================================
|
||||
# 阶段 2: 运行阶段
|
||||
# ================================
|
||||
FROM --platform=linux/amd64 nginx:alpine
|
||||
FROM --platform=linux/amd64 nginx:1.31.2-alpine
|
||||
|
||||
# 使用阿里云镜像源加速
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export { authApi } from './modules/auth.js';
|
||||
export { assetApi } from './modules/asset.js';
|
||||
export { assetApi, hasActiveOrPendingPackage, isAssetRealNameCompleted } from './modules/asset.js';
|
||||
export { deviceApi } from './modules/device.js';
|
||||
export { exchangeApi } from './modules/exchange.js';
|
||||
export { orderApi } from './modules/order.js';
|
||||
export { notificationApi } from './modules/notification.js';
|
||||
export { realnameApi } from './modules/realname.js';
|
||||
export { walletApi } from './modules/wallet.js';
|
||||
export { wechatApi } from './modules/wechat.js';
|
||||
export { wechatApi } from './modules/wechat.js';
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
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 isAssetRealNameCompleted = (assetInfo = {}) => {
|
||||
if (assetInfo.asset_type === 'device') {
|
||||
return (assetInfo.cards || []).some((card) => Number(card?.real_name_status) === 1);
|
||||
}
|
||||
|
||||
return Number(assetInfo.real_name_status) === 1;
|
||||
};
|
||||
|
||||
export const hasActiveOrPendingPackage = async (identifier) => {
|
||||
const [pendingData, activeData] = await Promise.all([
|
||||
assetApi.getPackageHistory(identifier, 1, 1, { status: 0 }),
|
||||
assetApi.getPackageHistory(identifier, 1, 1, { status: 1 })
|
||||
]);
|
||||
|
||||
return [pendingData, activeData].some(data => Array.isArray(data?.items) && data.items.length > 0);
|
||||
};
|
||||
|
||||
export const assetApi = {
|
||||
getInfo(identifier) {
|
||||
return request({
|
||||
url: '/api/c/v1/asset/info',
|
||||
method: 'GET',
|
||||
data: { identifier }
|
||||
});
|
||||
}).then(normalizeAssetInfo);
|
||||
},
|
||||
|
||||
getPackageHistory(identifier, page, page_size, params = {}) {
|
||||
@@ -17,11 +53,14 @@ export const assetApi = {
|
||||
});
|
||||
},
|
||||
|
||||
getPackages(identifier) {
|
||||
getPackages(identifier, packageType = '') {
|
||||
return request({
|
||||
url: '/api/c/v1/asset/packages',
|
||||
method: 'GET',
|
||||
data: { identifier }
|
||||
data: {
|
||||
identifier,
|
||||
...(packageType ? { package_type: packageType } : {})
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -32,4 +71,4 @@ export const assetApi = {
|
||||
data: { identifier }
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@ export const authApi = {
|
||||
return request({
|
||||
url: '/api/c/v1/auth/verify-asset',
|
||||
method: 'POST',
|
||||
data: { identifier }
|
||||
data: { identifier },
|
||||
showError: false
|
||||
});
|
||||
},
|
||||
|
||||
@@ -47,4 +48,4 @@ export const authApi = {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
36
api/modules/notification.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import request from '@/utils/request.js';
|
||||
|
||||
export const notificationApi = {
|
||||
getList(page = 1, page_size = 20, is_read) {
|
||||
return request({
|
||||
url: '/api/c/v1/notifications',
|
||||
method: 'GET',
|
||||
data: {
|
||||
page,
|
||||
page_size,
|
||||
...(is_read === undefined ? {} : { is_read })
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getUnreadCount() {
|
||||
return request({
|
||||
url: '/api/c/v1/notifications/unread-count',
|
||||
method: 'GET'
|
||||
});
|
||||
},
|
||||
|
||||
markRead(id) {
|
||||
return request({
|
||||
url: `/api/c/v1/notifications/${id}/read`,
|
||||
method: 'PUT'
|
||||
});
|
||||
},
|
||||
|
||||
markAllRead() {
|
||||
return request({
|
||||
url: '/api/c/v1/notifications/read-all',
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -22,10 +22,8 @@ export const orderApi = {
|
||||
},
|
||||
|
||||
create(identifier, package_ids, payment_method) {
|
||||
const data = { identifier, package_ids };
|
||||
if (payment_method === 'alipay') {
|
||||
data.payment_method = payment_method;
|
||||
} else {
|
||||
const data = { identifier, package_ids, payment_method };
|
||||
if (payment_method === 'wechat') {
|
||||
data.app_type = APP_TYPE;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
gap: 20rpx;
|
||||
|
||||
.service-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
}
|
||||
|
||||
.service-content {
|
||||
@@ -66,10 +66,10 @@
|
||||
|
||||
.service-btn {
|
||||
padding: 16rpx 32rpx;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
|
||||
background: #55ab5c;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<template>
|
||||
<view class="card function-card">
|
||||
<view class="card-header">
|
||||
<view class="title">功能菜单</view>
|
||||
<!-- <view class="header-actions">
|
||||
<button class="btn-apple btn-primary" @tap="$emit('sync')">运营数据同步</button>
|
||||
</view> -->
|
||||
</view>
|
||||
<view class="function-grid">
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'authentication')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/authentication.png" mode="aspectFit" alt="实名认证"></image>
|
||||
<image class="function-image icon-authentication" src="/static/authentication.png" mode="aspectFit" alt="实名认证"></image>
|
||||
</view>
|
||||
<view :class="['function-name', realNameStatus === '已实名' ? 'text-primary' : '']">
|
||||
{{ realNameStatus || '实名认证' }}
|
||||
@@ -18,48 +12,56 @@
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('sync')" role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/data.png" mode="aspectFit" alt="运营数据同步"></image>
|
||||
<image class="function-image icon-data" src="/static/data.png" mode="aspectFit" alt="数据同步"></image>
|
||||
</view>
|
||||
<view class="function-name">运营数据同步</view>
|
||||
<view class="function-name">数据同步</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'package-order')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/shop.png" mode="aspectFit" alt="套餐订购"></image>
|
||||
<image class="function-image icon-shop" src="/static/shop.png" mode="aspectFit" alt="套餐订购"></image>
|
||||
</view>
|
||||
<view class="function-name">套餐订购</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'notifications')" role="button"
|
||||
tabindex="0">
|
||||
<view class="function-icon function-icon-badge">
|
||||
<image class="function-image icon-notification" src="/static/notification.png" mode="aspectFit" alt="站内通知"></image>
|
||||
<view v-if="unreadCount > 0" class="unread-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</view>
|
||||
</view>
|
||||
<view class="function-name">站内通知</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'order-list')" role="button"
|
||||
tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/order.png" mode="aspectFit" alt="我的订单"></image>
|
||||
<image class="function-image icon-order" src="/static/order.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', 'back')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/back.png" mode="aspectFit" alt="后台管理"></image>
|
||||
<image class="function-image icon-back" src="/static/back.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', 'switch')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/change.png" mode="aspectFit" alt="切换运营商"></image>
|
||||
<image class="function-image icon-change" src="/static/change.png" mode="aspectFit" alt="切换运营商"></image>
|
||||
</view>
|
||||
<view class="function-name">切换运营商</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'asset-package')"
|
||||
role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/asset-package-history.png" mode="aspectFit" alt="资产套餐历史"></image>
|
||||
<image class="function-image icon-asset-package" src="/static/asset-package-history.png" mode="aspectFit" alt="资产套餐"></image>
|
||||
</view>
|
||||
<view class="function-name">资产套餐历史</view>
|
||||
<view class="function-name">历史套餐</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="handleBindPhone" role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/bind-phone.png" mode="aspectFit" alt="绑定手机号"></image>
|
||||
<image class="function-image icon-bind-phone" src="/static/bind-phone.png" mode="aspectFit" alt="绑定手机号"></image>
|
||||
</view>
|
||||
<view :class="['function-name', alreadyBindPhone ? 'text-primary' : 'text-danger']">
|
||||
{{ alreadyBindPhone ? '已绑定' : '未绑定' }}
|
||||
@@ -68,42 +70,42 @@
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'change-phone')" role="button"
|
||||
tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/change-phone.png" mode="aspectFit" alt="更换手机号"></image>
|
||||
<image class="function-image icon-change-phone" src="/static/change-phone.png" mode="aspectFit" alt="更换手机号"></image>
|
||||
</view>
|
||||
<view class="function-name">更换手机号</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive"
|
||||
@tap="$emit('enter', 'device-exchange')" role="button" tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/change.png" mode="aspectFit" alt="换货"></image>
|
||||
<image class="function-image icon-change" src="/static/change-shop.png" mode="aspectFit" alt="换货"></image>
|
||||
</view>
|
||||
<view class="function-name">换货</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'wallet')" role="button"
|
||||
tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/wallet.png" mode="aspectFit" alt="钱包"></image>
|
||||
<image class="function-image icon-wallet" src="/static/wallet-home.png" mode="aspectFit" alt="钱包"></image>
|
||||
</view>
|
||||
<view class="function-name">{{ walletText }}</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 class="function-image icon-restart" 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>
|
||||
<image class="function-image icon-recover" src="/static/recover.png" mode="aspectFit" alt="恢复出厂"></image>
|
||||
</view>
|
||||
<view class="function-name">恢复出厂</view>
|
||||
</view>
|
||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'out')" role="button"
|
||||
tabindex="0">
|
||||
<view class="function-icon">
|
||||
<image src="/static/out.png" mode="aspectFit" alt="退出登录"></image>
|
||||
<image class="function-image icon-out" src="/static/out.png" mode="aspectFit" alt="退出登录"></image>
|
||||
</view>
|
||||
<view class="function-name">退出登录</view>
|
||||
</view>
|
||||
@@ -132,6 +134,10 @@
|
||||
walletBalance: {
|
||||
type: [Number, String],
|
||||
default: 0
|
||||
},
|
||||
unreadCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
@@ -164,8 +170,8 @@
|
||||
|
||||
.function-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16rpx;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12rpx;
|
||||
width: 100%;
|
||||
|
||||
.function-item {
|
||||
@@ -173,11 +179,11 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20rpx;
|
||||
padding: 16rpx 8rpx;
|
||||
|
||||
.function-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -186,14 +192,52 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.function-image {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.icon-authentication,
|
||||
.icon-shop,
|
||||
.icon-back,
|
||||
.icon-change-phone,
|
||||
.icon-wallet,
|
||||
.icon-restart {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.icon-data { transform: scale(1.06); }
|
||||
.icon-notification,
|
||||
.icon-change,
|
||||
.icon-asset-package { transform: scale(1.01); }
|
||||
.icon-order,
|
||||
.icon-recover { transform: scale(0.91); }
|
||||
.icon-bind-phone { transform: scale(0.93); }
|
||||
.icon-out { transform: scale(0.97); }
|
||||
}
|
||||
|
||||
.function-icon-badge { position: relative; }
|
||||
.unread-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -12rpx;
|
||||
min-width: 34rpx;
|
||||
height: 34rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 20rpx;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-size: 18rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.function-name {
|
||||
font-size: 28rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-top: 15rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
200
components/NotificationPopup.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<view v-if="show" class="notification-popup" @tap.stop>
|
||||
<view class="notification-popup-card">
|
||||
<view class="popup-header">
|
||||
<view class="popup-heading">新通知</view>
|
||||
<view class="popup-close" role="button" aria-label="关闭" @tap="$emit('close')">×</view>
|
||||
</view>
|
||||
|
||||
<swiper class="notification-swiper" :current="current" @change="$emit('change', $event)">
|
||||
<swiper-item v-for="item in items" :key="item.id">
|
||||
<view class="notification-content">
|
||||
<view class="notification-title">{{ item.title || '业务通知' }}</view>
|
||||
<view class="notification-severity">{{ getNotificationMeta(item) }}</view>
|
||||
<view class="notification-time">{{ formatDate(item.created_at) }}</view>
|
||||
<scroll-view scroll-y class="notification-body">
|
||||
<text>{{ item.body || '暂无通知内容' }}</text>
|
||||
</scroll-view>
|
||||
<up-button v-if="item.category === 'expiry'" class="renew-button" type="primary" size="small"
|
||||
@tap.stop="$emit('renew', item)">立即续费</up-button>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
|
||||
<view v-if="items.length > 1" class="popup-footer">
|
||||
<view class="popup-swipe-hint" aria-label="左右滑动切换通知">
|
||||
<text class="swipe-arrow">‹</text>
|
||||
<text>左右滑动切换通知</text>
|
||||
<text class="swipe-arrow">›</text>
|
||||
</view>
|
||||
<view class="popup-dots">
|
||||
<view v-for="(_, index) in items" :key="index" class="popup-dot" :class="{ active: index === current }"></view>
|
||||
</view>
|
||||
<view class="popup-counter">{{ current + 1 }}/{{ items.length }} 左右滑动查看</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
show: Boolean,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['close', 'change', 'renew']);
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '-';
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.notification-popup {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.notification-popup-card {
|
||||
width: 100%;
|
||||
max-width: 640rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 30rpx 32rpx 18rpx;
|
||||
}
|
||||
|
||||
.popup-heading {
|
||||
color: var(--text-primary);
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 48rpx;
|
||||
font-weight: 300;
|
||||
line-height: 46rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-swiper { height: 430rpx; }
|
||||
|
||||
.notification-content {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 18rpx 32rpx 28rpx;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.notification-severity {
|
||||
margin-top: 10rpx;
|
||||
color: var(--primary);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
margin-top: 12rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
box-sizing: border-box;
|
||||
height: 180rpx;
|
||||
margin-top: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
padding: 0 32rpx 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.renew-button {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.popup-swipe-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
color: var(--primary);
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.swipe-arrow {
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.popup-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.popup-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #d8dce3;
|
||||
}
|
||||
|
||||
.popup-dot.active { width: 28rpx; border-radius: 8rpx; background: var(--primary); }
|
||||
|
||||
.popup-counter {
|
||||
margin-top: 12rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
</style>
|
||||
510
components/RenewalPaymentPopup.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<template>
|
||||
<up-popup :show="show" mode="center" @close="close">
|
||||
<view class="renewal-popup">
|
||||
<view class="popup-header">
|
||||
<view class="popup-title">{{ popupTitle }}</view>
|
||||
<view class="popup-close" @tap="close">×</view>
|
||||
</view>
|
||||
|
||||
<view class="package-summary">
|
||||
<view class="summary-name">{{ summaryName }}</view>
|
||||
<view v-if="summaryPrice" class="summary-price">¥{{ summaryPrice }}</view>
|
||||
</view>
|
||||
|
||||
<view class="payment-methods">
|
||||
<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">
|
||||
<image v-if="method.value === 'alipay'" class="method-icon" src="/static/支付宝支付.png" mode="aspectFit"></image>
|
||||
<image v-else-if="method.value === 'wallet'" class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
|
||||
<image v-else-if="method.value === 'wechat'" class="method-icon" src="/static/微信支付.png" mode="aspectFit"></image>
|
||||
<text class="method-name">{{ method.label }}</text>
|
||||
</view>
|
||||
<view class="method-radio" :class="{ checked: paymentMethod === method.value }"></view>
|
||||
</view>
|
||||
<view v-if="paymentMethodOptions.length === 0" class="method-empty">暂无可用支付方式</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-footer">
|
||||
<button class="btn-apple btn-secondary" @tap="close">取消</button>
|
||||
<button class="btn-apple btn-primary" :disabled="submitting" @tap="confirmPay">
|
||||
{{ submitting ? '处理中...' : confirmText }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</up-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { assetApi, isAssetRealNameCompleted, orderApi } from '@/api/index.js';
|
||||
import {
|
||||
handlePaymentError,
|
||||
isValidAlipayPaymentLink,
|
||||
isValidWechatPayConfig,
|
||||
openAlipayPayment,
|
||||
PAYMENT_REFRESH_TARGETS,
|
||||
showPaymentToast,
|
||||
wechatH5Pay
|
||||
} from '@/utils/payment.js';
|
||||
import { formatMoney } from '@/utils/display.js';
|
||||
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
||||
|
||||
const props = defineProps({
|
||||
identifier: { type: String, default: '' },
|
||||
paymentRefreshTarget: { type: String, default: PAYMENT_REFRESH_TARGETS.ORDER_LIST }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['completed']);
|
||||
const show = ref(false);
|
||||
const submitting = ref(false);
|
||||
const packageIds = ref([]);
|
||||
const packageNames = ref([]);
|
||||
const renewalPrice = ref(null);
|
||||
const paymentMethod = ref('alipay');
|
||||
const allowedPaymentMethods = ref([]);
|
||||
const assetInfo = ref({});
|
||||
const operationMode = ref('renewal');
|
||||
const orderPaymentId = ref(null);
|
||||
const paymentMethodOptions = computed(() => getPaymentMethodOptions(allowedPaymentMethods.value));
|
||||
const packageNamesText = computed(() => packageNames.value.filter(Boolean).join('、') || '当前套餐');
|
||||
const popupTitle = computed(() => operationMode.value === 'order-payment' ? '立即支付' : '立即续费');
|
||||
const summaryName = computed(() => operationMode.value === 'order-payment'
|
||||
? packageNamesText.value.replace(/^当前套餐$/, '当前订单')
|
||||
: packageNamesText.value);
|
||||
const summaryPrice = computed(() => renewalPrice.value === null || renewalPrice.value === undefined || renewalPrice.value === ''
|
||||
? ''
|
||||
: formatMoney(renewalPrice.value));
|
||||
const confirmText = computed(() => operationMode.value === 'order-payment' ? '确认支付' : '确认续费');
|
||||
|
||||
const hasPreparedPaymentData = (paymentData) => {
|
||||
return isValidWechatPayConfig(paymentData?.pay_config) ||
|
||||
isValidAlipayPaymentLink(paymentData?.payment_link);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!submitting.value) show.value = false;
|
||||
};
|
||||
|
||||
const loadAssetInfo = async () => {
|
||||
assetInfo.value = await assetApi.getInfo(props.identifier);
|
||||
allowedPaymentMethods.value = normalizePaymentMethods(assetInfo.value.allowed_payment_methods);
|
||||
paymentMethod.value = getDefaultPaymentMethod(allowedPaymentMethods.value);
|
||||
};
|
||||
|
||||
const open = async (ids, names = [], price = null) => {
|
||||
operationMode.value = 'renewal';
|
||||
orderPaymentId.value = null;
|
||||
renewalPrice.value = price;
|
||||
const validIds = [...new Set((Array.isArray(ids) ? ids : [ids])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isInteger(value) && value > 0))];
|
||||
if (!validIds.length) {
|
||||
uni.showToast({ title: '当前暂无可续费套餐', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!props.identifier) {
|
||||
uni.showToast({ title: '未找到当前资产', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
packageIds.value = validIds;
|
||||
packageNames.value = Array.isArray(names) ? names.filter(Boolean) : [];
|
||||
try {
|
||||
await loadAssetInfo();
|
||||
} catch (error) {
|
||||
console.error('加载续费支付方式失败', error);
|
||||
uni.showToast({ title: error.msg || error.message || '加载支付方式失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetInfo.value.effective_realname_policy === 'before_order' &&
|
||||
assetInfo.value.realname_required && !isAssetRealNameCompleted(assetInfo.value)) {
|
||||
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;
|
||||
}
|
||||
show.value = true;
|
||||
};
|
||||
|
||||
const openOrderPayment = async (orderId, names = []) => {
|
||||
if (!orderId) return;
|
||||
if (!props.identifier) {
|
||||
uni.showToast({ title: '未找到当前资产', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
operationMode.value = 'order-payment';
|
||||
orderPaymentId.value = orderId;
|
||||
renewalPrice.value = null;
|
||||
packageIds.value = [];
|
||||
packageNames.value = Array.isArray(names) ? names.filter(Boolean) : [];
|
||||
try {
|
||||
await loadAssetInfo();
|
||||
} catch (error) {
|
||||
console.error('加载订单支付方式失败', error);
|
||||
uni.showToast({ title: error.msg || error.message || '加载支付方式失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paymentMethodOptions.value.length) {
|
||||
uni.showToast({ title: '暂无可用支付方式', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
show.value = true;
|
||||
};
|
||||
|
||||
const selectPaymentMethod = (method) => {
|
||||
if (paymentMethodOptions.value.some((item) => item.value === method)) {
|
||||
paymentMethod.value = method;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOrderStatus = async (orderId) => {
|
||||
try {
|
||||
const detail = await orderApi.getDetail(orderId);
|
||||
const status = detail?.payment_status ?? detail?.order?.payment_status;
|
||||
showPaymentToast(status === 2, status === 2 ? '支付成功' : '支付结果确认中');
|
||||
if (status === 2) emit('completed');
|
||||
} catch (error) {
|
||||
console.error('确认订单支付状态失败', error);
|
||||
showPaymentToast(false, '支付结果确认失败,请稍后查看订单');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreparedPayment = async (paymentData, orderId = null, isRecharge = false) => {
|
||||
if (isValidWechatPayConfig(paymentData?.pay_config)) {
|
||||
try {
|
||||
await wechatH5Pay(paymentData.pay_config);
|
||||
if (orderId) {
|
||||
await confirmOrderStatus(orderId);
|
||||
} else {
|
||||
showPaymentToast(true, isRecharge ? '充值成功,套餐将自动购买' : '支付成功');
|
||||
emit('completed');
|
||||
}
|
||||
} catch (error) {
|
||||
handlePaymentError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidAlipayPaymentLink(paymentData?.payment_link)) {
|
||||
await openAlipayPayment(paymentData.payment_link, props.paymentRefreshTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({ title: '支付参数获取失败', icon: 'none' });
|
||||
};
|
||||
|
||||
const handleRechargeOrder = async (orderResult) => {
|
||||
const recharge = orderResult.recharge;
|
||||
if (recharge.status === 2 || recharge.status === 3) {
|
||||
uni.showToast({ title: `充值订单${recharge.status_name || '不可支付'},请重新下单`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (recharge.status === 1 && !hasPreparedPaymentData(orderResult)) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '检测到您有待支付的充值订单,是否前往我的钱包查看?',
|
||||
confirmText: '去查看',
|
||||
cancelText: '取消',
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) uni.navigateTo({ url: '/pages/my-wallet/my-wallet' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasPreparedPaymentData(orderResult)) {
|
||||
uni.showToast({ title: '支付参数获取失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const packageName = packageNamesText.value;
|
||||
const rechargeAmount = orderResult.linked_package_info?.force_recharge_amount || recharge.amount;
|
||||
uni.showModal({
|
||||
title: '需要充值',
|
||||
content: `续费${packageName}需要先充值¥${formatMoney(rechargeAmount)},充值成功后将自动购买套餐`,
|
||||
confirmText: '去充值',
|
||||
cancelText: '取消',
|
||||
success: async ({ confirm }) => {
|
||||
if (confirm) await handlePreparedPayment(orderResult, null, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const confirmPay = async () => {
|
||||
if (submitting.value) return;
|
||||
if (operationMode.value === 'order-payment') {
|
||||
await confirmOrderPayment();
|
||||
return;
|
||||
}
|
||||
if (!packageIds.value.length) return;
|
||||
submitting.value = true;
|
||||
uni.showLoading({ title: '创建订单...', mask: true });
|
||||
|
||||
try {
|
||||
const orderResult = await orderApi.create(props.identifier, packageIds.value, paymentMethod.value);
|
||||
if (orderResult?.order_type === 'recharge' && orderResult.recharge) {
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
await handleRechargeOrder(orderResult);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderId = orderResult?.order?.order_id;
|
||||
if (!orderId) {
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
uni.showToast({ title: '订单创建失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '准备支付...', mask: true });
|
||||
const payResult = await orderApi.pay(orderId, paymentMethod.value);
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
|
||||
if (paymentMethod.value === 'wallet') {
|
||||
await confirmOrderStatus(orderId);
|
||||
return;
|
||||
}
|
||||
await handlePreparedPayment(payResult, orderId);
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
console.error('创建续费订单或支付失败', error);
|
||||
uni.showToast({ title: error.msg || error.message || '操作失败,请稍后重试', icon: 'none' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOrderPayment = async () => {
|
||||
if (!orderPaymentId.value) return;
|
||||
submitting.value = true;
|
||||
uni.showLoading({ title: '准备支付...', mask: true });
|
||||
|
||||
try {
|
||||
const payData = await orderApi.pay(orderPaymentId.value, paymentMethod.value);
|
||||
uni.hideLoading();
|
||||
show.value = false;
|
||||
|
||||
if (paymentMethod.value === 'wallet') {
|
||||
await confirmOrderStatus(orderPaymentId.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (paymentMethod.value === 'wechat') {
|
||||
if (!isValidWechatPayConfig(payData?.pay_config)) {
|
||||
uni.showToast({ title: '支付参数获取失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await wechatH5Pay(payData.pay_config);
|
||||
await confirmOrderStatus(orderPaymentId.value);
|
||||
} catch (error) {
|
||||
handlePaymentError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidAlipayPaymentLink(payData?.payment_link)) {
|
||||
uni.showToast({ title: '支付链接获取失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
await openAlipayPayment(payData.payment_link, props.paymentRefreshTarget);
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
console.error('订单支付失败', error);
|
||||
uni.showToast({ title: error.msg || error.message || '支付失败,请稍后重试', icon: 'none' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const payOrderDirectly = async (orderId, method = 'wallet') => {
|
||||
if (!orderId || submitting.value) return;
|
||||
submitting.value = true;
|
||||
uni.showLoading({ title: '准备支付...', mask: true });
|
||||
|
||||
try {
|
||||
await orderApi.pay(orderId, method);
|
||||
uni.hideLoading();
|
||||
if (method === 'wallet') {
|
||||
await confirmOrderStatus(orderId);
|
||||
}
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
console.error('直接支付订单失败', error);
|
||||
uni.showToast({ title: error.msg || error.message || '支付失败,请稍后重试', icon: 'none' });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, openOrderPayment, payOrderDirectly });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.renewal-popup {
|
||||
width: 600rpx;
|
||||
max-width: calc(100vw - 80rpx);
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-header,
|
||||
.popup-footer,
|
||||
.method-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
justify-content: space-between;
|
||||
padding: 30rpx;
|
||||
border-bottom: 1rpx solid var(--border-light);
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 40rpx;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.package-summary {
|
||||
padding: 32rpx 30rpx 24rpx;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-name {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.summary-price {
|
||||
margin-top: 14rpx;
|
||||
color: var(--primary);
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.payment-methods {
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.method-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
border: 2rpx solid var(--border-light);
|
||||
border-radius: var(--radius-medium);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(85, 171, 92, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.method-left { gap: 20rpx; }
|
||||
|
||||
.method-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.method-badge-alipay { background: #1677ff; }
|
||||
.method-name { color: var(--text-primary); font-size: 28rpx; }
|
||||
|
||||
.method-radio {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border: 2rpx solid var(--gray-400);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
|
||||
&.checked {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
|
||||
&::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.method-empty {
|
||||
padding: 28rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
padding: 30rpx;
|
||||
gap: 20rpx;
|
||||
border-top: 1rpx solid var(--border-light);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,11 +2,21 @@
|
||||
<view class="card user-info-card interactive">
|
||||
<view class="flex-row-g20">
|
||||
<view class="user-details flex-col-g8">
|
||||
<view class="flex-row-g20">
|
||||
<view class="info-copy-row">
|
||||
<view class="title">{{ currentCardNo || '-' }}</view>
|
||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"
|
||||
@tap.stop="copyText(currentCardNo, '号码')" aria-label="复制号码"></image>
|
||||
</view>
|
||||
<view v-if="!isDevice" class="info-copy-row">
|
||||
<view class="caption">ICCID:{{ deviceInfo.iccid || '-' }}</view>
|
||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"
|
||||
@tap.stop="copyText(deviceInfo.iccid, 'ICCID')" aria-label="复制ICCID"></image>
|
||||
</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 v-if="isDevice" class="tag-apple" :class="onlineStatus === '在线' ? 'tag-success' : 'tag-warning'">
|
||||
{{ onlineStatus }}
|
||||
@@ -15,27 +25,70 @@
|
||||
{{ networkStatus == 1 ? '正常' : '停机' }}
|
||||
</view>
|
||||
</view>
|
||||
<up-button v-if="hasPackageName" class="renew-button" type="primary" size="small"
|
||||
@tap.stop="emit('renew')">立即续费</up-button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['renew']);
|
||||
|
||||
const props = defineProps({
|
||||
currentCardNo: { type: String, default: '' },
|
||||
deviceInfo: { type: Object, default: () => ({}) },
|
||||
onlineStatus: { type: String, 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 }
|
||||
});
|
||||
|
||||
const hasPackageName = computed(() => {
|
||||
const packageName = String(props.deviceInfo?.packageName || '').trim();
|
||||
return Boolean(packageName && packageName !== '-');
|
||||
});
|
||||
|
||||
const copyText = (value, label) => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text || text === '-') return;
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => uni.showToast({ title: `${label}已复制`, icon: 'success' })
|
||||
});
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-info-card {
|
||||
color: var(--text-primary);
|
||||
|
||||
.expiry-warning { color: var(--danger); }
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-copy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.renew-button {
|
||||
margin: 24rpx 0 0;
|
||||
width: 180rpx;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<view class="card-header flex-row-sb">
|
||||
<view class="title">WiFi配置</view>
|
||||
<view class="btn-group">
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="$emit('modify')">修改</button>
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="copyConfig">复制配置</button>
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="$emit('modify')">修改配置</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="wifi-info flex-col-g16 mt-30">
|
||||
@@ -12,25 +13,27 @@
|
||||
<view class="caption">网络名称</view>
|
||||
<view class="subtitle">{{ deviceInfo.ssidName }}</view>
|
||||
</view>
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="$emit('copy', deviceInfo.ssidName)">复制</button>
|
||||
</view>
|
||||
<view class="wifi-item flex-row-sb">
|
||||
<view class="wifi-details flex-col-g8">
|
||||
<view class="caption">连接密码</view>
|
||||
<view class="subtitle">{{ deviceInfo.ssidPwd }}</view>
|
||||
</view>
|
||||
<button class="btn-apple btn-primary btn-mini" @tap="$emit('copy', deviceInfo.ssidPwd)">复制</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
deviceInfo: { type: Object, default: () => ({}) }
|
||||
});
|
||||
|
||||
defineEmits(['modify', 'copy']);
|
||||
const emit = defineEmits(['modify', 'copy-config']);
|
||||
|
||||
const copyConfig = () => {
|
||||
emit('copy-config', `网络名称: ${props.deviceInfo.ssidName || ''} 连接密码: ${props.deviceInfo.ssidPwd || ''}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -9,6 +9,9 @@ services:
|
||||
- '3003:80'
|
||||
networks:
|
||||
- device-voice-network
|
||||
tmpfs:
|
||||
- /run:rw
|
||||
- /tmp:rw
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/health']
|
||||
interval: 30s
|
||||
|
||||
286
docs/产品迭代7月份/七月迭代H5_C端改动说明.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# 七月迭代 H5/C 端改动说明
|
||||
|
||||
本文根据《七月迭代实现与接口对接说明》整理,重点说明本期 H5/C 端需要调整的页面、接口和预期效果。
|
||||
|
||||
## 一、改动总览
|
||||
|
||||
本期 H5/C 端主要涉及以下功能:
|
||||
|
||||
1. 店铺 C 端登录限制。
|
||||
2. 实名认证流程和实名状态展示。
|
||||
3. 支付方式展示与支付提交。
|
||||
4. 下架套餐老客户续费。
|
||||
5. 预计套餐最终到期时间展示。
|
||||
6. 套餐临期提醒。
|
||||
7. 换货通知提醒。
|
||||
8. C 端订单字段展示调整。
|
||||
|
||||
## 二、具体改动说明
|
||||
|
||||
### 1. 店铺 C 端登录限制(#41)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
资产验证后,根据接口返回结果判断是否允许登录。如果接口返回店铺已禁止 C 端登录,应直接展示后端错误提示,不继续获取或保存资产 Token。
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
POST /api/c/v1/auth/verify-asset
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 被限制的店铺无法新登录 H5/C 端。
|
||||
- 前端展示后端返回的业务错误信息。
|
||||
- 已经签发的 Token 不会被强制吊销。
|
||||
|
||||
### 2. 实名认证流程(#62)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
资产初始化时只使用后端返回的实名策略和实名状态,不要根据卡、设备或前端本地规则自行推断。
|
||||
|
||||
重点使用以下字段:
|
||||
|
||||
- `effective_realname_policy`
|
||||
- `realname_required`
|
||||
- `real_name_status`
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
GET /api/c/v1/asset/info?identifier=...
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- `none`:不需要实名。
|
||||
- `before_order`:下单前要求实名。
|
||||
- `after_order`:下单后要求实名。
|
||||
- 卡和设备存在策略冲突时,以设备最终生效策略为准。
|
||||
- 设备只要有一张有效绑定卡已实名,即视为设备已实名。
|
||||
|
||||
### 3. 支付方式展示和提交(#48)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
支付按钮和支付方式列表必须使用后端返回的 `allowed_payment_methods`,不能在前端写死卡、设备的微信、支付宝或钱包规则。
|
||||
|
||||
下单或充值时必须将用户选择的 `payment_method` 传给后端;微信支付场景按接口要求传递 `app_type`。
|
||||
|
||||
#### 接口
|
||||
|
||||
资产初始化:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/asset/info
|
||||
```
|
||||
|
||||
充值前校验:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/wallet/recharge-check
|
||||
```
|
||||
|
||||
创建订单:
|
||||
|
||||
```http
|
||||
POST /api/c/v1/orders/create
|
||||
```
|
||||
|
||||
钱包充值:
|
||||
|
||||
```http
|
||||
POST /api/c/v1/wallet/recharge
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 不同资产类型显示正确的支付方式。
|
||||
- 强充和普通充值遵循后端允许的支付配置。
|
||||
- 前端无法绕过后端支付限制。
|
||||
- 支付方式变化后,前端无需重新发布即可按接口结果生效。
|
||||
|
||||
### 4. 下架套餐老客户续费(#40)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
普通套餐列表中不展示下架套餐,但当前正在使用下架套餐的老客户仍可通过资产信息或历史订单获取套餐 ID,并继续调用现有创建订单接口。
|
||||
|
||||
创建订单时传递:
|
||||
|
||||
- 选中的套餐 ID;
|
||||
- 资产 `identifier`;
|
||||
- 当前允许的 `payment_method`。
|
||||
|
||||
#### 接口
|
||||
|
||||
获取当前资产套餐:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/asset/info
|
||||
```
|
||||
|
||||
获取历史订单:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/orders
|
||||
GET /api/c/v1/orders/:id
|
||||
```
|
||||
|
||||
创建续费订单:
|
||||
|
||||
```http
|
||||
POST /api/c/v1/orders/create
|
||||
```
|
||||
|
||||
相关返回字段:
|
||||
|
||||
- 资产信息中的 `current_package_id`;
|
||||
- 历史订单中的 `package_ids`。
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 老客户可以继续续费已下架套餐。
|
||||
- 不新增专用续费接口,仍复用普通创建订单接口。
|
||||
- 新客户和代理代购仍不能购买下架套餐。
|
||||
- 历史订单数据不被修改。
|
||||
|
||||
### 5. 预计套餐最终到期时间(#46)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
资产详情、资产列表等页面展示后端返回的预计最终到期时间,不要只展示当前套餐的到期时间。
|
||||
|
||||
重点使用:
|
||||
|
||||
- `estimated_final_expires_at`
|
||||
- `is_expiring`
|
||||
- 剩余天数及临期等级字段(如接口返回)
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
GET /api/c/v1/asset/info
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 用户看到资产综合计算后的最终到期时间。
|
||||
- 临期资产可以按后端返回的临期字段进行高亮。
|
||||
- 避免因只显示当前套餐到期时间导致到期日期不准确。
|
||||
|
||||
### 6. 套餐临期提醒(#33)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
接入 C 端现有站内通知能力,读取未读通知并在合适时机展示弹窗或提醒入口。
|
||||
|
||||
#### 接口
|
||||
|
||||
获取未读数量:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/notifications/unread-count
|
||||
```
|
||||
|
||||
获取通知列表:
|
||||
|
||||
```http
|
||||
GET /api/c/v1/notifications
|
||||
```
|
||||
|
||||
标记已读:
|
||||
|
||||
```http
|
||||
PUT /api/c/v1/notifications/:id/read
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 在套餐剩余 15 天、7 天、3 天时触发提醒。
|
||||
- 前端可按后端返回的临期等级进行展示。
|
||||
- 0~3 天的临期提醒优先级最高。
|
||||
- 通知通过 C 端站内消息展示,不新增企微业务员提醒。
|
||||
|
||||
### 7. 换货通知提醒(#188)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
继续使用现有 C 端通知接口,展示换货相关的未读通知,并支持点击后标记已读。
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
GET /api/c/v1/notifications/unread-count
|
||||
GET /api/c/v1/notifications
|
||||
PUT /api/c/v1/notifications/:id/read
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 换货创建后,用户可以在 H5/C 端收到站内通知。
|
||||
- 首页或通知入口能够展示未读数量。
|
||||
- 用户查看后可以正常标记已读。
|
||||
- 不新增营销投放、ERP 或其他业务单据。
|
||||
|
||||
### 8. C 端订单字段展示(#181)
|
||||
|
||||
#### 前端改动
|
||||
|
||||
订单列表和订单详情使用后端返回的订单角色及资产标识字段。
|
||||
|
||||
重点字段:
|
||||
|
||||
- `purchase_role`
|
||||
- `asset_identifier`
|
||||
|
||||
设备资产标识的显示规则:
|
||||
|
||||
1. 优先显示 `VirtualNo`;
|
||||
2. `VirtualNo` 为空时显示 `IMEI`;
|
||||
3. 不使用 SN 冒充订单设备标识;
|
||||
4. 历史空数据不需要前端伪造。
|
||||
|
||||
#### 接口
|
||||
|
||||
```http
|
||||
GET /api/c/v1/orders
|
||||
GET /api/c/v1/orders/:id
|
||||
```
|
||||
|
||||
#### 预期效果
|
||||
|
||||
- 订单中的购买角色显示准确。
|
||||
- 卡展示正确的 ICCID 或资产标识。
|
||||
- 设备展示 VirtualNo,缺失时使用 IMEI。
|
||||
- 订单资产标识与退款、换货等业务中的固化快照保持一致。
|
||||
|
||||
## 三、H5/C 端不需要改动的内容
|
||||
|
||||
以下事项本期明确不需要 H5/C 端新增功能或接口:
|
||||
|
||||
- #84 H5 首页隐藏设备下 ICCID:本期确认不做。
|
||||
- #73 行业卡未实名复机:保持现有逻辑。
|
||||
- #94 状态同步和运营商回调:继续读取现有资产状态字段,无新增 H5/C 端调用。
|
||||
- 企业微信审批回调:H5/C 端不调用。
|
||||
- 原路退款、聚水潭、跨品类换货、分销码/佣金提现:本期不做。
|
||||
|
||||
## 四、H5/C 端联调注意事项
|
||||
|
||||
- 金额接口字段默认单位为“分”,页面展示时转换为“元”,提交时仍传整数分。
|
||||
- `effective_realname_policy` 和 `allowed_payment_methods` 必须以后端返回值为准。
|
||||
- 企微审批业务只读展示 `approval_provider`、`approval_status`、`approval_status_name`,不能再显示旧的人工通过、驳回或线下充值确认按钮。
|
||||
- 企微回调接口由企微服务器调用,H5/C 端不调用:
|
||||
|
||||
```http
|
||||
GET/POST /api/callback/wecom/approval/:application_id
|
||||
```
|
||||
|
||||
- 前端不要根据接口名称自行假设存在“下架套餐续费接口”,续费仍调用:
|
||||
|
||||
```http
|
||||
POST /api/c/v1/orders/create
|
||||
```
|
||||
|
||||
284
docs/产品迭代7月份/七月迭代实现与接口对接说明(1).md
Normal file
@@ -0,0 +1,284 @@
|
||||
# 七月迭代实现与接口对接说明
|
||||
|
||||
> 面向:产品、前端、测试和联调人员
|
||||
> 范围:`deliver-july-iteration-confirmed-scope` 及本期确认“后端已完成,只需前端联调”的需求
|
||||
> 接口细节:以 [`docs/admin-openapi.yaml`](../admin-openapi.yaml) 为准,本文只说明关键调用和字段变化。
|
||||
|
||||
## 一、先看这几个关键结论
|
||||
|
||||
1. **设备没有限速接口**:限速只允许对 IoT 卡 ICCID 操作,固定档位 `-1~8`,不通过设备绑定卡间接限速。
|
||||
2. **企微模板在企微后台创建**:系统只配置应用、默认发起人、账号 userid 绑定、`template_id` 和控件映射,不在本系统设计审批节点。
|
||||
3. **代理发起审批走默认发起人**:企微单据使用配置的默认成员发起,但退款、充值等本地业务单仍记录真实代理提交人。
|
||||
4. **下架套餐续费不新增专用接口**:历史订单和资产信息返回稳定套餐 ID,前端仍调用现有 C 端创建订单接口生成一张新订单。
|
||||
5. **实名流程由后端返回值决定**:前端使用 `effective_realname_policy`,不能按卡或设备自行推断;设备有任意一张有效绑定卡已实名即视为已实名。
|
||||
6. **支付按钮由后端返回值决定**:前端使用 `allowed_payment_methods`,不要自行写死卡/设备的微信、支付宝或钱包规则。
|
||||
7. **设备批量分配只复用导入任务外壳**:复用任务表、队列、进度和结果页;不会进入原 Excel 创建设备逻辑。
|
||||
8. **系列套餐批量授权后端原本就支持多选**:前端把多选套餐组装为 `packages[]` 调现有接口即可,不需要新后端接口。
|
||||
|
||||
## 二、本期需求怎么实现
|
||||
|
||||
### 2.1 本期新增或修改后端的需求
|
||||
|
||||
| 需求 | 实现方式 | 关键口径检查 |
|
||||
| --- | --- | --- |
|
||||
| #189 换货后退款套餐未失效 | 退款处理不再只按旧资产查套餐,而是按原订单和换货迁移关系定位新资产上的对应套餐权益 | 只失效该退款订单产生的权益,不影响其他订单套餐;无前端改动 |
|
||||
| #188 换货 C 端提醒 | 创建物流换货单时写可靠通知事件,继续走现有 C 端站内通知和未读弹窗 | 不做营销投放、ERP、自动创建其他单据 |
|
||||
| #182/#44 提交人和审批展示 | 退款、代理充值、换货列表/详情批量解析提交人 ID 和名称;企微详情已具备将审批节点 userid 批量映射系统账号的内部能力 | 当前退款/充值业务 DTO 只稳定返回提交人和审批状态,尚未直接返回审批节点人员列表;如页面必须展示具体审批人,仍需把已有投影接入业务查询 |
|
||||
| #181 订单渠道和资产标识 | C 端新订单保存正确 `purchase_role`;卡返回 ICCID,设备优先 VirtualNo、为空时返回 IMEI | 不用 SN 冒充订单设备标识;历史空数据不伪造 |
|
||||
| #41 店铺 C 端登录限制 | 店铺增加 `client_login_disabled`;C 端验证资产后、签发短期令牌前检查所属店铺 | 只阻止新登录,不吊销已有 Token;平台库存保持原行为 |
|
||||
| #53 卡/设备实名筛选 | 卡按自身实名状态过滤;设备通过有效绑定卡 `EXISTS` 实时判断 | 设备任意一张有效绑定卡已实名即为已实名;未建投影或 Worker |
|
||||
| #57 退款中禁止换货 | 在现有换货创建入口前检查资产未终结退款 | 拒绝文案为“该资产存在退款申请”;不依赖企微实时接口 |
|
||||
| #62 三种实名顺序 | 保留 `none/before_order/after_order`,补齐卡和设备最多 500 条批量修改,C 端返回生效策略 | 批量全成全败;设备和下卡冲突时以设备策略为准 |
|
||||
| #97 主钱包低余额预警 | 消费主钱包扣款事实,余额首次从不少于 100 元跌破 100 元时通知店铺业务员 | 阈值以下不重复;恢复后再次跌破可再次提醒;无业务员不猜接收人 |
|
||||
| #33 套餐临期提醒 | 每日计算 15/7/3 天节点,提供后台/代理临期列表及数量,并向个人客户发送站内通知 | 高亮为 8~15、4~7、0~3 天;0~3 天优先;不发企微业务员提醒 |
|
||||
| #34 员工线下代充值 | 原充值创建入口在 `offline` 场景创建通用审批实例和企微提交事件;通过后幂等入主钱包 | 在线扫码充值后置;真实提交人、金额和凭证明文业务快照保留 |
|
||||
| #35 退款企微审批 | 原退款申请关联唯一通用审批实例,企微标准终态驱动现有退款处理 | 不做原路退款;重复回调/轮询不会重复退款或失效套餐 |
|
||||
| #37 企业微信审批 | 完成应用连接、通讯录同步、账号绑定、默认发起人、模板映射、提交、回调、详情查询和轮询恢复 | 模板/审批人规则由企微维护;超时结果未知不盲目重提 |
|
||||
| #36 批量订购套餐 | 单列 CSV,整批选择一个套餐和支付方式,走独立异步任务,逐行复用现有订单和钱包规则 | 不选择代理,不在 CSV 中逐行指定套餐;部分失败不影响其他行 |
|
||||
| #40 下架套餐老客户续费 | 普通可购列表排除下架套餐;当前使用者可用历史订单或当前资产返回的套餐 ID 调现有下单接口创建新订单 | 无专用续费接口;新客户和代理代购仍拒绝;历史订单不修改 |
|
||||
| #42 六类导出 | 在现有导出任务框架新增 IoT 卡、套餐、钱包流水、代理充值、退款、换货 datasource | 按现有数据权限和字段来源导出,不建设新字段权限平台 |
|
||||
| #47 卡固定档位限速 | 后台选择固定档位,按 ICCID 调用 Gateway,并记录操作审计和 Integration Log | 仅 IoT 卡;设备无接口;超时返回结果未知并人工核对 |
|
||||
| #48 按资产类型配置支付方式 | `system_config` 分别保存卡、设备允许的 `wallet/wechat/alipay` 集合;C 端返回业务场景交集,订单端再次校验 | 至少保留一种;强充和普通充值剔除钱包;前端不可绕过 |
|
||||
| #49 设备批量分配 | 扩展现有设备导入任务的 `operation_type`,新增分配代理和设置套餐系列两个 CSV 分支 | CSV 单列 VirtualNo/IMEI/SN;复用现有权限、分配、系列绑定和幂等规则 |
|
||||
|
||||
### 2.2 后端此前已经完成,主要由前端正确调用或展示
|
||||
|
||||
| 需求 | 后端现状 | 前端要做什么 |
|
||||
| --- | --- | --- |
|
||||
| #45 换货新旧资产展示/搜索 | 换货列表已分别返回新旧资产,并支持两个独立搜索参数 | 分别提供“旧资产”和“新资产”搜索框,不要继续共用一个字段 |
|
||||
| #46 预计套餐到期 | 资产详情、C 端资产信息和相关列表已返回预计最终到期字段 | 展示 `estimated_final_expires_at`;按 `is_expiring` 或剩余天数高亮,不要只显示当前套餐到期时间 |
|
||||
| #55 套餐分配生效条件 | 套餐和分配接口已支持默认值、覆盖值和最终生效值 | 创建/编辑套餐传 `expiry_base`;分配时传 `expiry_base_override`,展示 `effective_expiry_base` |
|
||||
| #60 店铺联系电话搜索 | 店铺列表已支持 11 位联系电话精确查询 | 将输入值作为 `contact_phone` 查询参数传给店铺列表接口 |
|
||||
| #86 资产换货标识和跳转 | 资产解析接口已返回 `exchange_trace.previous_asset/next_asset` 和 `can_view` | 仅 `can_view=true` 且存在资产 ID 时允许跳转;该需求前端已对接可保持现状 |
|
||||
| #38 代理信用额度 | 角色默认额度、店铺实际额度、资金概况和负可用余额均已有接口 | 使用分单位字段;更新时携带钱包 `version`;余额为负数时正常展示 |
|
||||
| #94 状态同步和运营商回调 | 后端回调、定时触发和原轮询链路已装配 | 通常无前端新调用;状态页面继续读取现有资产状态字段 |
|
||||
| #96 店铺业务员 | 店铺创建/更新、候选人、列表/详情和筛选都已支持业务员 | 创建/编辑店铺选择 `business_owner_account_id`;列表可按该 ID 筛选并展示名称 |
|
||||
| #98 换货新资产继承旧店铺 | 换货完成时后端自动继承旧资产店铺归属 | 前端继续调用原换货完成接口,不新增分配步骤 |
|
||||
| #43 系列套餐批量授权 | 创建授权和管理套餐接口均支持 `packages[]`,单次 1~100 项 | 页面实现套餐多选,一次提交整个数组;删除项使用 `remove=true` |
|
||||
|
||||
> 注意:#44 的“提交人”已经可以直接对接;“审批人”目前不是退款、充值列表的稳定返回字段。后端已经能从企微详情快照解析并映射 userid,但当前前端不能把 `processor_id` 当作完整企微审批人列表。
|
||||
|
||||
### 2.3 本期明确不由后端处理
|
||||
|
||||
- #84 H5 首页隐藏设备下 ICCID:纯前端显示调整,但本期范围确认标记为“不做”。
|
||||
- #63 授权列表滚动条和字段顺序:纯前端页面调整,需求已关闭。
|
||||
- #73 行业卡未实名复机:后端保持原有行业卡放行逻辑,不改接口、不改前端。
|
||||
- #99 原路退款、#52 聚水潭、#51 跨品类换货、#39 分销码/佣金提现:本期不做。
|
||||
- #168、#90、#75、#64:已关闭,本期不重新修改。
|
||||
|
||||
## 三、需求与接口对接表
|
||||
|
||||
### 3.1 资产、店铺、订单和换货
|
||||
|
||||
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
|
||||
| --- | --- | --- |
|
||||
| #41 登录限制 | `PUT /api/admin/shops/:id`;`GET /api/admin/shops`;`GET /api/admin/shops/:id` | 更新请求增加可选 `client_login_disabled`;列表/详情返回同名布尔值。C 端仍调 `POST /api/c/v1/auth/verify-asset`,受限时直接展示后端错误,不会返回资产令牌 |
|
||||
| #53 实名筛选 | `GET /api/admin/iot-cards/standalone`;`GET /api/admin/devices` | 查询参数增加 `real_name_status=0|1`;响应已有 `real_name_status`、`real_name_status_name` |
|
||||
| #62 实名顺序 | `PATCH /api/admin/assets/:identifier/realname-mode`;`POST /api/admin/iot-cards/batch-update-realname-policy`;`POST /api/admin/devices/batch-update-realname-policy` | 单条传 `realname_policy`;批量传 `asset_ids[] + realname_policy`,最多 500 条 |
|
||||
| #62/#48 C 端初始化 | `GET /api/c/v1/asset/info?identifier=...` | 使用 `effective_realname_policy`、`realname_required`、`real_name_status`、`allowed_payment_methods`;前端不要自行覆盖 |
|
||||
| #45 换货搜索 | `GET /api/admin/exchanges` | 使用 `old_asset_keyword`、`new_asset_keyword` 两个独立参数,可同时传并按 AND 组合 |
|
||||
| #57/#98 换货 | `POST /api/admin/exchanges`;`POST /api/admin/exchanges/:id/complete` | 请求结构不变;存在退款时创建接口返回业务错误;完成后店铺归属由后端继承 |
|
||||
| #188 换货提醒 | `GET /api/c/v1/notifications/unread-count`;`GET /api/c/v1/notifications`;`PUT /api/c/v1/notifications/:id/read` | 无新增通知接口;前端继续使用现有未读数、列表和已读接口弹窗展示 |
|
||||
| #181 订单字段 | `GET /api/admin/orders`;`GET /api/admin/orders/:id`;`GET /api/c/v1/orders`;`GET /api/c/v1/orders/:id` | 返回正确 `purchase_role`、`asset_identifier`;设备标识为 VirtualNo 优先、IMEI 兜底 |
|
||||
| #40 下架套餐续费 | `GET /api/c/v1/asset/info`;`GET /api/c/v1/orders`;`GET /api/c/v1/orders/:id`;`POST /api/c/v1/orders/create` | 资产信息返回 `current_package_id`,历史订单返回 `package_ids`;前端把选中的套餐 ID、资产 `identifier` 和当前允许的 `payment_method` 传给原创建订单接口 |
|
||||
| #46/#86 资产展示 | `GET /api/admin/assets/resolve/:identifier`;`GET /api/c/v1/asset/info` | 返回预计最终到期字段;后台解析额外返回 `exchange_trace`,跳转前检查 `can_view` |
|
||||
|
||||
### 3.2 店铺业务员、信用和套餐授权
|
||||
|
||||
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
|
||||
| --- | --- | --- |
|
||||
| #60 联系电话搜索 | `GET /api/admin/shops?contact_phone=11位号码` | 精确查询;可与店铺名称、编号等条件组合 |
|
||||
| #96 店铺业务员 | `GET /api/admin/shops/business-owner-candidates`;`POST /api/admin/shops`;`PUT /api/admin/shops/:id`;`GET /api/admin/shops` | 创建/更新传 `business_owner_account_id`;列表可用同名参数筛选,响应展示账号 ID、名称和可用状态 |
|
||||
| #38 信用额度 | `PUT /api/admin/roles/:id/default-credit`;`PUT /api/admin/shops/:id/credit-limit`;`GET /api/admin/shops/fund-summary` | 角色接口配置新建代理默认值;店铺接口传 `credit_enabled + credit_limit + version`;金额单位均为分 |
|
||||
| #55 套餐默认生效条件 | `POST /api/admin/packages`;`PUT /api/admin/packages/:id`;`GET /api/admin/packages/:id` | 请求使用 `expiry_base=from_activation|from_purchase`;响应展示默认生效条件名称 |
|
||||
| #55 分配覆盖 | `POST /api/admin/shop-package-batch-allocations`;`PATCH /api/admin/shop-package-allocations/:id/expiry-base` | 分配时使用 `expiry_base_override`;`null` 表示跟随套餐默认值 |
|
||||
| #43 系列套餐多选 | `POST /api/admin/shop-series-grants`;`PUT /api/admin/shop-series-grants/:id/packages` | `packages` 是 1~100 项数组,每项包含 `package_id`、`cost_price`,删除时传 `remove=true` |
|
||||
|
||||
#### 3.2.1 #43 套餐价格展示和已授权/未授权区分
|
||||
|
||||
#43 不需要新增后端接口,前端按下面两个现有接口组合数据:
|
||||
|
||||
1. 调用 `GET /api/admin/packages?series_id={series_id}&page=1&page_size=100` 分页取得该系列全部套餐。列表项直接使用:
|
||||
- `suggested_retail_price`:建议售价,单位分;未配置时为空。
|
||||
- `cost_price`:公司成本价,单位分。
|
||||
2. 编辑已有授权时,调用 `GET /api/admin/shop-series-grants/{grant_id}`,注意路径参数是授权记录 ID,不是系列 ID。
|
||||
3. 将授权详情的 `packages[].package_id` 组成已授权套餐 ID 集合。
|
||||
4. 套餐列表项的 `id` 在该集合中显示“已授权”,否则显示“未授权”。授权详情 `packages[].cost_price` 是该次店铺授权成本价,不要拿它替代套餐列表的公司成本价。
|
||||
5. 用户提交多选结果时,创建授权调用 `POST /api/admin/shop-series-grants`;编辑授权调用 `PUT /api/admin/shop-series-grants/{grant_id}/packages`。新增/调价项传 `package_id + cost_price`,删除项传 `package_id + remove=true`。
|
||||
|
||||
套餐接口是分页接口;一个系列超过 100 个套餐时,前端必须继续请求后续页,再完成已授权集合标记。
|
||||
|
||||
### 3.3 审批、退款和员工线下代充值
|
||||
|
||||
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
|
||||
| --- | --- | --- |
|
||||
| #181/#182/#44 退款 | `GET /api/admin/refunds`;`GET /api/admin/refunds/:id` | 新增/补齐 `asset_identifier`、`submitter_id`、`submitter_name`、`approval_provider`、`approval_status`、`approval_status_name`;资产标识是退款创建时固化的快照,卡为 ICCID,设备按 VirtualNo 优先、IMEI 兜底;历史空快照不做兼容 |
|
||||
| #182/#44 充值 | `GET /api/admin/agent-recharges`;`GET /api/admin/agent-recharges/:id` | 同上;线下代充值的审批状态只读展示 |
|
||||
| #44 换货 | `GET /api/admin/exchanges`;`GET /api/admin/exchanges/:id` | 新增/补齐 `submitter_id`、`submitter_name` |
|
||||
| #34 员工线下代充 | `POST /api/admin/agent-recharges` | 仍用原入口;`payment_method=offline` 时传目标 `shop_id`、金额、1~5 个 `payment_voucher_key` 和备注,创建后等待企微审批 |
|
||||
| #35 退款申请 | `POST /api/admin/refunds` | 仍用原入口;请求结构保持订单、实收金额、申请金额、凭证、原因等业务字段,创建后等待企微审批 |
|
||||
| #34/#35 旧按钮 | `POST /api/admin/agent-recharges/:id/offline-pay`;`POST /api/admin/refunds/:id/approve|reject` | 仅兼容存量旧审批记录;企微记录前端不得显示这些操作按钮,最终按发布配置停用 |
|
||||
| #37 企微应用 | `POST/GET /api/admin/wecom/applications`;`POST /api/admin/wecom/applications/:id/test` | 配置 corp_id、agent_id、Secret、回调 Token、EncodingAESKey;管理端按明文填写 |
|
||||
| #37 默认发起人和成员 | `POST /api/admin/wecom/applications/:id/members/sync`;`GET /api/admin/wecom/applications/:id/members`;`PUT /api/admin/wecom/applications/:id/default-creator` | 先同步成员,再从可见成员中选择默认 `userid` |
|
||||
| #37 账号绑定 | `PUT /api/admin/accounts/:id/wecom-binding` | 管理员选择应用可见成员,绑定 `(corp_id,userid)`;不做扫码绑定 |
|
||||
| #37 场景模板 | `PUT /api/admin/wecom/scenes/:business_type`;`GET /api/admin/wecom/scenes` | `business_type` 为 `refund_approval` 或 `offline_recharge_approval`;传 `application_id`、`template_id`、`control_mapping[]` |
|
||||
| #37 回调 | `GET/POST /api/callback/wecom/approval/:application_id` | 由企微服务器调用,前端无需调用 |
|
||||
|
||||
#### 3.3.1 企业微信审批 8 个接口的前端调用流程
|
||||
|
||||
企业微信配置页使用 8 个 `/api/admin/wecom` 接口;账号绑定属于账号模块,因此完整闭环是“8 个企微配置接口 + 1 个账号绑定接口”。应用凭据和场景写操作应只向超级管理员开放;前端收到 403 时不要降级绕过。
|
||||
|
||||
| 顺序 | 接口 | 页面动作与调用说明 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `POST /api/admin/wecom/applications` | 保存应用。传 `corp_id`、`agent_id`、`name`、`secret`、`callback_token`、43 位 `encoding_aes_key`、`status=1`。相同 `corp_id + agent_id` 再次提交表示更新;保存响应中的 `id` 是后续 `application_id` |
|
||||
| 2 | `GET /api/admin/wecom/applications?page=1&page_size=20` | 进入配置页或保存后刷新应用列表,展示连接时间、启用状态、默认发起人和凭据是否完整 |
|
||||
| 3 | `POST /api/admin/wecom/applications/{id}/test` | 用户点击“测试连接”时调用;`data.success=true` 只表示成功取得 access_token,不表示通讯录、模板和回调均已配置完成 |
|
||||
| 4 | `POST /api/admin/wecom/applications/{id}/members/sync` | 连接成功后点击“同步成员”;后端拉取该自建应用可见范围,返回 `synced_count` 和 `synced_at` |
|
||||
| 5 | `GET /api/admin/wecom/applications/{id}/members?page=1&page_size=20&keyword=...` | 查询最近同步的本地成员快照,`keyword` 可按姓名或 userid 搜索;用于默认发起人和账号绑定的选择器,不要允许手输一个未同步 userid |
|
||||
| 6 | `PUT /api/admin/wecom/applications/{id}/default-creator` | 从成员选择器取 `userid`,请求体为 `{"userid":"zhangsan"}`。代理等非企微账号提交业务时,企微审批由该成员代为发起,但本地业务提交人仍保持真实账号 |
|
||||
| 7 | `PUT /api/admin/wecom/scenes/{business_type}` | 分别保存退款和线下代充值模板映射;后端会实时读取企微模板详情并校验控件 ID、类型、必填控件和选择项 key,校验失败时页面应保留用户输入并展示后端错误 |
|
||||
| 8 | `GET /api/admin/wecom/scenes?page=1&page_size=20` | 进入场景页或保存后刷新,展示 `business_type_name`、模板名称、状态、最近校验时间和控件映射 |
|
||||
|
||||
应用保存请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"agent_id": 1000002,
|
||||
"name": "测试环境审批应用",
|
||||
"secret": "企微应用Secret",
|
||||
"callback_token": "企微后台配置的回调Token",
|
||||
"encoding_aes_key": "43位EncodingAESKey",
|
||||
"status": 1
|
||||
}
|
||||
```
|
||||
|
||||
场景路径只支持:
|
||||
|
||||
- `refund_approval`:退款审批。
|
||||
- `offline_recharge_approval`:员工线下代充值审批。
|
||||
|
||||
场景保存请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"application_id": 1,
|
||||
"template_id": "企微模板ID",
|
||||
"control_mapping": [
|
||||
{
|
||||
"business_field": "refund_no",
|
||||
"control_id": "Text-xxxxxxxx",
|
||||
"control_type": "Text",
|
||||
"option_mapping": {}
|
||||
}
|
||||
],
|
||||
"status": 1
|
||||
}
|
||||
```
|
||||
|
||||
`control_mapping` 的 `control_id`、`control_type` 和选择项 key 必须来自企微后台已经创建的模板。退款可映射业务字段为 `refund_no`、`order_id`、`order_no`、`asset_identifier`、`asset_type`、`actual_received_amount`、`requested_refund_amount`、`refund_voucher_key`、`refund_reason`、`package_usage_id`、`submitter_id`、`submitter_name`;线下代充值可映射 `recharge_no`、`shop_id`、`shop_name`、`amount`、`amount_cent`、`payment_voucher_key`、`remark`、`submitter_id`、`submitter_name`。企微模板中的必填控件必须全部映射。
|
||||
|
||||
完成成员同步后,账号管理页还要调用:
|
||||
|
||||
```http
|
||||
PUT /api/admin/accounts/{account_id}/wecom-binding
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"application_id": 1,
|
||||
"userid": "zhangsan"
|
||||
}
|
||||
```
|
||||
|
||||
前端完整配置顺序为:保存应用 → 测试连接 → 同步成员 → 查询成员 → 设置默认发起人 → 给需要本人发起审批的系统账号绑定成员 → 保存两个业务场景 → 查询场景确认均已启用。应用可见范围变化后,应重新同步成员并检查默认发起人和账号绑定。
|
||||
|
||||
#### 3.3.2 配置完成后的业务审批流程
|
||||
|
||||
1. 前端继续调用原业务接口创建退款或员工线下代充值,不直接调用企微发起审批接口。
|
||||
2. 后端保存业务单、通用审批实例和提交事件,并异步向企微发起审批;前端根据业务列表/详情的 `approval_provider`、`approval_status`、`approval_status_name` 只读展示进度。
|
||||
3. `approval_status` 可能为:`0` 提交中、`1` 审批中、`2` 已通过、`3` 已拒绝、`4` 已撤销、`5` 通过后撤销、`6` 已删除、`7` 提交失败、`8` 提交结果未知。页面名称优先直接使用 `approval_status_name`。
|
||||
4. 企微回调和 Worker 轮询共同同步最终状态;`GET/POST /api/callback/wecom/approval/{application_id}` 只由企微服务器调用,前端禁止调用。
|
||||
5. `approval_provider=wecom` 或存在 `approval_instance_id` 时,前端不得显示退款通过/驳回、线下充值确认等旧人工按钮。企微通过后,退款终结或钱包入账由后端自动幂等执行。
|
||||
|
||||
### 3.4 通知、批量任务、导出和 Gateway
|
||||
|
||||
| 需求 | 接口 | 参数或返回变化 / 前端调用说明 |
|
||||
| --- | --- | --- |
|
||||
| #97 后台低余额通知 | `GET /api/admin/notifications/unread-count`;`GET /api/admin/notifications`;`PUT /api/admin/notifications/:id/read` | 复用现有后台通知接口;业务员账号正常展示低余额通知 |
|
||||
| #33 临期列表 | `GET /api/admin/expiring-assets` | 支持资产类型、关键词、店铺、套餐、剩余天数和日期范围;响应含 `summary`、`expiry_level`、`is_priority` |
|
||||
| #33 C 端提醒 | `GET /api/c/v1/notifications/unread-count`;`GET /api/c/v1/notifications` | 复用现有站内通知,前端在 15/7/3 天节点按未读通知弹窗 |
|
||||
| #36 批量订购上传 | `POST /api/admin/storage/upload-url` | `purpose=batch_purchase`,上传 UTF-8 单列 CSV 后取得 `file_key` |
|
||||
| #36 批量订购任务 | `POST /api/admin/asset-package-batch-orders`;`GET /api/admin/asset-package-batch-orders`;`GET /api/admin/asset-package-batch-orders/:id` | 创建传 `file_key + package_id + payment_method`,线下支付另传 `voucher_keys[]`;详情返回逐行结果 |
|
||||
| #42 业务导出 | `POST /api/admin/export-tasks`;`GET /api/admin/export-tasks`;`GET /api/admin/export-tasks/:id` | `scene` 使用 `iot_card/package/agent_wallet_transaction/agent_recharge/refund/exchange`,筛选条件放 `query` |
|
||||
| #47 卡限速 | `PUT /api/admin/iot-cards/:iccid/speed-tier` | 请求只传 `code=-1..8`;设备页面不要展示限速入口 |
|
||||
| #48 后台支付配置 | `GET /api/admin/system-configs`;`PUT /api/admin/system-configs/:key` | Key 为 `c2b.payment.card_allowed_methods`、`c2b.payment.device_allowed_methods`;更新请求的 `value` 是 JSON 数组字符串 |
|
||||
| #48 C 端支付 | `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` | 展示和提交都使用后端返回的 `allowed_payment_methods`;创建订单时 `payment_method` 必传,微信场景按接口要求传 `app_type` |
|
||||
| #49 分配 CSV 上传 | `POST /api/admin/storage/upload-url` | `purpose=device_batch_allocation`,上传单列 CSV 后取得 `file_key` |
|
||||
| #49 创建设备分配任务 | `POST /api/admin/devices/import/allocations` | 传 `file_key + operation_type + target_id`;`operation_type=assign_shop|assign_series` |
|
||||
| #49 查询任务 | `GET /api/admin/devices/import/tasks`;`GET /api/admin/devices/import/tasks/:id` | 复用原设备导入任务页面,新增展示 `operation_type`、`operation_name`、`target_id`、`status_name` |
|
||||
|
||||
### 3.5 前端静态 CSV 模板
|
||||
|
||||
本期需要前端提供两份静态文件模板,**都是 CSV,不接受 Excel(`.xls`/`.xlsx`)**。文件使用 UTF-8 编码,允许 UTF-8 BOM,最大 10MB,最多 1000 行数据(不含表头)。每个文件只允许一列,不要添加空行、说明行或示例外的其他列。
|
||||
|
||||
#### 模板一:批量订购套餐
|
||||
|
||||
- 建议文件名:`批量订购套餐模板.csv`
|
||||
- 上传用途:`purpose=batch_purchase`
|
||||
- 套餐、支付方式和线下凭证由页面另外选择,不放在 CSV 中。
|
||||
|
||||
| 列序号 | 固定表头 | 必填 | 填写内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `资产标识` | 是 | 每行一个资产标识。卡支持 ICCID、VirtualNo 或 MSISDN;设备支持 VirtualNo、IMEI 或 SN |
|
||||
|
||||
```csv
|
||||
资产标识
|
||||
89860012345678901234
|
||||
CARD-VIRTUAL-0001
|
||||
DEVICE-VIRTUAL-0001
|
||||
860123456789012
|
||||
```
|
||||
|
||||
#### 模板二:设备批量分配
|
||||
|
||||
- 建议文件名:`设备批量分配模板.csv`
|
||||
- 上传用途:`purpose=device_batch_allocation`
|
||||
- 目标代理或套餐系列由页面另外选择,不放在 CSV 中。“分配代理”和“设置套餐系列”可以共用这一份模板。
|
||||
|
||||
| 列序号 | 固定表头 | 必填 | 填写内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `设备标识` | 是 | 每行一个设备标识,支持 VirtualNo、IMEI 或 SN |
|
||||
|
||||
```csv
|
||||
设备标识
|
||||
DEVICE-VIRTUAL-0001
|
||||
860123456789012
|
||||
SN202607250001
|
||||
```
|
||||
|
||||
> 前端下载的静态模板可以只保留表头,上述数据行仅用于说明格式。资产标识必须按文本原样保存,不得转换为科学计数法、浮点数或截断前导零。
|
||||
|
||||
## 四、前端本期最容易漏掉的工作
|
||||
|
||||
- 换货列表拆成新、旧资产两个搜索参数。
|
||||
- 资产预计到期展示使用“预计最终到期”,并按临期字段高亮。
|
||||
- 套餐分配页面传递默认/覆盖生效条件。
|
||||
- 店铺页面接入联系电话查询、业务员选择和 C 端登录限制开关。
|
||||
- 系列套餐授权页面真正使用现有 `packages[]` 做多选提交。
|
||||
- C 端实名流程只读取 `effective_realname_policy`。
|
||||
- C 端支付按钮只读取 `allowed_payment_methods`。
|
||||
- 历史订单续费继续调用原创建订单接口,不等待新续费接口。
|
||||
- 企微退款和线下代充值只读展示审批状态,隐藏本地人工审批按钮。
|
||||
- 卡页面提供固定档位限速;设备页面不得出现限速入口。
|
||||
- 设备批量分配继续复用原设备导入任务列表/详情页面,但根据 `operation_type` 改标题和结果说明。
|
||||
|
||||
## 五、联调和验收边界
|
||||
|
||||
- 当前有两个需要在联调时明确的接口边界:
|
||||
- #44:退款/充值列表已经返回提交人和审批状态,但没有直接返回企微审批节点人员列表;现有 userid 映射能力尚未接入这两个业务 DTO。
|
||||
- #49:设备批量分配创建服务允许平台和代理账号,但复用的设备导入任务列表/详情 Handler 目前仍沿用“仅平台用户可查看”的旧限制。若本期只允许平台操作则前端应隐藏代理入口;若要求代理自行查看任务,需要再统一后端权限。
|
||||
- OpenAPI 已生成,新增路径和 DTO 已检查;设备限速旧契约不存在。
|
||||
- 本次没有执行数据库迁移、完整构建、自动化测试、LSP 或真实企微/Gateway 闭环。
|
||||
- 企微、Gateway、Redis/Asynq、对象存储配置和回滚步骤见 [`七月迭代联调交付说明.md`](七月迭代联调交付说明.md)。
|
||||
- 涉及金额的字段默认单位为分;前端展示时统一转换,提交时不要传浮点元金额。
|
||||
54
docs/产品迭代7月份/个人客户站内通知.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 个人客户站内通知
|
||||
|
||||
为个人客户增加通知入口,支持查询通知、查看未读数以及标记已读。进入首页时自动查询未读通知并弹窗展示;多条通知使用左右滑动查看,弹窗打开时第一条自动标记已读,滑动查看其他通知时立即标记对应通知已读。弹窗支持关闭。所有接口均需要登录,使用 `Bearer Token` 鉴权。
|
||||
|
||||
## 查询通知列表
|
||||
|
||||
`GET /api/c/v1/notifications`
|
||||
|
||||
查询当前客户可见的未过期业务通知,按创建时间和通知 ID 倒序排列。
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `page` | 否 | 页码,默认 1,范围 1~10000 |
|
||||
| `page_size` | 否 | 每页数量,默认 20,范围 1~50 |
|
||||
| `is_read` | 否 | 已读状态;`false` 仅查询未读,`true` 仅查询已读,不传时查询全部 |
|
||||
|
||||
返回:包含通知列表 `items`、当前页 `page`、每页数量 `size` 和总数 `total`。通知项包含标题、正文、类型、类别、级别、关联资源、已读状态及创建/已读时间。
|
||||
|
||||
## 标记单条通知已读
|
||||
|
||||
`PUT /api/c/v1/notifications/{id}/read`
|
||||
|
||||
将当前客户可见的指定通知标记为已读。通知不存在、属于其他客户或已经已读时,也返回成功,接口幂等。
|
||||
|
||||
返回:`{ "success": true }`
|
||||
|
||||
## 全部标记已读
|
||||
|
||||
`PUT /api/c/v1/notifications/read-all`
|
||||
|
||||
将当前客户可见的未过期未读业务通知全部标记为已读,重复调用幂等。
|
||||
|
||||
返回:`{ "updated_count": 3 }`
|
||||
|
||||
## 查询未读数
|
||||
|
||||
`GET /api/c/v1/notifications/unread-count`
|
||||
|
||||
查询当前客户未过期业务通知的未读数量;平台同步和系统运维通知不计入结果。
|
||||
|
||||
返回:`{ "count": 3, "display_count": "3" }`,数量超过 99 时 `display_count` 为 `99+`。
|
||||
|
||||
## 首页未读通知弹窗
|
||||
|
||||
- 用户进入首页后,调用 `GET /api/c/v1/notifications`,传入 `is_read=false`、`page=1`、`page_size=50`,只展示当前未读通知。
|
||||
- 有多条未读通知时默认展示第一条,弹窗打开后立即调用单条已读接口标记第一条。
|
||||
- 用户左右滑动切换到其他通知时,视为已查看并立即调用单条已读接口标记当前通知。
|
||||
- 点击关闭图标只关闭弹窗,不会将未查看的其他通知批量标记为已读;下次进入首页仍可继续展示剩余未读通知。
|
||||
- 通知的 `ref_type`、`ref_id`、`ref_key` 仅用于资源定位或展示,不直接拼接页面路由。
|
||||
|
||||
## 通用约定
|
||||
|
||||
- 所有接口均使用 `Bearer Token` 鉴权。
|
||||
- 未登录、参数错误、无权访问和服务异常沿用统一错误响应。
|
||||
277
docs/所需接口文档/new-api.md
Normal 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,长度 1~50。
|
||||
- 金额单位:分。页面展示时转换为元,提交时仍传整数分。
|
||||
- 成功响应:`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 | 是 | 资产标识符,1~50 个字符 |
|
||||
|
||||
### `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 | 是否临期(精确推算且剩余 0~15 天) |
|
||||
| `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 | 是 | 充值金额,1~10000000 分 |
|
||||
| `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 | 是 | 每页数量,1~100 |
|
||||
|
||||
### `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. 页面展示支付链接或支付参数后,支付结果通过订单详情等查询接口确认;不要仅根据前端跳转结果判断已支付。
|
||||
19
openspec/changes/add-personal-notifications/proposal.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Change: Add personal customer notifications
|
||||
|
||||
## Why
|
||||
|
||||
个人客户缺少统一的站内通知入口,无法查看业务通知、获取未读数量或管理通知已读状态。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 增加个人客户通知列表查询接口,支持分页和固定倒序排列。
|
||||
- 增加单条通知已读和全部通知已读接口。
|
||||
- 增加个人客户通知未读数查询接口。
|
||||
- 限制通知数据仅返回当前认证客户可见且未过期的业务通知。
|
||||
- 统一返回通知内容、关联资源、通知级别、已读状态及时间信息。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `personal-notifications`
|
||||
- Affected code: 个人客户通知 API、通知列表入口、未读数展示和已读状态处理
|
||||
- No breaking changes to existing APIs
|
||||
@@ -0,0 +1,56 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Personal customers SHALL be able to query notifications
|
||||
|
||||
The system SHALL provide `GET /api/c/v1/notifications` for authenticated personal customers. The endpoint SHALL return only visible, unexpired business notifications for the current customer, ordered by creation time descending and notification ID descending.
|
||||
|
||||
#### Scenario: Query the first notification page
|
||||
- **WHEN** an authenticated customer requests the notification list without pagination parameters
|
||||
- **THEN** the system SHALL return page 1 with up to 20 notifications
|
||||
- **AND** the response SHALL include `items`, `page`, `size`, and `total`
|
||||
|
||||
#### Scenario: Apply valid pagination
|
||||
- **WHEN** the request includes `page` from 1 to 10000 and `page_size` from 1 to 50
|
||||
- **THEN** the system SHALL return the requested page and page size
|
||||
|
||||
#### Scenario: Reject invalid pagination
|
||||
- **WHEN** `page` or `page_size` is outside its allowed range
|
||||
- **THEN** the system SHALL return a parameter validation error
|
||||
|
||||
### Requirement: Notification items SHALL expose client-readable status and metadata
|
||||
|
||||
Each notification item SHALL include `id`, `title`, `body`, `type`, `category`, `severity`, `ref_type`, `ref_id`, `ref_key`, `is_read`, `created_at`, and nullable `read_at`.
|
||||
|
||||
#### Scenario: Return an unread notification
|
||||
- **WHEN** a visible notification has not been read by the current customer
|
||||
- **THEN** the system SHALL return `is_read: false` and `read_at: null`
|
||||
|
||||
### Requirement: Personal customers SHALL be able to mark one notification as read
|
||||
|
||||
The system SHALL provide `PUT /api/c/v1/notifications/{id}/read` for authenticated personal customers. The operation SHALL be idempotent and SHALL only update a notification visible to the current customer.
|
||||
|
||||
#### Scenario: Mark a visible unread notification
|
||||
- **WHEN** the customer marks a visible unread notification as read
|
||||
- **THEN** the system SHALL set its read state and return `{ "success": true }`
|
||||
|
||||
#### Scenario: Mark an unavailable or already-read notification
|
||||
- **WHEN** the notification does not exist, belongs to another customer, or is already read
|
||||
- **THEN** the system SHALL return `{ "success": true }` without exposing ownership information
|
||||
|
||||
### Requirement: Personal customers SHALL be able to mark all notifications as read
|
||||
|
||||
The system SHALL provide `PUT /api/c/v1/notifications/read-all` to mark all visible, unexpired, unread business notifications for the current customer as read.
|
||||
|
||||
#### Scenario: Mark all unread notifications
|
||||
- **WHEN** the customer calls the mark-all-read endpoint
|
||||
- **THEN** the system SHALL return the number of notifications actually updated in `updated_count`
|
||||
- **AND** repeated calls SHALL remain successful and return zero when nothing needs updating
|
||||
|
||||
### Requirement: Personal customers SHALL be able to query unread count
|
||||
|
||||
The system SHALL provide `GET /api/c/v1/notifications/unread-count`. The count SHALL include only visible, unexpired business notifications and SHALL exclude platform sync and system operations notifications.
|
||||
|
||||
#### Scenario: Return unread count and badge text
|
||||
- **WHEN** an authenticated customer queries the unread count
|
||||
- **THEN** the system SHALL return numeric `count` and string `display_count`
|
||||
- **AND** `display_count` SHALL be `99+` when `count` exceeds 99
|
||||
26
openspec/changes/add-personal-notifications/tasks.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## 1. API and Data Contract
|
||||
|
||||
- [x] 1.1 Define the client-side personal notification item and paginated list contract
|
||||
- [x] 1.2 Define the client-side unread count and read-operation contracts
|
||||
- [x] 1.3 Add authenticated notification API wrappers
|
||||
|
||||
## 2. Notification Behavior
|
||||
|
||||
- [ ] 2.1 Return only current customer's visible, unexpired business notifications
|
||||
- [ ] 2.2 Apply creation-time and notification-ID descending ordering
|
||||
- [ ] 2.3 Support idempotent single-read and read-all operations
|
||||
- [ ] 2.4 Exclude platform sync and system operations notifications from unread count
|
||||
|
||||
## 3. Client Entry
|
||||
|
||||
- [x] 3.1 Add the personal notification list entry and pagination handling
|
||||
- [x] 3.2 Add unread-count display and refresh behavior
|
||||
- [x] 3.3 Mark notifications read from list actions and support mark-all-read
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [ ] 4.1 Test authentication, pagination limits, ordering, and visibility isolation
|
||||
- [ ] 4.2 Test idempotent read behavior and unread-count updates
|
||||
- [x] 4.3 Verify the H5 build and unified success/error response handling
|
||||
|
||||
> Note: The repository contains the H5 client only. Tasks 2.1-2.4 and 4.1-4.2 require implementation and verification in the backend service repository.
|
||||
63
openspec/changes/update-july-h5-c-iteration/design.md
Normal 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`,当前登录尝试不得继续获取或覆盖资产 Token;Token 的签发和吊销仍由服务端负责。
|
||||
|
||||
### 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 天触发和 0~3 天的优先级由后端通知数据表达,前端只负责按等级排序/展示和更新未读数。换货通知沿用同一套入口,不新增营销、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.
|
||||
41
openspec/changes/update-july-h5-c-iteration/proposal.md
Normal 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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
## 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** the effective real-name policy is `after_order`
|
||||
- **AND** `/api/c/v1/device/cards` returns no card with completed real-name authentication
|
||||
- **AND** package history contains neither a pending package (`status = 0`) nor an active package (`status = 1`)
|
||||
- **THEN** selecting a real-name entry on the homepage or operator-switch page SHALL block direct real-name authentication
|
||||
- **AND** the client SHALL show a confirmation prompt that can navigate to package ordering
|
||||
|
||||
#### Scenario: Post-order real-name requirement is satisfied
|
||||
|
||||
- **WHEN** the effective real-name policy is `after_order`
|
||||
- **AND** `/api/c/v1/device/cards` returns at least one authenticated card, or package history contains a pending or active package
|
||||
- **THEN** the homepage and operator-switch real-name entries SHALL retain the existing real-name authentication flow
|
||||
|
||||
#### 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,66 @@
|
||||
## 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
|
||||
|
||||
### Requirement: Multi-notification popup SHALL communicate horizontal navigation
|
||||
|
||||
When the homepage notification popup contains more than one notification, the client SHALL make the horizontal swipe interaction visible to the user and SHALL retain the current-position indicator.
|
||||
|
||||
#### Scenario: User opens multiple unread notifications
|
||||
|
||||
- **WHEN** the homepage popup contains two or more notifications
|
||||
- **THEN** the popup SHALL display an explicit `左右滑动切换通知` hint
|
||||
- **AND** the user SHALL be able to move between notifications by swiping horizontally
|
||||
- **AND** the popup SHALL display the current notification position
|
||||
@@ -0,0 +1,59 @@
|
||||
## 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 standard order/payment flow without navigating to the package catalog
|
||||
|
||||
#### 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 order-list renewal entry is used
|
||||
- **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
|
||||
|
||||
### Requirement: Renewal buttons SHALL be limited to the homepage and order list
|
||||
|
||||
The client SHALL show an `立即续费` button in the homepage asset summary and in every order card in the order list. The client SHALL NOT show a renewal button in the order detail page, package catalog, package-order page, or other pages.
|
||||
|
||||
#### Scenario: Customer renews from the homepage
|
||||
|
||||
- **WHEN** the homepage asset summary is displayed
|
||||
- **THEN** the client SHALL show an `立即续费` button
|
||||
- **AND** tapping it SHALL directly start the standard order/payment flow for the asset's current package ID when one is available
|
||||
|
||||
#### Scenario: Customer renews from an order card
|
||||
|
||||
- **WHEN** an order is displayed in the order list
|
||||
- **THEN** the client SHALL show an `立即续费` button for that order
|
||||
- **AND** tapping it SHALL directly start the standard order/payment flow for that order's package IDs without navigating to the package-order page
|
||||
|
||||
#### Scenario: Customer views another page
|
||||
|
||||
- **WHEN** the customer views any page other than the homepage or order list
|
||||
- **THEN** the client SHALL NOT show an `立即续费` button
|
||||
@@ -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
|
||||
51
openspec/changes/update-july-h5-c-iteration/tasks.md
Normal file
@@ -0,0 +1,51 @@
|
||||
## 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
|
||||
- [x] 2.5 Gate homepage and operator-switch real-name entries for `after_order` using device card real-name states and pending/active package history
|
||||
|
||||
## 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
|
||||
- [x] 3.5 Show `立即续费` only on the homepage asset summary and every order-list card, with direct order/payment 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.
|
||||
@@ -44,6 +44,12 @@
|
||||
"navigationBarTitleText": "我的订单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notifications/notifications",
|
||||
"style": {
|
||||
"navigationBarTitleText": "站内通知"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/bind/bind",
|
||||
"style": {
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 28rpx 24rpx 48rpx;
|
||||
background: linear-gradient(180deg, #eaf4ff 0%, var(--bg-secondary) 42%);
|
||||
background: linear-gradient(180deg, #eaf6ec 0%, var(--bg-secondary) 42%);
|
||||
}
|
||||
|
||||
.pay-content {
|
||||
@@ -108,8 +108,8 @@
|
||||
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);
|
||||
box-shadow: 0 16rpx 40rpx rgba(85, 171, 92, 0.08);
|
||||
border: 1rpx solid rgba(85, 171, 92, 0.08);
|
||||
}
|
||||
|
||||
.qr-desc {
|
||||
@@ -144,7 +144,7 @@
|
||||
.browser-tip {
|
||||
padding: 22rpx 24rpx;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(10, 132, 255, 0.08);
|
||||
background: rgba(85, 171, 92, 0.08);
|
||||
color: var(--primary);
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
@@ -167,7 +167,7 @@
|
||||
padding: 28rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
border: 1rpx dashed rgba(10, 132, 255, 0.24);
|
||||
border: 1rpx dashed rgba(85, 171, 92, 0.24);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<!-- 空状态提示 -->
|
||||
<view v-if="packageList.length === 0 && !loading" class="empty-state">
|
||||
<view class="empty-icon">📦</view>
|
||||
<view v-if="!historyLoaded || (loading && packageList.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="packageList.length === 0" class="empty-state">
|
||||
<image class="empty-icon" src="/static/asset-package-history.png" mode="aspectFit" alt="资产套餐历史"></image>
|
||||
<view class="empty-title">暂无套餐记录</view>
|
||||
<view class="empty-desc">当前账号下暂无套餐历史信息</view>
|
||||
</view>
|
||||
@@ -69,6 +72,7 @@
|
||||
|
||||
let packageList = reactive([]);
|
||||
let loading = ref(false);
|
||||
let historyLoaded = ref(false);
|
||||
let noMore = ref(false);
|
||||
let page = ref(1);
|
||||
const pageSize = 10;
|
||||
@@ -161,6 +165,7 @@
|
||||
console.error('加载套餐历史失败', e);
|
||||
}
|
||||
loading.value = false;
|
||||
historyLoaded.value = true;
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
@@ -176,6 +181,13 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 500rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -183,7 +195,7 @@
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
min-height: 400rpx;
|
||||
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||
.empty-icon { width: 120rpx; height: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
||||
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
||||
}
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view v-if="list.length === 0 && !loading" class="empty-state">
|
||||
<view class="empty-icon">📇</view>
|
||||
<view v-if="!cardsLoaded || (loading && list.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="list.length === 0" class="empty-state">
|
||||
<image class="empty-icon" src="/static/authentication.png" mode="aspectFit" alt="实名认证"></image>
|
||||
<view class="empty-title">认证列表为空</view>
|
||||
<view class="empty-desc">当前账号下暂无可实名的卡片</view>
|
||||
</view>
|
||||
|
||||
<view class="card" v-for="item in list" :key="item.iccid">
|
||||
<view class="flex-row-g20">
|
||||
<view class="logo">
|
||||
<image :src="getCarrier(item.carrier_type).logo" mode="aspectFit"></image>
|
||||
<view class="card carrier-card" v-for="item in list" :key="item.iccid">
|
||||
<view class="carrier-main">
|
||||
<view class="logo-stack">
|
||||
<image class="carrier-logo" :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">运营商: {{ getCarrier(item.carrier_type).name }}</view>
|
||||
<view v-if="item.isDevice" class="slot">卡槽位: {{ item.slot_position || '-' }}</view>
|
||||
<view class="carrier-details">
|
||||
<view class="carrier-heading">
|
||||
<view class="carrier-name">{{ getCarrier(item.carrier_type).name }}</view>
|
||||
<image v-if="item.isDevice" class="slot-badge" :src="getSlotIcon(item.slot_position)" mode="aspectFit"></image>
|
||||
</view>
|
||||
<view class="carrier-iccid">
|
||||
<text class="iccid-value-text">{{ item.iccid }}</text>
|
||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"
|
||||
@tap.stop="copyIccid(item.iccid)" aria-label="复制ICCID"></image>
|
||||
</view>
|
||||
<view class="card-actions">
|
||||
<button v-if="item.isRealName" class="btn-apple btn-primary action-button" disabled>已实名</button>
|
||||
<button v-else class="btn-apple btn-primary action-button" @tap="toReal(item)">去实名</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="btn mt-30 flex-col-g20">
|
||||
<up-button class="btn-apple btn-primary" v-if="item.isRealName" type="primary">已实名</up-button>
|
||||
<up-button class="btn-apple btn-success" v-else type="success" @tap="toReal(item)">去实名</up-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal-overlay" v-if="showIccidModal" @tap="closeModal">
|
||||
@@ -41,34 +49,56 @@
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { assetApi, realnameApi } from '@/api/index.js';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
import slot1Icon from '@/static/卡槽1.jpeg';
|
||||
import slot2Icon from '@/static/卡槽2.jpeg';
|
||||
import slot3Icon from '@/static/卡槽3.jpeg';
|
||||
import cmccLogo from '@/static/中国移动.png';
|
||||
import cuccLogo from '@/static/中国联通.png';
|
||||
import ctccLogo from '@/static/中国电信.png';
|
||||
import cbnLogo from '@/static/中国广电.png';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
let list = reactive([]);
|
||||
let loading = ref(false);
|
||||
let cardsLoaded = ref(false);
|
||||
let showIccidModal = ref(false);
|
||||
let currentModalIccid = ref('');
|
||||
let currentCard = ref(null);
|
||||
|
||||
const carrierMap = {
|
||||
CMCC: '中国移动',
|
||||
CUCC: '中国联通',
|
||||
CTCC: '中国电信',
|
||||
CBN: '中国广电'
|
||||
CMCC: { name: '中国移动', logo: cmccLogo },
|
||||
CUCC: { name: '中国联通', logo: cuccLogo },
|
||||
CTCC: { name: '中国电信', logo: ctccLogo },
|
||||
CBN: { name: '中国广电', logo: cbnLogo }
|
||||
};
|
||||
|
||||
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 slotIconMap = {
|
||||
1: slot1Icon,
|
||||
2: slot2Icon,
|
||||
3: slot3Icon
|
||||
};
|
||||
|
||||
const getCarrier = (carrierType) => {
|
||||
return {
|
||||
name: carrierMap[carrierType] || '-',
|
||||
logo: carrierLogoMap[carrierType] || carrierLogoMap.CMCC
|
||||
};
|
||||
return carrierMap[carrierType] || { name: '-', logo: '' };
|
||||
};
|
||||
|
||||
const getSlotIcon = (slotPosition) => slotIconMap[Number(slotPosition)] || slotIconMap[1];
|
||||
|
||||
const sortBySlotPosition = (cards) => cards.sort((left, right) => {
|
||||
const leftSlot = Number(left.slot_position);
|
||||
const rightSlot = Number(right.slot_position);
|
||||
const normalizedLeft = Number.isFinite(leftSlot) && leftSlot > 0 ? leftSlot : Number.MAX_SAFE_INTEGER;
|
||||
const normalizedRight = Number.isFinite(rightSlot) && rightSlot > 0 ? rightSlot : Number.MAX_SAFE_INTEGER;
|
||||
return normalizedLeft - normalizedRight;
|
||||
});
|
||||
|
||||
const copyIccid = (value) => {
|
||||
if (!value) return;
|
||||
uni.setClipboardData({
|
||||
data: String(value),
|
||||
success: () => uni.showToast({ title: 'ICCID已复制', icon: 'success' })
|
||||
});
|
||||
};
|
||||
|
||||
const loadCards = async () => {
|
||||
@@ -77,13 +107,14 @@
|
||||
const assetData = await assetApi.getInfo(userStore.state.identifier);
|
||||
|
||||
if (assetData.asset_type === 'device') {
|
||||
list.splice(0, list.length, ...(assetData.cards || []).map(card => ({
|
||||
const cards = (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
|
||||
})));
|
||||
}));
|
||||
list.splice(0, list.length, ...sortBySlotPosition(cards));
|
||||
} else if (assetData.iccid) {
|
||||
list.splice(0, list.length, {
|
||||
iccid: assetData.iccid,
|
||||
@@ -98,6 +129,7 @@
|
||||
console.error('加载卡列表失败', e);
|
||||
}
|
||||
loading.value = false;
|
||||
cardsLoaded.value = true;
|
||||
};
|
||||
|
||||
const toReal = async (card) => {
|
||||
@@ -140,15 +172,94 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
.card {
|
||||
.logo {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 120rpx;
|
||||
border: 1rpx solid var(--primary);
|
||||
overflow: hidden;
|
||||
image { width: 100%; height: 100%; }
|
||||
}
|
||||
.carrier-card {
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.carrier-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.logo-stack {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
margin-top: 12rpx;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.carrier-logo {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slot-badge {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.carrier-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.carrier-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.carrier-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.carrier-iccid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-top: 14rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.iccid-value-text {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24rpx;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
height: 64rpx;
|
||||
padding: 0 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 64rpx;
|
||||
box-sizing: border-box;
|
||||
&::after { border: none; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,8 +354,15 @@
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
min-height: 400rpx;
|
||||
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||
.empty-icon { width: 120rpx; height: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
||||
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 500rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view v-if="!exchangeData && !loading" class="empty-state">
|
||||
<view class="empty-icon"></view>
|
||||
<view v-if="!exchangeLoaded || (loading && !exchangeData)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="!exchangeData" class="empty-state">
|
||||
<image class="empty-icon" src="/static/change-shop.png" mode="aspectFit" alt="换货"></image>
|
||||
<view class="empty-title">暂无换货记录</view>
|
||||
<view class="empty-desc">当前账号下暂无换货记录</view>
|
||||
</view>
|
||||
@@ -91,7 +94,9 @@
|
||||
<view class="form-label">收件人电话</view>
|
||||
<up-input
|
||||
v-model="form.recipient_phone"
|
||||
placeholder="请输入收件人电话"
|
||||
placeholder="请输入11位手机号"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
border="surround"
|
||||
/>
|
||||
</view>
|
||||
@@ -156,6 +161,7 @@
|
||||
|
||||
const exchangeData = ref(null);
|
||||
const loading = ref(false);
|
||||
const exchangeLoaded = ref(false);
|
||||
const showPopup = ref(false);
|
||||
const showAreaPicker = ref(false);
|
||||
const areaPickerColumns = ref([]);
|
||||
@@ -188,6 +194,24 @@
|
||||
return String(time).split('T').join(' ').slice(0, 19);
|
||||
};
|
||||
|
||||
const getRecipientPhone = () => String(form.recipient_phone || '').trim();
|
||||
|
||||
const validateRecipientPhone = () => {
|
||||
const phone = getRecipientPhone();
|
||||
|
||||
if (!phone) {
|
||||
uni.showToast({ title: '请输入收件人电话', icon: 'none' });
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!PHONE_REG.test(phone)) {
|
||||
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
|
||||
return '';
|
||||
}
|
||||
|
||||
return phone;
|
||||
};
|
||||
|
||||
const syncAreaPickerState = (regionCodes = []) => {
|
||||
const { columns, defaultIndex } = buildRegionColumns(regionCodes);
|
||||
areaPickerColumns.value = columns;
|
||||
@@ -204,6 +228,7 @@
|
||||
exchangeData.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
exchangeLoaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -307,15 +332,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.recipient_phone.trim()) {
|
||||
uni.showToast({ title: '请输入收件人电话', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PHONE_REG.test(form.recipient_phone.trim())) {
|
||||
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const recipientPhone = validateRecipientPhone();
|
||||
if (!recipientPhone) return;
|
||||
|
||||
if (form.recipient_region_codes.length !== 3) {
|
||||
uni.showToast({ title: '请选择省市区', icon: 'none' });
|
||||
@@ -336,7 +354,7 @@
|
||||
await exchangeApi.submitShippingInfo(
|
||||
exchangeData.value.id,
|
||||
form.recipient_name.trim(),
|
||||
form.recipient_phone.trim(),
|
||||
recipientPhone,
|
||||
recipientAddress
|
||||
);
|
||||
uni.showToast({ title: '提交成功', icon: 'success' });
|
||||
@@ -355,6 +373,13 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 500rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -364,7 +389,8 @@
|
||||
min-height: 60vh;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 120rpx;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-bottom: 30rpx;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<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"
|
||||
:isExpiring="deviceInfo.isExpiring" :expiryDays="deviceInfo.expiryDays" @renew="startHomepageRenewal" />
|
||||
|
||||
<DeviceStatusCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" :isRealName="isRealName"
|
||||
:isDevice="userInfo.isDevice" @authentication="enterDetail('authentication')" />
|
||||
@@ -14,14 +15,16 @@
|
||||
<!-- <WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
|
||||
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" /> -->
|
||||
|
||||
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi" @copy="copy" />
|
||||
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi"
|
||||
@copy-config="copyWifiConfig" />
|
||||
|
||||
<FunctionCard :realNameStatus="realNameStatus" :alreadyBindPhone="alreadyBindPhone"
|
||||
:isDevice="userInfo.isDevice" :walletBalance="deviceInfo.walletBalance" @enter="enterDetail" @sync="onSync">
|
||||
<!-- 修改WIFI弹窗 -->
|
||||
:isDevice="userInfo.isDevice" :walletBalance="deviceInfo.walletBalance" :unreadCount="notificationUnreadCount"
|
||||
@enter="enterDetail" @sync="onSync">
|
||||
<!-- 修改配置弹窗 -->
|
||||
<up-popup :show="showModifyWifi" mode="center" @close="showModifyWifi=false">
|
||||
<view class="wifi-popup">
|
||||
<view class="title mb-md">修改WIFI</view>
|
||||
<view class="title mb-md">修改配置</view>
|
||||
<view class="flex-col-g20">
|
||||
<view class="flex-col-g8">
|
||||
<label class="caption">名称:</label>
|
||||
@@ -94,6 +97,13 @@
|
||||
|
||||
<view class="bottom-spacer"></view>
|
||||
|
||||
<NotificationPopup :show="notificationPopupShow" :items="notificationItems"
|
||||
:current="notificationPopupCurrent" @change="onNotificationChange" @close="closeNotificationPopup"
|
||||
@renew="startHomepageRenewal" />
|
||||
|
||||
<RenewalPaymentPopup ref="renewalPopupRef" :identifier="userStore.state.identifier"
|
||||
paymentRefreshTarget="home" @completed="loadAssetInfo" />
|
||||
|
||||
<!-- 日期选择器弹窗 -->
|
||||
<up-datetime-picker v-if="!userInfo.isDevice" :show="showStartDatePicker" v-model="startDateTimestamp"
|
||||
mode="date" @confirm="onStartDateConfirm" @cancel="showStartDatePicker=false"
|
||||
@@ -122,13 +132,18 @@
|
||||
import WifiCard from '@/components/WifiCard.vue';
|
||||
import FunctionCard from '@/components/FunctionCard.vue';
|
||||
import FloatingButton from '@/components/FloatingButton.vue';
|
||||
import NotificationPopup from '@/components/NotificationPopup.vue';
|
||||
import RenewalPaymentPopup from '@/components/RenewalPaymentPopup.vue';
|
||||
import {
|
||||
assetApi,
|
||||
deviceApi
|
||||
deviceApi,
|
||||
hasActiveOrPendingPackage,
|
||||
notificationApi
|
||||
} from '@/api/index.js';
|
||||
import {
|
||||
useUserStore
|
||||
} from '@/store/index.js';
|
||||
import { consumePendingPaymentRefresh, PAYMENT_REFRESH_TARGETS } from '@/utils/payment.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
@@ -149,7 +164,12 @@
|
||||
network_status: 1,
|
||||
status: 1,
|
||||
packageName: '-',
|
||||
currentPackageId: 0,
|
||||
renewalPrice: null,
|
||||
expireDate: '-',
|
||||
isExpiring: false,
|
||||
expiryDays: null,
|
||||
expiryEstimateStatus: 'none',
|
||||
walletBalance: 0,
|
||||
iccid: '-',
|
||||
currentIccid: '-',
|
||||
@@ -178,6 +198,8 @@
|
||||
let alreadyBindPhone = ref(false);
|
||||
let boundPhone = ref('');
|
||||
let realNameStatus = ref('');
|
||||
let effectiveRealnamePolicy = ref('none');
|
||||
let realnameEntryChecking = ref(false);
|
||||
let wifi_info = reactive({
|
||||
ssid: '',
|
||||
pwd: ''
|
||||
@@ -210,6 +232,12 @@
|
||||
let smsCodePhone = ref('');
|
||||
let smsCode = ref('');
|
||||
let indexEntryChecking = ref(false);
|
||||
let notificationUnreadCount = ref(0);
|
||||
let notificationPopupShow = ref(false);
|
||||
let notificationItems = ref([]);
|
||||
let notificationPopupCurrent = ref(0);
|
||||
let renewalPopupRef = ref(null);
|
||||
const notificationReadPending = new Set();
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
@@ -236,15 +264,22 @@
|
||||
deviceInfo.network_status = data.network_status;
|
||||
deviceInfo.status = data.status;
|
||||
deviceInfo.imei = data.imei || '-';
|
||||
deviceInfo.asset_type = data.asset_type || 'device';
|
||||
deviceInfo.bound_phone = data.bound_phone || '';
|
||||
deviceInfo.packageName = data.current_package || '-';
|
||||
deviceInfo.expireDate = formatDate(data.current_package_expires_at);
|
||||
deviceInfo.asset_type = data.asset_type || 'device';
|
||||
deviceInfo.bound_phone = data.bound_phone || '';
|
||||
deviceInfo.packageName = data.current_package || '-';
|
||||
deviceInfo.currentPackageId = Number(data.current_package_id || 0);
|
||||
deviceInfo.renewalPrice = data.renewal_price ?? null;
|
||||
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.walletBalance = data.wallet_balance ?? 0;
|
||||
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 ? '已实名' : '未实名';
|
||||
effectiveRealnamePolicy.value = data.effective_realname_policy || 'none';
|
||||
userStore.setRealNameStatus(data.real_name_status);
|
||||
boundPhone.value = data.bound_phone || '';
|
||||
alreadyBindPhone.value = !!data.bound_phone;
|
||||
|
||||
@@ -301,7 +336,7 @@
|
||||
deviceInfo.signal_bad_reason = '';
|
||||
}
|
||||
|
||||
// 到期时间由 TrafficCard 组件获取套餐信息时一并返回
|
||||
// 最终到期时间和临期状态均以后端 asset/info 返回值为准。
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error('加载资产信息失败', e);
|
||||
@@ -322,25 +357,21 @@
|
||||
indexEntryChecking.value = true;
|
||||
|
||||
try {
|
||||
const data = await loadAssetInfo();
|
||||
if (data && !data.bound_phone) {
|
||||
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
|
||||
}
|
||||
await loadAssetInfo();
|
||||
} finally {
|
||||
indexEntryChecking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理套餐加载完成事件(从 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 startHomepageRenewal = () => {
|
||||
const packageId = Number(deviceInfo.currentPackageId || 0);
|
||||
const packageName = String(deviceInfo.packageName || '').trim();
|
||||
if (!packageId || !packageName || packageName === '-') {
|
||||
uni.showToast({ title: '当前资产暂无可续费套餐', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
renewalPopupRef.value?.open([packageId], [packageName], deviceInfo.renewalPrice);
|
||||
};
|
||||
|
||||
const modifyWifi = () => {
|
||||
@@ -483,16 +514,6 @@
|
||||
restartShow.value = false;
|
||||
};
|
||||
|
||||
const copy = (content) => {
|
||||
uni.setClipboardData({
|
||||
data: content,
|
||||
success: () => uni.showToast({
|
||||
title: '复制成功',
|
||||
icon: 'none'
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const YesRecover = async () => {
|
||||
try {
|
||||
await deviceApi.factoryReset(userStore.state.identifier);
|
||||
@@ -697,8 +718,58 @@
|
||||
});
|
||||
};
|
||||
|
||||
const shouldRedirectRealnameToPackageOrder = async () => {
|
||||
if (effectiveRealnamePolicy.value !== 'after_order') return false;
|
||||
|
||||
const identifier = userStore.state.identifier;
|
||||
let cards = [{ real_name_status: isRealName.value ? 1 : 0 }];
|
||||
if (userInfo.isDevice) {
|
||||
const data = await deviceApi.getCards(identifier);
|
||||
cards = Array.isArray(data?.cards) ? data.cards : [];
|
||||
}
|
||||
|
||||
if (cards.some(card => Number(card?.real_name_status) === 1)) return false;
|
||||
return !(await hasActiveOrPendingPackage(identifier));
|
||||
};
|
||||
|
||||
const openRealnameEntry = async () => {
|
||||
if (realnameEntryChecking.value) return;
|
||||
realnameEntryChecking.value = true;
|
||||
uni.showLoading({ title: '校验中...', mask: true });
|
||||
|
||||
try {
|
||||
const shouldRedirect = await shouldRedirectRealnameToPackageOrder();
|
||||
uni.hideLoading();
|
||||
if (!shouldRedirect) {
|
||||
uni.navigateTo({ url: '/pages/auth/auth' });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '当前设备所有卡均未实名,且暂无生效中或待生效套餐。请先订购套餐,再进行实名认证。',
|
||||
confirmText: '去订购',
|
||||
cancelText: '取消',
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) uni.navigateTo({ url: '/pages/package-order/package-order' });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
console.error('校验实名入口条件失败', error);
|
||||
uni.showToast({ title: '实名条件校验失败,请稍后重试', icon: 'none' });
|
||||
} finally {
|
||||
realnameEntryChecking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const enterDetail = (name) => {
|
||||
switch (name) {
|
||||
case 'notifications':
|
||||
uni.navigateTo({
|
||||
url: '/pages/notifications/notifications'
|
||||
});
|
||||
break;
|
||||
case 'package-order':
|
||||
uni.navigateTo({
|
||||
url: '/pages/package-order/package-order'
|
||||
@@ -751,9 +822,7 @@
|
||||
});
|
||||
break;
|
||||
case 'authentication':
|
||||
uni.navigateTo({
|
||||
url: '/pages/auth/auth'
|
||||
});
|
||||
openRealnameEntry();
|
||||
break;
|
||||
case 'recover':
|
||||
recoverShow.value = true;
|
||||
@@ -767,12 +836,87 @@
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotificationUnreadCount = async () => {
|
||||
try {
|
||||
const data = await notificationApi.getUnreadCount();
|
||||
notificationUnreadCount.value = Number(data?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('加载通知未读数失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const markNotificationRead = async (item) => {
|
||||
if (!item || item.is_read || notificationReadPending.has(item.id)) return;
|
||||
notificationReadPending.add(item.id);
|
||||
try {
|
||||
await notificationApi.markRead(item.id);
|
||||
item.is_read = true;
|
||||
item.read_at = new Date().toISOString();
|
||||
notificationUnreadCount.value = Math.max(notificationUnreadCount.value - 1, 0);
|
||||
} catch (error) {
|
||||
console.error('标记首页通知已读失败', error);
|
||||
} finally {
|
||||
notificationReadPending.delete(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUnreadNotifications = async () => {
|
||||
try {
|
||||
const data = await notificationApi.getList(1, 50, false);
|
||||
const items = (data?.items || [])
|
||||
.filter(item => !item.is_read)
|
||||
.sort((a, b) => getNotificationPriority(b) - getNotificationPriority(a));
|
||||
if (!items.length) return;
|
||||
|
||||
notificationItems.value = items;
|
||||
notificationPopupCurrent.value = 0;
|
||||
notificationPopupShow.value = true;
|
||||
// 弹窗打开即视为用户已看到第一条通知。
|
||||
markNotificationRead(items[0]);
|
||||
} catch (error) {
|
||||
console.error('加载首页未读通知失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const copyWifiConfig = (content) => {
|
||||
uni.setClipboardData({
|
||||
data: content,
|
||||
success: () => uni.showToast({ title: '配置已复制', icon: 'none' })
|
||||
});
|
||||
};
|
||||
|
||||
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 index = Number(event?.detail?.current ?? event?.current ?? 0);
|
||||
notificationPopupCurrent.value = index;
|
||||
markNotificationRead(notificationItems.value[index]);
|
||||
};
|
||||
|
||||
const closeNotificationPopup = () => {
|
||||
notificationPopupShow.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initCurrentMonth();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
if (consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.HOME)) {
|
||||
loadAssetInfo();
|
||||
}
|
||||
handleIndexEntry();
|
||||
loadNotificationUnreadCount();
|
||||
loadUnreadNotifications();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -245,14 +245,27 @@
|
||||
|
||||
const doLogin = async () => {
|
||||
loading.value = true;
|
||||
// 丢弃上一轮未完成授权留下的临时凭证,避免校验失败时继续复用。
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.removeItem('assetToken');
|
||||
}
|
||||
try {
|
||||
const verifyData = await authApi.verifyAsset(identifier.value);
|
||||
if (!verifyData?.asset_token) {
|
||||
throw { msg: '资产校验未返回有效登录凭证' };
|
||||
}
|
||||
|
||||
userStore.setAssetToken(verifyData.asset_token);
|
||||
userStore.setIdentifier(identifier.value);
|
||||
|
||||
await redirectToWxAuth(verifyData.asset_token);
|
||||
} catch (e) {
|
||||
console.error('登录失败', e);
|
||||
uni.showToast({
|
||||
title: e?.msg || e?.message || '资产校验失败,请稍后重试',
|
||||
icon: 'none',
|
||||
duration: 2500
|
||||
});
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
@@ -355,7 +368,7 @@
|
||||
.input-wrap.focus {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4rpx rgba(10, 132, 255, 0.1);
|
||||
box-shadow: 0 0 0 4rpx rgba(85, 171, 92, 0.1);
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -406,7 +419,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4rpx 12rpx rgba(10, 132, 255, 0.2);
|
||||
box-shadow: 0 4rpx 12rpx rgba(85, 171, 92, 0.2);
|
||||
}
|
||||
|
||||
.btn-login.disabled {
|
||||
@@ -417,7 +430,7 @@
|
||||
|
||||
.btn-login:active:not(.disabled) {
|
||||
transform: translateY(2rpx);
|
||||
box-shadow: 0 2rpx 8rpx rgba(10, 132, 255, 0.2);
|
||||
box-shadow: 0 2rpx 8rpx rgba(85, 171, 92, 0.2);
|
||||
}
|
||||
|
||||
.btn-scan {
|
||||
@@ -438,7 +451,7 @@
|
||||
}
|
||||
|
||||
.btn-scan:active {
|
||||
background: rgba(10, 132, 255, 0.05);
|
||||
background: rgba(85, 171, 92, 0.05);
|
||||
}
|
||||
|
||||
.scan-icon {
|
||||
|
||||
@@ -37,8 +37,11 @@
|
||||
>
|
||||
<swiper-item class="swiper-item-content">
|
||||
<scroll-view scroll-y class="list-content" @scrolltolower="loadMoreRecharge">
|
||||
<view v-if="rechargeList.length === 0 && !rechargeLoading" class="empty-state">
|
||||
<view class="empty-icon">RCG</view>
|
||||
<view v-if="!rechargeLoaded || (rechargeLoading && rechargeList.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="rechargeList.length === 0" class="empty-state">
|
||||
<image class="empty-icon" src="/static/wallet.png" mode="aspectFit" alt="钱包"></image>
|
||||
<view class="empty-title">暂无充值订单</view>
|
||||
</view>
|
||||
|
||||
@@ -55,10 +58,6 @@
|
||||
<view class="info-label">充值金额</view>
|
||||
<view class="info-value text-danger">+¥{{ formatMoney(item.amount) }}</view>
|
||||
</view>
|
||||
<view class="info-row flex-row-sb">
|
||||
<view class="info-label">支付方式</view>
|
||||
<view class="info-value">{{ formatPaymentMethod(item.payment_method) }}</view>
|
||||
</view>
|
||||
<view v-if="hasAutoPurchaseStatus(item)" class="info-row flex-row-sb">
|
||||
<view class="info-label">自动购包</view>
|
||||
<view class="info-value">{{ getAutoPurchaseStatusText(item.auto_purchase_status) }}</view>
|
||||
@@ -89,8 +88,11 @@
|
||||
|
||||
<swiper-item class="swiper-item-content">
|
||||
<scroll-view scroll-y class="list-content" @scrolltolower="loadMoreTransaction">
|
||||
<view v-if="transactionList.length === 0 && !transactionLoading" class="empty-state">
|
||||
<view class="empty-icon">WAL</view>
|
||||
<view v-if="!transactionLoaded || (transactionLoading && transactionList.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="transactionList.length === 0" class="empty-state">
|
||||
<image class="empty-icon" src="/static/wallet.png" mode="aspectFit" alt="钱包"></image>
|
||||
<view class="empty-title">暂无钱包流水</view>
|
||||
</view>
|
||||
|
||||
@@ -155,21 +157,17 @@
|
||||
</view>
|
||||
|
||||
<view class="payment-methods">
|
||||
<view v-if="isDeviceAsset" class="method-item" :class="{ active: rechargePaymentMethod === 'wechat' }" @tap="selectRechargePaymentMethod('wechat')">
|
||||
<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">
|
||||
<image class="method-icon" src="/static/wechat.png" mode="aspectFit"></image>
|
||||
<text class="method-name">微信支付</text>
|
||||
<image v-if="method.value === 'alipay'" class="method-icon" src="/static/支付宝支付.png" mode="aspectFit"></image>
|
||||
<image v-else-if="method.value === 'wechat'" class="method-icon" src="/static/微信支付.png" mode="aspectFit"></image>
|
||||
<image v-else class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
|
||||
<text class="method-name">{{ method.label }}</text>
|
||||
</view>
|
||||
<view class="method-radio" :class="{ checked: rechargePaymentMethod === 'wechat' }"></view>
|
||||
</view>
|
||||
|
||||
<view 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>
|
||||
</view>
|
||||
<view class="method-radio" :class="{ checked: rechargePaymentMethod === 'alipay' }"></view>
|
||||
<view class="method-radio" :class="{ checked: rechargePaymentMethod === method.value }"></view>
|
||||
</view>
|
||||
<view v-if="paymentMethodOptions.length === 0" class="method-empty">暂无可用支付方式</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-footer">
|
||||
@@ -177,6 +175,46 @@
|
||||
<button class="btn-apple btn-primary" :disabled="rechargeSubmitting" @tap="confirmRecharge">
|
||||
{{ rechargeSubmitting ? '处理中...' : '确认充值' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</up-popup>
|
||||
|
||||
<up-popup :show="showRechargeOrderPaymentModal" mode="center" @close="closeRechargeOrderPaymentModal">
|
||||
<view class="recharge-popup recharge-order-payment-popup">
|
||||
<view class="popup-header">
|
||||
<view class="popup-title">选择支付方式</view>
|
||||
<view class="popup-close" @tap="closeRechargeOrderPaymentModal">×</view>
|
||||
</view>
|
||||
|
||||
<view class="package-summary">
|
||||
<view class="summary-name">{{ selectedRechargeOrder?.recharge_order_no || selectedRechargeOrder?.recharge_no || '-' }}</view>
|
||||
<view class="summary-price">¥{{ formatMoney(selectedRechargeOrder?.amount) }}</view>
|
||||
</view>
|
||||
|
||||
<view class="payment-methods">
|
||||
<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">
|
||||
<image v-if="method.value === 'alipay'" class="method-icon" src="/static/支付宝支付.png" mode="aspectFit"></image>
|
||||
<image v-else-if="method.value === 'wechat'" class="method-icon" src="/static/微信支付.png" mode="aspectFit"></image>
|
||||
<image v-else class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
|
||||
<text class="method-name">{{ method.label }}</text>
|
||||
</view>
|
||||
<view class="method-radio" :class="{ checked: rechargePaymentMethod === method.value }"></view>
|
||||
</view>
|
||||
<view v-if="paymentMethodOptions.length === 0" class="method-empty">暂无可用支付方式</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-footer">
|
||||
<button class="btn-apple btn-secondary" @tap="closeRechargeOrderPaymentModal">取消</button>
|
||||
<button class="btn-apple btn-primary" :disabled="rechargeOrderSubmittingKey !== null" @tap="confirmRechargeOrderPayment">
|
||||
{{ rechargeOrderSubmittingKey !== null ? '处理中...' : '确认支付' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</up-popup>
|
||||
@@ -188,7 +226,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
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 {
|
||||
consumePendingPaymentRefresh,
|
||||
@@ -200,6 +238,8 @@
|
||||
showPaymentToast,
|
||||
wechatH5Pay
|
||||
} from '@/utils/payment.js';
|
||||
import { formatMoney } from '@/utils/display.js';
|
||||
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
@@ -212,29 +252,29 @@
|
||||
|
||||
const rechargeList = reactive([]);
|
||||
const rechargeLoading = ref(false);
|
||||
const rechargeLoaded = ref(false);
|
||||
const rechargeNoMore = ref(false);
|
||||
const rechargePage = ref(1);
|
||||
const rechargePageSize = 5;
|
||||
|
||||
const transactionList = reactive([]);
|
||||
const transactionLoading = ref(false);
|
||||
const transactionLoaded = ref(false);
|
||||
const transactionNoMore = ref(false);
|
||||
const transactionPage = ref(1);
|
||||
const transactionPageSize = 5;
|
||||
|
||||
const showRechargeModal = ref(false);
|
||||
const selectedAmount = ref(null);
|
||||
const showRechargeOrderPaymentModal = ref(false);
|
||||
const selectedRechargeOrder = ref(null);
|
||||
const selectedAmount = ref(10000);
|
||||
const isCustomAmount = ref(false);
|
||||
const customAmount = ref('');
|
||||
const rechargePaymentMethod = ref('alipay');
|
||||
const rechargeSubmitting = ref(false);
|
||||
const rechargeOrderSubmittingKey = ref(null);
|
||||
const isDeviceAsset = ref(true);
|
||||
let assetTypePromise = null;
|
||||
const paymentMethodOptions = computed(() => [
|
||||
...(isDeviceAsset.value ? [{ label: '微信支付', value: 'wechat' }] : []),
|
||||
{ label: '支付宝支付', value: 'alipay' }
|
||||
]);
|
||||
const rechargeAllowedPaymentMethods = ref([]);
|
||||
const paymentMethodOptions = computed(() => getPaymentMethodOptions(rechargeAllowedPaymentMethods.value));
|
||||
const rechargeAmounts = [
|
||||
{ value: 1000, label: '10' },
|
||||
{ value: 2000, label: '20' },
|
||||
@@ -252,25 +292,11 @@
|
||||
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) => {
|
||||
if (!amount && amount !== 0) return '0';
|
||||
return parseFloat(amount).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
};
|
||||
|
||||
const formatPaymentMethod = (method) => {
|
||||
const labelMap = {
|
||||
wechat: '微信支付',
|
||||
alipay: '支付宝支付',
|
||||
wallet: '钱包支付'
|
||||
};
|
||||
return labelMap[method] || method || '-';
|
||||
};
|
||||
|
||||
const getRechargeStatusClass = (status) => {
|
||||
const classMap = {
|
||||
0: 'tag-warning',
|
||||
@@ -307,34 +333,13 @@
|
||||
return rechargeOrderSubmittingKey.value === getRechargeOrderSubmitKey(rechargeOrder);
|
||||
};
|
||||
|
||||
const getDefaultRechargePaymentMethod = () => 'alipay';
|
||||
|
||||
const normalizeRechargePaymentMethod = () => {
|
||||
const defaultMethod = getDefaultRechargePaymentMethod();
|
||||
if (rechargePaymentMethod.value !== defaultMethod) {
|
||||
const defaultMethod = getDefaultPaymentMethod(rechargeAllowedPaymentMethods.value);
|
||||
if (!paymentMethodOptions.value.some((item) => item.value === rechargePaymentMethod.value)) {
|
||||
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;
|
||||
@@ -348,7 +353,6 @@
|
||||
};
|
||||
|
||||
const syncWalletStatus = () => {
|
||||
loadAssetType();
|
||||
loadWalletDetail();
|
||||
resetRechargeListAndLoad();
|
||||
resetTransactionListAndLoad();
|
||||
@@ -368,10 +372,33 @@
|
||||
}, 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 () => {
|
||||
await loadAssetType();
|
||||
rechargePaymentMethod.value = getDefaultRechargePaymentMethod();
|
||||
showRechargeModal.value = true;
|
||||
try {
|
||||
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
|
||||
);
|
||||
selectedAmount.value = 10000;
|
||||
isCustomAmount.value = false;
|
||||
customAmount.value = '';
|
||||
showRechargeModal.value = true;
|
||||
} catch (error) {
|
||||
console.error('充值前校验失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAmount = (value) => {
|
||||
@@ -390,6 +417,12 @@
|
||||
rechargePaymentMethod.value = method;
|
||||
};
|
||||
|
||||
const closeRechargeOrderPaymentModal = () => {
|
||||
if (rechargeOrderSubmittingKey.value !== null) return;
|
||||
showRechargeOrderPaymentModal.value = false;
|
||||
selectedRechargeOrder.value = null;
|
||||
};
|
||||
|
||||
const loadWalletDetail = async () => {
|
||||
try {
|
||||
const data = await walletApi.getDetail(userStore.state.identifier);
|
||||
@@ -466,6 +499,7 @@
|
||||
}
|
||||
|
||||
rechargeLoading.value = false;
|
||||
rechargeLoaded.value = true;
|
||||
};
|
||||
|
||||
const normalizeTransactionItem = (item) => {
|
||||
@@ -510,9 +544,16 @@
|
||||
}
|
||||
|
||||
transactionLoading.value = false;
|
||||
transactionLoaded.value = true;
|
||||
};
|
||||
|
||||
const handleRechargeResult = async (rechargeData, paymentMethod) => {
|
||||
if (paymentMethod === 'wallet') {
|
||||
showPaymentToast(true, '充值成功');
|
||||
setTimeout(() => syncWalletStatus(), 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (paymentMethod === 'wechat') {
|
||||
if (!isValidWechatPayConfig(rechargeData?.pay_config)) {
|
||||
uni.showToast({
|
||||
@@ -586,23 +627,31 @@
|
||||
|
||||
try {
|
||||
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.showModal({
|
||||
title: '提示',
|
||||
content: `${checkData.message || '当前需先充值最低金额'} ¥${(checkData.force_recharge_amount / 100).toFixed(2)}`,
|
||||
content: `${checkData.message || '当前需先充值最低金额'} ¥${formatMoney(forceAmount)}`,
|
||||
showCancel: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount < checkData.min_amount || amount > checkData.max_amount) {
|
||||
if (amount < minAmount || amount > maxAmount) {
|
||||
uni.hideLoading();
|
||||
showRechargeModal.value = false;
|
||||
setTimeout(() => {
|
||||
uni.showToast({
|
||||
title: `充值金额范围:¥${(checkData.min_amount / 100).toFixed(2)} - ¥${(checkData.max_amount / 100).toFixed(2)}`,
|
||||
title: `充值金额范围:¥${formatMoney(minAmount)} - ¥${formatMoney(maxAmount)}`,
|
||||
icon: 'none',
|
||||
duration: 2500
|
||||
});
|
||||
@@ -637,18 +686,31 @@
|
||||
|
||||
const showRechargePaymentMethods = async (rechargeOrder) => {
|
||||
if (rechargeOrderSubmittingKey.value !== null) return;
|
||||
await loadAssetType();
|
||||
const options = paymentMethodOptions.value;
|
||||
try {
|
||||
applyRechargeCheck(await walletApi.rechargeCheck(userStore.state.identifier));
|
||||
} catch (error) {
|
||||
console.error('加载充值支付方式失败', error);
|
||||
uni.showToast({ title: error.msg || error.message || '加载支付方式失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!paymentMethodOptions.value.length) {
|
||||
uni.showToast({ title: '暂无可用支付方式', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
rechargePaymentMethod.value = getDefaultPaymentMethod(rechargeAllowedPaymentMethods.value);
|
||||
selectedRechargeOrder.value = rechargeOrder;
|
||||
showRechargeOrderPaymentModal.value = true;
|
||||
};
|
||||
|
||||
uni.showActionSheet({
|
||||
itemList: options.map((item) => item.label),
|
||||
success: ({ tapIndex }) => {
|
||||
const selectedMethod = options[tapIndex]?.value;
|
||||
if (selectedMethod) {
|
||||
handleRechargePayment(rechargeOrder, selectedMethod);
|
||||
}
|
||||
}
|
||||
});
|
||||
const confirmRechargeOrderPayment = async () => {
|
||||
if (!selectedRechargeOrder.value || rechargeOrderSubmittingKey.value !== null) return;
|
||||
const rechargeOrder = selectedRechargeOrder.value;
|
||||
const paymentMethod = rechargePaymentMethod.value;
|
||||
showRechargeOrderPaymentModal.value = false;
|
||||
await handleRechargePayment(rechargeOrder, paymentMethod);
|
||||
if (rechargeOrderSubmittingKey.value === null) {
|
||||
selectedRechargeOrder.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRechargePayment = async (rechargeOrder, paymentMethod) => {
|
||||
@@ -933,10 +995,10 @@
|
||||
padding: 120rpx 40rpx;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 56rpx;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-bottom: 24rpx;
|
||||
opacity: 0.5;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
@@ -1004,7 +1066,7 @@
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(0, 122, 255, 0.05);
|
||||
background: rgba(85, 171, 92, 0.05);
|
||||
|
||||
.amount-value {
|
||||
color: var(--primary);
|
||||
@@ -1049,7 +1111,7 @@
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(0, 122, 255, 0.05);
|
||||
background: rgba(85, 171, 92, 0.05);
|
||||
}
|
||||
|
||||
.method-left {
|
||||
@@ -1144,4 +1206,39 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 360rpx;
|
||||
}
|
||||
|
||||
.recharge-order-payment-popup {
|
||||
.package-summary {
|
||||
padding: 40rpx 30rpx 30rpx;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
|
||||
.summary-name {
|
||||
margin: 0 0 20rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summary-price {
|
||||
margin: 0;
|
||||
font-size: 56rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1rpx;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.payment-methods {
|
||||
padding: 30rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
178
pages/notifications/notifications.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="toolbar flex-row-sb">
|
||||
<view class="summary">共 {{ total }} 条通知</view>
|
||||
<button class="btn-apple btn-secondary mark-all" :disabled="unreadCount === 0" @tap="markAllRead">
|
||||
全部已读
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="!notificationsLoaded || (loading && notifications.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="notifications.length === 0" class="empty-state">
|
||||
<image src="/static/notification.png" mode="aspectFit" class="empty-icon" />
|
||||
<view class="empty-title">暂无通知</view>
|
||||
<view class="empty-desc">当前没有可查看的业务通知</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="notification-list">
|
||||
<view v-for="item in notifications" :key="item.id" class="card notification-card"
|
||||
:class="{ unread: !item.is_read }" @tap="markItemRead(item)">
|
||||
<view class="notification-header flex-row-sb">
|
||||
<view class="notification-title">{{ item.title || '业务通知' }}</view>
|
||||
<view v-if="!item.is_read" class="unread-dot"></view>
|
||||
</view>
|
||||
<view class="notification-body">{{ item.body || '-' }}</view>
|
||||
<view class="notification-footer flex-row-sb">
|
||||
<view class="notification-meta">{{ getCategoryText(item.category) }} · {{ getSeverityText(item.severity) }}</view>
|
||||
<view class="notification-time">{{ formatDate(item.created_at) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="notifications.length > 0" class="load-more" @tap="loadMore">
|
||||
<text v-if="loading">加载中...</text>
|
||||
<text v-else-if="noMore">没有更多了</text>
|
||||
<text v-else>点击加载更多</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { notificationApi } from '@/api/index.js';
|
||||
|
||||
const pageSize = 20;
|
||||
const notifications = reactive([]);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const unreadCount = ref(0);
|
||||
const loading = ref(false);
|
||||
const notificationsLoaded = ref(false);
|
||||
const noMore = ref(false);
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '-';
|
||||
return value.replace('T', ' ').slice(0, 16);
|
||||
};
|
||||
|
||||
const getCategoryText = (category) => ({
|
||||
approval: '审批',
|
||||
expiry: '临期',
|
||||
exchange: '换货',
|
||||
sync: '同步',
|
||||
system: '系统'
|
||||
}[category] || '通知');
|
||||
|
||||
const getSeverityText = (severity) => ({
|
||||
info: '提示',
|
||||
warning: '警告',
|
||||
error: '错误',
|
||||
critical: '严重'
|
||||
}[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 () => {
|
||||
try {
|
||||
const data = await notificationApi.getUnreadCount();
|
||||
unreadCount.value = Number(data?.count || 0);
|
||||
} catch (error) {
|
||||
console.error('加载通知未读数失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotifications = async (append = false) => {
|
||||
if (loading.value || (append && noMore.value)) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await notificationApi.getList(page.value, pageSize);
|
||||
const items = (data?.items || []).sort((a, b) => getNotificationPriority(b) - getNotificationPriority(a));
|
||||
if (append) {
|
||||
notifications.push(...items);
|
||||
} else {
|
||||
notifications.splice(0, notifications.length, ...items);
|
||||
}
|
||||
total.value = Number(data?.total || 0);
|
||||
noMore.value = notifications.length >= total.value || items.length < pageSize;
|
||||
if (!noMore.value) page.value += 1;
|
||||
} catch (error) {
|
||||
console.error('加载通知列表失败', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
notificationsLoaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const markItemRead = async (item) => {
|
||||
if (item.is_read) return;
|
||||
try {
|
||||
await notificationApi.markRead(item.id);
|
||||
item.is_read = true;
|
||||
item.read_at = new Date().toISOString();
|
||||
unreadCount.value = Math.max(unreadCount.value - 1, 0);
|
||||
} catch (error) {
|
||||
console.error('标记通知已读失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
if (!unreadCount.value) return;
|
||||
try {
|
||||
await notificationApi.markAllRead();
|
||||
notifications.forEach(item => {
|
||||
item.is_read = true;
|
||||
item.read_at = item.read_at || new Date().toISOString();
|
||||
});
|
||||
unreadCount.value = 0;
|
||||
uni.showToast({ title: '已全部标记为已读', icon: 'success' });
|
||||
} catch (error) {
|
||||
console.error('全部标记通知已读失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (!noMore.value) loadNotifications(true);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadNotifications();
|
||||
loadUnreadCount();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
loadUnreadCount();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container { padding-bottom: 40rpx; }
|
||||
.toolbar { margin-bottom: var(--space-md); }
|
||||
.summary { color: var(--text-tertiary); font-size: 24rpx; }
|
||||
.mark-all { margin: 0; padding: 0 24rpx; height: 64rpx; line-height: 64rpx; font-size: 24rpx; }
|
||||
.notification-card { margin-bottom: var(--space-md); border-left: 6rpx solid transparent; }
|
||||
.notification-card.unread { border-left-color: var(--primary); }
|
||||
.notification-header { margin-bottom: var(--space-sm); }
|
||||
.notification-title { color: var(--text-primary); font-size: 30rpx; font-weight: 600; flex: 1; }
|
||||
.unread-dot { width: 14rpx; height: 14rpx; margin-left: 16rpx; border-radius: 50%; background: var(--primary); }
|
||||
.notification-body { color: var(--text-secondary); font-size: 26rpx; line-height: 1.6; white-space: pre-wrap; }
|
||||
.notification-footer { margin-top: var(--space-md); }
|
||||
.notification-meta, .notification-time { color: var(--text-tertiary); font-size: 22rpx; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 140rpx 40rpx; }
|
||||
.loading-state { display: flex; align-items: center; justify-content: center; min-height: 400rpx; }
|
||||
.empty-icon { width: 100rpx; height: 100rpx; opacity: 0.6; margin-bottom: 24rpx; }
|
||||
.empty-title { color: var(--text-primary); font-size: 30rpx; font-weight: 600; }
|
||||
.empty-desc { color: var(--text-tertiary); font-size: 24rpx; margin-top: 12rpx; }
|
||||
.load-more { padding: var(--space-lg); color: var(--text-tertiary); text-align: center; font-size: 24rpx; }
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="filter-tabs">
|
||||
<view class="filter-tab-indicator" :style="filterIndicatorStyle"></view>
|
||||
<view
|
||||
v-for="(item, index) in filterOptions"
|
||||
:key="index"
|
||||
@@ -9,13 +10,14 @@
|
||||
@tap="onFilterChange(index)"
|
||||
>
|
||||
{{ item.label }}
|
||||
<view v-if="filterIndex === index" class="tab-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="scroll-container" @scrolltolower="loadMore">
|
||||
<view v-if="orderList.length === 0 && !loading" class="empty-state">
|
||||
<view class="empty-icon">ORD</view>
|
||||
<view v-if="!ordersLoaded || (loading && orderList.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="orderList.length === 0" class="empty-state">
|
||||
<image class="empty-icon" src="/static/order.png" mode="aspectFit" alt="我的订单"></image>
|
||||
<view class="empty-title">暂无订单</view>
|
||||
<view class="empty-desc">当前账号下暂无订单信息</view>
|
||||
</view>
|
||||
@@ -23,11 +25,9 @@
|
||||
<view v-else class="order-list">
|
||||
<view class="order-card" v-for="item in orderList" :key="item.order_id">
|
||||
<view class="card-header">
|
||||
<view class="header-left">
|
||||
<view class="header-row">
|
||||
<text class="field-tag">订单号</text>
|
||||
<text class="order-no">{{ item.order_no }}</text>
|
||||
</view>
|
||||
<view class="order-number" @tap.stop="copyOrderNo(item.order_no)">
|
||||
<text class="order-no">{{ item.order_no || '-' }}</text>
|
||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"></image>
|
||||
</view>
|
||||
<view class="status-badge" :class="getStatusClass(item.payment_status)">
|
||||
{{ item.payment_status_name }}
|
||||
@@ -35,29 +35,26 @@
|
||||
</view>
|
||||
|
||||
<view class="card-body">
|
||||
<view class="info-grid">
|
||||
<view class="info-item">
|
||||
<view class="info-label">套餐名称</view>
|
||||
<view class="info-value">
|
||||
<view class="package-item" v-for="(pkgName, pkgIndex) in item.package_names" :key="pkgIndex">
|
||||
<text class="package-name">{{ pkgName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<view class="info-label">订单金额</view>
|
||||
<view class="info-value amount">¥{{ formatMoney(item.total_amount) }}</view>
|
||||
</view>
|
||||
<view class="info-item">
|
||||
<view class="info-label">下单时间</view>
|
||||
<view class="info-value">{{ item.created_at }}</view>
|
||||
<view class="package-row">
|
||||
<view class="package-content">
|
||||
<view class="package-name">{{ getPackageNamesText(item) }}</view>
|
||||
<view class="package-desc">高速流量 · 全国通用 · 不限速</view>
|
||||
</view>
|
||||
<view class="order-amount">¥{{ formatMoney(item.total_amount) }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="item.payment_status === 1" class="card-footer">
|
||||
<button class="btn-pay" :disabled="orderPayingId !== null" @tap="showOrderPaymentMethods(item)">
|
||||
{{ isOrderPaying(item) ? '处理中...' : '立即支付' }}
|
||||
</button>
|
||||
<view class="card-divider"></view>
|
||||
|
||||
<view class="card-footer">
|
||||
<view class="created-time">下单时间:{{ item.created_at || '-' }}</view>
|
||||
<view class="order-actions">
|
||||
<button v-if="item.payment_status === 1" class="order-action-button primary-action-button"
|
||||
@tap.stop="showOrderPaymentMethods(item)">
|
||||
立即支付
|
||||
</button>
|
||||
<button v-if="item.payment_status === 2" class="order-action-button primary-action-button"
|
||||
@tap.stop="startOrderRenewal(item)">立即续费</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -68,42 +65,34 @@
|
||||
<text v-else>上拉加载更多</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<RenewalPaymentPopup ref="renewalPopupRef" :identifier="userStore.state.identifier"
|
||||
paymentRefreshTarget="order-list" @completed="resetOrderListAndLoad" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { assetApi, orderApi } from '@/api/index.js';
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { onReachBottom, onShow } from '@dcloudio/uni-app';
|
||||
import { orderApi } from '@/api/index.js';
|
||||
import RenewalPaymentPopup from '@/components/RenewalPaymentPopup.vue';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
import {
|
||||
consumePendingPaymentRefresh,
|
||||
handlePaymentError,
|
||||
isValidAlipayPaymentLink,
|
||||
isValidWechatPayConfig,
|
||||
openAlipayPayment,
|
||||
PAYMENT_REFRESH_TARGETS,
|
||||
showPaymentToast,
|
||||
wechatH5Pay
|
||||
PAYMENT_REFRESH_TARGETS
|
||||
} from '@/utils/payment.js';
|
||||
import { formatMoney } from '@/utils/display.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const orderList = reactive([]);
|
||||
const loading = ref(false);
|
||||
const ordersLoaded = ref(false);
|
||||
const noMore = ref(false);
|
||||
const page = ref(1);
|
||||
const orderPayingId = ref(null);
|
||||
const renewalPopupRef = ref(null);
|
||||
const pageSize = 10;
|
||||
const filterIndex = ref(0);
|
||||
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 },
|
||||
@@ -112,10 +101,9 @@
|
||||
{ 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 filterIndicatorStyle = computed(() => ({
|
||||
left: `calc(${filterIndex.value * 20}% + 8rpx)`
|
||||
}));
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const classMap = {
|
||||
@@ -127,24 +115,6 @@
|
||||
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;
|
||||
@@ -166,6 +136,7 @@
|
||||
};
|
||||
|
||||
const onFilterChange = (index) => {
|
||||
if (filterIndex.value === index) return;
|
||||
filterIndex.value = index;
|
||||
resetOrderListAndLoad();
|
||||
};
|
||||
@@ -214,6 +185,7 @@
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
ordersLoaded.value = true;
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
@@ -222,85 +194,39 @@
|
||||
}
|
||||
};
|
||||
|
||||
const isOrderPaying = (order) => orderPayingId.value === order.order_id;
|
||||
const getPackageNamesText = (order) => {
|
||||
const packageNames = Array.isArray(order?.package_names)
|
||||
? order.package_names.filter(Boolean)
|
||||
: [];
|
||||
return packageNames.join('、') || '-';
|
||||
};
|
||||
|
||||
const showOrderPaymentMethods = async (order) => {
|
||||
if (orderPayingId.value !== null || !order?.order_id) return;
|
||||
await loadAssetType();
|
||||
const options = paymentMethodOptions.value;
|
||||
|
||||
uni.showActionSheet({
|
||||
itemList: options.map((item) => item.label),
|
||||
success: ({ tapIndex }) => {
|
||||
const selectedMethod = options[tapIndex]?.value;
|
||||
if (selectedMethod) {
|
||||
handleOrderPayment(order, selectedMethod);
|
||||
}
|
||||
}
|
||||
const copyOrderNo = (orderNo) => {
|
||||
if (!orderNo) return;
|
||||
uni.setClipboardData({
|
||||
data: String(orderNo),
|
||||
success: () => uni.showToast({ title: '订单号已复制', icon: 'success' })
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrderPayment = async (order, paymentMethod) => {
|
||||
if (orderPayingId.value !== null || !order?.order_id) return;
|
||||
|
||||
orderPayingId.value = order.order_id;
|
||||
uni.showLoading({
|
||||
title: '处理中...',
|
||||
mask: true
|
||||
});
|
||||
|
||||
try {
|
||||
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({
|
||||
title: '支付参数获取失败',
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await wechatH5Pay(payData.pay_config);
|
||||
showPaymentToast(true, '支付成功');
|
||||
setTimeout(() => {
|
||||
resetOrderListAndLoad();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
handlePaymentError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidAlipayPaymentLink(payData?.payment_link)) {
|
||||
uni.showToast({
|
||||
title: '支付链接获取失败',
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await openAlipayPayment(payData.payment_link, PAYMENT_REFRESH_TARGETS.ORDER_LIST);
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
console.error('订单支付失败', error);
|
||||
uni.showToast({
|
||||
title: error.msg || error.message || '支付失败,请稍后重试',
|
||||
icon: 'none'
|
||||
});
|
||||
} finally {
|
||||
orderPayingId.value = null;
|
||||
const startOrderRenewal = (order) => {
|
||||
const packageIds = Array.isArray(order?.package_ids) ? order.package_ids.filter(Boolean) : [];
|
||||
if (!packageIds.length) {
|
||||
uni.showToast({ title: '当前订单暂无可续费套餐', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const packageNames = Array.isArray(order.package_names) ? order.package_names : [];
|
||||
renewalPopupRef.value?.open(packageIds, packageNames);
|
||||
};
|
||||
|
||||
const showOrderPaymentMethods = (order) => {
|
||||
if (!order?.order_id) return;
|
||||
const packageNames = Array.isArray(order.package_names) ? order.package_names : [];
|
||||
if (String(order.payment_method || '').toLowerCase() === 'wallet') {
|
||||
renewalPopupRef.value?.payOrderDirectly(order.order_id, 'wallet');
|
||||
return;
|
||||
}
|
||||
renewalPopupRef.value?.openOrderPayment(order.order_id, packageNames);
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
@@ -312,26 +238,26 @@
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadAssetType();
|
||||
loadOrderList();
|
||||
});
|
||||
|
||||
onReachBottom(() => {
|
||||
loadMore();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
position: fixed;
|
||||
top: var(--window-top);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary);
|
||||
max-width: 750rpx;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-top: 0;
|
||||
min-height: 100vh;
|
||||
padding: 0 0 48rpx;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
@@ -340,7 +266,7 @@
|
||||
border-radius: 20rpx;
|
||||
z-index: 100;
|
||||
flex-shrink: 0;
|
||||
margin-top: 20rpx;
|
||||
margin: 20rpx 24rpx 0;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
.tab-item {
|
||||
@@ -365,17 +291,12 @@
|
||||
transform: translateX(-50%);
|
||||
width: 48rpx;
|
||||
height: 6rpx;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: var(--primary);
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -384,10 +305,10 @@
|
||||
padding: 200rpx 40rpx;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 56rpx;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-bottom: 30rpx;
|
||||
opacity: 0.6;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
@@ -451,19 +372,26 @@
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20rpx;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16rpx;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
|
||||
.info-label {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-bottom: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
|
||||
&.amount {
|
||||
color: #ff6b6b;
|
||||
@@ -474,40 +402,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.package-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 24rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
|
||||
.btn-pay {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 20rpx;
|
||||
padding: 16rpx 40rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
.order-action-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.payment-action-buttons {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 16rpx;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
.order-action-button {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -526,8 +442,8 @@
|
||||
}
|
||||
|
||||
&.tag-success {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
background: #eaf6ec;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.tag-secondary {
|
||||
@@ -547,4 +463,296 @@
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 500rpx;
|
||||
}
|
||||
|
||||
/* Screenshot-aligned order list skin */
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 88rpx;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: #f8f9fb;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
padding-top: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0;
|
||||
padding: 0 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 0;
|
||||
border-bottom: 1rpx solid #f0f1f3;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-tabs .tab-item {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 128rpx;
|
||||
padding: 28rpx 12rpx 24rpx;
|
||||
color: #777b84;
|
||||
font-size: 28rpx;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-tabs .tab-item.active {
|
||||
color: var(--primary);
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tabs .tab-line {
|
||||
bottom: 0;
|
||||
width: 42rpx;
|
||||
height: 6rpx;
|
||||
background: var(--primary);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.container .filter-tabs {
|
||||
position: relative;
|
||||
margin-top: 0;
|
||||
padding: 8rpx;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container .filter-tabs .tab-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
padding: 20rpx 4rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 26rpx;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.container .filter-tabs .tab-item.active {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tab-indicator {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
width: calc(20% - 16rpx);
|
||||
height: calc(100% - 16rpx);
|
||||
background: var(--primary);
|
||||
border-radius: 12rpx;
|
||||
transition: left 0.3s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.order-list {
|
||||
padding: 20rpx 24rpx 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
margin-bottom: 20rpx;
|
||||
padding: 0 32rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid #f0f1f3;
|
||||
border-radius: 18rpx;
|
||||
box-shadow: 0 6rpx 18rpx rgba(31, 35, 41, 0.04);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.order-card .card-header {
|
||||
align-items: center;
|
||||
padding: 32rpx 0 24rpx;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.order-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: #777b84;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
max-width: 410rpx;
|
||||
overflow: hidden;
|
||||
color: #777b84;
|
||||
font-size: 26rpx;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
margin-left: 14rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.order-card .card-body {
|
||||
padding: 0 0 28rpx;
|
||||
}
|
||||
|
||||
.package-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.package-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
color: #16181c;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.package-desc {
|
||||
margin-top: 14rpx;
|
||||
color: #8b9099;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
align-self: center;
|
||||
color: #ff302f;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
height: 1rpx;
|
||||
margin: 28rpx 0 20rpx;
|
||||
background: #edf0f2;
|
||||
}
|
||||
|
||||
.order-card .card-footer {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 16rpx;
|
||||
margin-top: 0;
|
||||
padding: 0 0 28rpx;
|
||||
border-top: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.order-card .card-body .card-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 16rpx;
|
||||
margin-top: 0;
|
||||
padding: 0 0 28rpx;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.created-time {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
color: #777b84;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
gap: 16rpx;
|
||||
margin-left: 0;
|
||||
align-self: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.order-card .order-action-button {
|
||||
width: 180rpx;
|
||||
height: 58rpx;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 16rpx;
|
||||
border: 1rpx solid transparent;
|
||||
border-radius: 12rpx;
|
||||
font-size: 25rpx;
|
||||
font-weight: 500;
|
||||
line-height: 56rpx;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&::after { border: none; }
|
||||
}
|
||||
|
||||
.order-card .card-body .card-footer .order-action-button {
|
||||
width: 180rpx;
|
||||
height: 58rpx;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0 16rpx;
|
||||
line-height: 56rpx;
|
||||
}
|
||||
|
||||
.primary-action-button {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.order-card .status-badge {
|
||||
padding: 0 0 0 16rpx;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
font-size: 25rpx;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-card .status-badge.tag-warning { color: #f08a00; }
|
||||
.order-card .status-badge.tag-success { color: var(--primary); }
|
||||
.order-card .status-badge.tag-secondary { color: #7d828b; }
|
||||
.order-card .status-badge.tag-info { color: #666d78; }
|
||||
|
||||
.load-more {
|
||||
padding: 24rpx 32rpx 40rpx;
|
||||
color: #9aa0aa;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view v-if="packageList.length === 0 && !loading" class="empty-state">
|
||||
<view class="empty-icon">PKG</view>
|
||||
<view class="empty-title">暂无可购套餐,请联系客服</view>
|
||||
<view class="empty-desc">当前资产暂无适用的套餐</view>
|
||||
</view>
|
||||
<view class="tab-section">
|
||||
<view class="tab-header">
|
||||
<view class="tab-indicator" :style="{ left: packageTypeIndex === 0 ? '8rpx' : '50%' }"></view>
|
||||
<view
|
||||
v-for="tab in packageTypeTabs"
|
||||
:key="tab.value"
|
||||
class="tab-item"
|
||||
:class="{ active: activePackageType === tab.value }"
|
||||
@tap="switchPackageType(tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card package-card" v-for="item in packageList" :key="item.package_id">
|
||||
<view class="package-header">
|
||||
<view class="package-name">{{ item.package_name }}</view>
|
||||
<view class="tag-apple" :class="item.is_addon ? 'tag-warning' : 'tag-primary'">
|
||||
{{ item.is_addon ? '加油包' : '正式套餐' }}
|
||||
<view class="package-content">
|
||||
<view v-if="!packagesLoaded || (loading && packageList.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
</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 v-else-if="packageList.length === 0" class="empty-state">
|
||||
<image class="empty-icon" src="/static/shop.png" mode="aspectFit" alt="套餐订购"></image>
|
||||
<view class="empty-title">暂无{{ activePackageType === 'formal' ? '正式套餐' : '加油包' }}</view>
|
||||
<view class="empty-desc">当前资产暂无适用的套餐</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 v-else class="package-list">
|
||||
<view class="package-card" v-for="item in packageList" :key="item.package_id">
|
||||
<view class="package-info">
|
||||
<view class="package-name">{{ item.package_name }}</view>
|
||||
<view class="package-meta">
|
||||
<view class="package-meta-item">
|
||||
<image class="package-meta-icon" src="/static/有效期.png" mode="aspectFit" />
|
||||
<text>{{ formatValidity(item.validity_days) }}</text>
|
||||
</view>
|
||||
<view class="package-meta-item">
|
||||
<image class="package-meta-icon" src="/static/流量.png" mode="aspectFit" />
|
||||
<text>{{ formatData(item.data_allowance, item.data_unit) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="package-description">高速流量 · 全国通用 · 不限速</view>
|
||||
</view>
|
||||
<view class="package-side">
|
||||
<view class="package-price"><text class="price-symbol">¥</text>{{ formatPackagePrice(item.retail_price) }}</view>
|
||||
<button class="btn-apple btn-primary package-buy-button" @tap="buyPackage(item)">立即订购</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -44,7 +58,7 @@
|
||||
|
||||
<view class="package-summary">
|
||||
<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="detail-item">
|
||||
<text class="detail-label">套餐流量</text>
|
||||
@@ -53,35 +67,23 @@
|
||||
<view class="detail-divider"></view>
|
||||
<view class="detail-item">
|
||||
<text class="detail-label">有效期</text>
|
||||
<text class="detail-value">{{ currentPackage?.validity_days }} 天</text>
|
||||
<text class="detail-value">{{ formatValidity(currentPackage?.validity_days) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="payment-methods">
|
||||
<view v-if="isDeviceAsset" class="method-item" :class="{ active: paymentMethod === 'wechat' }" @tap="selectPaymentMethod('wechat')">
|
||||
<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">
|
||||
<image class="method-icon" src="/static/wechat.png" mode="aspectFit"></image>
|
||||
<text class="method-name">微信支付</text>
|
||||
<image v-if="method.value === 'alipay'" class="method-icon" src="/static/支付宝支付.png" mode="aspectFit"></image>
|
||||
<image v-else-if="method.value === 'wallet'" class="method-icon" src="/static/wallet.png" mode="aspectFit"></image>
|
||||
<image v-else-if="method.value === 'wechat'" class="method-icon" src="/static/微信支付.png" mode="aspectFit"></image>
|
||||
<text class="method-name">{{ method.label }}<template v-if="method.value === 'wallet'">(¥{{ formatMoney(walletBalance) }})</template></text>
|
||||
</view>
|
||||
<view class="method-radio" :class="{ checked: paymentMethod === 'wechat' }"></view>
|
||||
</view>
|
||||
|
||||
<view 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>
|
||||
</view>
|
||||
<view class="method-radio" :class="{ checked: paymentMethod === 'alipay' }"></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 class="method-radio" :class="{ checked: paymentMethod === method.value }"></view>
|
||||
</view>
|
||||
<view v-if="paymentMethodOptions.length === 0" class="method-empty">暂无可用支付方式</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-footer">
|
||||
@@ -96,9 +98,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { assetApi, orderApi, walletApi } from '@/api/index.js';
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { assetApi, isAssetRealNameCompleted, orderApi, walletApi } from '@/api/index.js';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
import {
|
||||
consumePendingPaymentRefresh,
|
||||
@@ -110,6 +112,8 @@
|
||||
showPaymentToast,
|
||||
wechatH5Pay
|
||||
} from '@/utils/payment.js';
|
||||
import { formatMoney } from '@/utils/display.js';
|
||||
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
@@ -117,36 +121,45 @@
|
||||
const currentPackage = ref(null);
|
||||
const packageList = reactive([]);
|
||||
const loading = ref(false);
|
||||
const packagesLoaded = ref(false);
|
||||
const paymentMethod = ref('alipay');
|
||||
const walletBalance = ref(0);
|
||||
const paySubmitting = ref(false);
|
||||
const isDeviceAsset = ref(true);
|
||||
const assetInfo = ref({});
|
||||
const allowedPaymentMethods = ref([]);
|
||||
const renewalPackageIds = ref([]);
|
||||
const renewalPackageNames = ref([]);
|
||||
const activePackageType = ref('formal');
|
||||
const packageTypeIndex = ref(0);
|
||||
const packageTypeTabs = [
|
||||
{ label: '正式套餐', value: 'formal' },
|
||||
{ label: '加油包', value: 'addon' }
|
||||
];
|
||||
let assetTypePromise = null;
|
||||
|
||||
const formatMoney = (amount) => {
|
||||
if (!amount && amount !== 0) return '0.00';
|
||||
return (amount / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
};
|
||||
let packageRequestId = 0;
|
||||
const paymentMethodOptions = computed(() => getPaymentMethodOptions(allowedPaymentMethods.value));
|
||||
|
||||
const formatData = (allowance, unit) => {
|
||||
if (unit === 'MB') {
|
||||
return allowance >= 1024 ? `${(allowance / 1024).toFixed(0)} GB` : `${allowance} MB`;
|
||||
return allowance >= 1024 ? `${(allowance / 1024).toFixed(0)}GB` : `${allowance}MB`;
|
||||
}
|
||||
return `${allowance} ${unit}`;
|
||||
return `${allowance}${unit}`;
|
||||
};
|
||||
|
||||
const getDefaultPaymentMethod = () => 'alipay';
|
||||
const formatPackagePrice = (amount) => amount === null || amount === undefined ? '-' : formatMoney(amount);
|
||||
|
||||
const formatValidity = (days) => {
|
||||
const value = String(days ?? '').trim();
|
||||
return value && value !== '-' ? `${value}天有效` : '有效期未知';
|
||||
};
|
||||
|
||||
const isPaymentMethodAvailable = (method) => {
|
||||
if (method === 'wallet') return true;
|
||||
if (method === 'wechat') return isDeviceAsset.value;
|
||||
if (method === 'alipay') return true;
|
||||
return false;
|
||||
return paymentMethodOptions.value.some((item) => item.value === method);
|
||||
};
|
||||
|
||||
const normalizePaymentMethod = () => {
|
||||
if (!isPaymentMethodAvailable(paymentMethod.value)) {
|
||||
paymentMethod.value = getDefaultPaymentMethod();
|
||||
paymentMethod.value = getDefaultPaymentMethod(allowedPaymentMethods.value);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,7 +169,8 @@
|
||||
|
||||
assetTypePromise = assetApi.getInfo(userStore.state.identifier)
|
||||
.then((data) => {
|
||||
isDeviceAsset.value = data.asset_type === 'device';
|
||||
assetInfo.value = data;
|
||||
allowedPaymentMethods.value = normalizePaymentMethods(data.allowed_payment_methods);
|
||||
normalizePaymentMethod();
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -169,15 +183,60 @@
|
||||
return assetTypePromise;
|
||||
};
|
||||
|
||||
const loadPackages = async () => {
|
||||
const loadPackages = async (packageType = activePackageType.value) => {
|
||||
const requestId = ++packageRequestId;
|
||||
packagesLoaded.value = false;
|
||||
loading.value = true;
|
||||
packageList.splice(0, packageList.length);
|
||||
try {
|
||||
const data = await assetApi.getPackages(userStore.state.identifier);
|
||||
packageList.splice(0, packageList.length, ...(data.packages || []));
|
||||
const data = await assetApi.getPackages(userStore.state.identifier, packageType);
|
||||
if (requestId !== packageRequestId) return;
|
||||
|
||||
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(() => packageType === 'formal')
|
||||
.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: '-',
|
||||
package_type: 'formal',
|
||||
is_renewal: true
|
||||
}));
|
||||
packageList.splice(0, packageList.length, ...packages, ...renewalOnlyPackages);
|
||||
} catch (error) {
|
||||
if (requestId !== packageRequestId) return;
|
||||
console.error('加载套餐列表失败', error);
|
||||
} finally {
|
||||
if (requestId === packageRequestId) {
|
||||
loading.value = false;
|
||||
packagesLoaded.value = true;
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const switchPackageType = (packageType) => {
|
||||
const nextIndex = packageTypeTabs.findIndex((item) => item.value === packageType);
|
||||
if (nextIndex < 0) return;
|
||||
packageTypeIndex.value = nextIndex;
|
||||
if (packageType === activePackageType.value) return;
|
||||
activePackageType.value = packageType;
|
||||
loadPackages(packageType);
|
||||
};
|
||||
|
||||
const loadWalletBalance = async () => {
|
||||
@@ -189,16 +248,32 @@
|
||||
}
|
||||
};
|
||||
|
||||
const syncPackagePageState = () => {
|
||||
loadAssetType();
|
||||
loadPackages();
|
||||
loadWalletBalance();
|
||||
const syncPackagePageState = async () => {
|
||||
await loadAssetType();
|
||||
await Promise.all([loadPackages(), loadWalletBalance()]);
|
||||
};
|
||||
|
||||
const buyPackage = async (item) => {
|
||||
await loadAssetType();
|
||||
if (assetInfo.value.effective_realname_policy === 'before_order' &&
|
||||
assetInfo.value.realname_required && !isAssetRealNameCompleted(assetInfo.value)) {
|
||||
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;
|
||||
paymentMethod.value = getDefaultPaymentMethod();
|
||||
paymentMethod.value = getDefaultPaymentMethod(allowedPaymentMethods.value);
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
@@ -212,10 +287,16 @@
|
||||
isValidAlipayPaymentLink(paymentData?.payment_link);
|
||||
};
|
||||
|
||||
const handleWechatPay = async (payConfig, isForceRecharge = false) => {
|
||||
const handleWechatPay = async (payConfig, isForceRecharge = false, orderId = null) => {
|
||||
try {
|
||||
await wechatH5Pay(payConfig);
|
||||
showPaymentToast(true, isForceRecharge ? '充值成功,套餐将自动购买' : '支付成功');
|
||||
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 ? '充值成功,套餐将自动购买' : '支付成功');
|
||||
}
|
||||
setTimeout(() => {
|
||||
loadWalletBalance();
|
||||
}, 1500);
|
||||
@@ -224,9 +305,9 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreparedPayment = async (paymentData, isForceRecharge = false) => {
|
||||
const handlePreparedPayment = async (paymentData, isForceRecharge = false, orderId = null) => {
|
||||
if (isValidWechatPayConfig(paymentData?.pay_config)) {
|
||||
await handleWechatPay(paymentData.pay_config, isForceRecharge);
|
||||
await handleWechatPay(paymentData.pay_config, isForceRecharge, orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -242,11 +323,13 @@
|
||||
};
|
||||
|
||||
const getCreateOrderPaymentMethod = () => {
|
||||
if (paymentMethod.value === 'alipay') return 'alipay';
|
||||
if (paymentMethod.value === 'wallet') return 'alipay';
|
||||
return undefined;
|
||||
return paymentMethod.value;
|
||||
};
|
||||
|
||||
const getCreateOrderPackageIds = () => renewalPackageIds.value.length
|
||||
? renewalPackageIds.value
|
||||
: [currentPackage.value.package_id];
|
||||
|
||||
const confirmPay = async () => {
|
||||
if (paySubmitting.value || !currentPackage.value) return;
|
||||
|
||||
@@ -261,7 +344,7 @@
|
||||
try {
|
||||
const orderResult = await orderApi.create(
|
||||
userStore.state.identifier,
|
||||
[currentPackage.value.package_id],
|
||||
getCreateOrderPackageIds(),
|
||||
getCreateOrderPaymentMethod()
|
||||
);
|
||||
|
||||
@@ -356,17 +439,20 @@
|
||||
showModal.value = false;
|
||||
|
||||
if (paymentMethod.value === 'wallet') {
|
||||
uni.showToast({
|
||||
title: '支付成功',
|
||||
icon: 'success'
|
||||
});
|
||||
try {
|
||||
const detail = await orderApi.getDetail(orderResult.order.order_id);
|
||||
const status = detail?.payment_status ?? detail?.order?.payment_status;
|
||||
showPaymentToast(status === 2, status === 2 ? '支付成功' : '支付结果确认中');
|
||||
} catch (error) {
|
||||
showPaymentToast(false, '支付结果确认失败,请稍后查看订单');
|
||||
}
|
||||
setTimeout(() => {
|
||||
loadWalletBalance();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
await handlePreparedPayment(payResult);
|
||||
await handlePreparedPayment(payResult, false, orderResult.order.order_id);
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
showModal.value = false;
|
||||
@@ -416,6 +502,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(() => {
|
||||
syncPackagePageState();
|
||||
});
|
||||
@@ -423,6 +524,35 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 20rpx 40rpx;
|
||||
|
||||
.package-tabs {
|
||||
display: flex;
|
||||
gap: 6rpx;
|
||||
padding: 6rpx;
|
||||
background: var(--gray-100);
|
||||
border: 1rpx solid var(--gray-200);
|
||||
border-radius: 16rpx;
|
||||
|
||||
.package-tab {
|
||||
flex: 1;
|
||||
padding: 16rpx 0;
|
||||
border-radius: 12rpx;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: var(--bg-primary);
|
||||
box-shadow: var(--shadow-small);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -432,10 +562,10 @@
|
||||
min-height: 400rpx;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 60rpx;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-bottom: 30rpx;
|
||||
opacity: 0.6;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
@@ -453,100 +583,93 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
margin-bottom: 0;
|
||||
padding: 28rpx;
|
||||
border: 1rpx solid var(--gray-200);
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.03);
|
||||
|
||||
.package-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 28rpx;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.package-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.package-header-meta {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.package-tag {
|
||||
padding: 6rpx 12rpx;
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
border-radius: var(--radius-small);
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.package-validity {
|
||||
padding: 8rpx 12rpx;
|
||||
border-radius: var(--radius-small);
|
||||
background: rgba(85, 171, 92, 0.1);
|
||||
color: var(--primary-dark);
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 24rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
background: linear-gradient(135deg, rgba(85, 171, 92, 0.08), rgba(85, 171, 92, 0.02));
|
||||
border: 1rpx solid rgba(85, 171, 92, 0.1);
|
||||
border-radius: 18rpx;
|
||||
|
||||
.data-block {
|
||||
flex: 1;
|
||||
padding-right: 20rpx;
|
||||
|
||||
.data-label {
|
||||
font-size: 24rpx;
|
||||
font-size: 23rpx;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.package-data {
|
||||
font-size: 52rpx;
|
||||
font-weight: 800;
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -1rpx;
|
||||
}
|
||||
|
||||
.validity-box {
|
||||
min-width: 132rpx;
|
||||
.price-block {
|
||||
min-width: 150rpx;
|
||||
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);
|
||||
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);
|
||||
|
||||
.price-block {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
color: var(--warning);
|
||||
padding-left: 24rpx;
|
||||
border-left: 1rpx solid rgba(85, 171, 92, 0.18);
|
||||
color: var(--primary-dark);
|
||||
white-space: nowrap;
|
||||
|
||||
.price-symbol {
|
||||
@@ -556,18 +679,258 @@
|
||||
}
|
||||
|
||||
.package-price {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.package-footer {
|
||||
display: block;
|
||||
padding-top: 22rpx;
|
||||
border-top: 1rpx solid var(--gray-100);
|
||||
|
||||
.btn {
|
||||
flex-shrink: 0;
|
||||
min-width: 180rpx;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* Screenshot-aligned package list skin */
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20rpx 24rpx 48rpx;
|
||||
min-height: 100vh;
|
||||
background: #f8f9fb;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
width: calc(50% - 16rpx);
|
||||
height: calc(100% - 16rpx);
|
||||
background: var(--primary);
|
||||
border-radius: 12rpx;
|
||||
transition: left 0.3s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20rpx 0;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: color 0.3s;
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.container .package-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: #f3f5f8;
|
||||
border: 0;
|
||||
border-radius: 22rpx;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container .package-tab {
|
||||
flex: 1;
|
||||
padding: 26rpx 0 24rpx;
|
||||
border-radius: 22rpx;
|
||||
color: #737985;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.container .package-tab.active {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.container .package-tabs {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
padding: 8rpx;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.container .package-tab {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 20rpx 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.package-tab-indicator {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
width: calc(50% - 16rpx);
|
||||
height: calc(100% - 16rpx);
|
||||
background: var(--primary);
|
||||
border-radius: 12rpx;
|
||||
transition: left 0.3s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.package-content {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.package-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.container .package-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
min-height: 210rpx;
|
||||
margin: 0;
|
||||
padding: 30rpx 32rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid #f0f1f3;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 6rpx 18rpx rgba(31, 35, 41, 0.04);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.package-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.package-info .package-name {
|
||||
color: #14161a;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.package-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.package-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
min-width: 0;
|
||||
padding: 10rpx 16rpx;
|
||||
border-radius: 28rpx;
|
||||
background: rgba(85, 171, 92, 0.08);
|
||||
color: var(--primary-dark);
|
||||
font-size: 24rpx;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-meta-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.package-description {
|
||||
margin-top: 18rpx;
|
||||
color: #7c838e;
|
||||
font-size: 25rpx;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.package-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
align-self: stretch;
|
||||
min-width: 190rpx;
|
||||
padding: 4rpx 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.package-side .package-price {
|
||||
color: #ed2524;
|
||||
font-size: 44rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-side .price-symbol {
|
||||
margin-right: 4rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
vertical-align: 10rpx;
|
||||
}
|
||||
|
||||
.package-buy-button {
|
||||
width: 184rpx;
|
||||
height: 64rpx;
|
||||
min-height: 64rpx;
|
||||
padding: 0;
|
||||
border-radius: 14rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 64rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.payment-popup {
|
||||
@@ -603,7 +966,7 @@
|
||||
|
||||
.package-summary {
|
||||
padding: 40rpx 30rpx 30rpx;
|
||||
background: linear-gradient(135deg, rgba(0, 122, 255, 0.05) 0%, rgba(0, 122, 255, 0.02) 100%);
|
||||
background: linear-gradient(135deg, rgba(85, 171, 92, 0.05) 0%, rgba(85, 171, 92, 0.02) 100%);
|
||||
border-bottom: 1rpx solid var(--border-light);
|
||||
text-align: center;
|
||||
|
||||
@@ -629,7 +992,7 @@
|
||||
justify-content: center;
|
||||
gap: 24rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid rgba(0, 122, 255, 0.1);
|
||||
border-top: 1rpx solid rgba(85, 171, 92, 0.1);
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
@@ -677,7 +1040,7 @@
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(0, 122, 255, 0.05);
|
||||
background: rgba(85, 171, 92, 0.05);
|
||||
}
|
||||
|
||||
.method-left {
|
||||
|
||||
@@ -1,47 +1,46 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view v-if="mchList.length === 0 && !loading" class="empty-state">
|
||||
<view class="empty-icon">📡</view>
|
||||
<view v-if="!cardsLoaded || (loading && mchList.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
<view v-else-if="mchList.length === 0" class="empty-state">
|
||||
<image class="empty-icon" src="/static/change.png" mode="aspectFit" alt="切换运营商"></image>
|
||||
<view class="empty-title">运营商列表为空</view>
|
||||
<view class="empty-desc">当前设备暂无可切换的运营商</view>
|
||||
</view>
|
||||
|
||||
<view class="card" v-for="item in mchList" :key="item.iccid">
|
||||
<view class="flex-row-sb mt-30">
|
||||
<view class="flex-row-g20">
|
||||
<view class="logo">
|
||||
<image :src="getCarrier(item.carrier_type).logo" mode="aspectFit"></image>
|
||||
<view class="card carrier-card" v-for="item in mchList" :key="item.iccid">
|
||||
<view class="carrier-main">
|
||||
<view class="logo-stack">
|
||||
<image class="carrier-logo" :src="getCarrier(item.carrier_type).logo" mode="aspectFit"></image>
|
||||
</view>
|
||||
<view class="carrier-details">
|
||||
<view class="carrier-heading">
|
||||
<view class="carrier-name">{{ getCarrier(item.carrier_type).name }}</view>
|
||||
<image class="slot-badge" :src="getSlotIcon(item.slot_position)" mode="aspectFit"></image>
|
||||
<view class="carrier-statuses">
|
||||
<up-tag :type="item.real_name_status === 1 ? 'primary' : 'success'" size="mini">
|
||||
{{ item.real_name_status === 1 ? '已实名' : '未实名' }}
|
||||
</up-tag>
|
||||
<up-tag v-if="item.network_status" type="info" size="mini">{{ item.network_status }}</up-tag>
|
||||
</view>
|
||||
</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">{{ 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 class="carrier-iccid">
|
||||
<text class="iccid-value-text">{{ item.iccid }}</text>
|
||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"
|
||||
@tap.stop="copyIccid(item.iccid)" aria-label="复制ICCID"></image>
|
||||
</view>
|
||||
<view class="card-actions">
|
||||
<button v-if="item.is_current" class="btn-apple btn-primary action-button" disabled>当前使用</button>
|
||||
<button v-else class="btn-apple btn-primary action-button" :disabled="switching" @tap="switchOperator(item)">
|
||||
切换此运营商
|
||||
</button>
|
||||
<button v-if="item.real_name_status !== 1" class="btn-apple btn-secondary action-button" @tap="toReal(item)">
|
||||
去实名
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="btn flex-row-g20 mt-30">
|
||||
<up-button class="btn-apple btn-primary" v-if="item.is_current" type="primary" disabled>
|
||||
当前使用
|
||||
</up-button>
|
||||
<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">
|
||||
@@ -59,36 +58,59 @@
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
import { assetApi, deviceApi, realnameApi } from '@/api/index.js';
|
||||
import { assetApi, deviceApi, hasActiveOrPendingPackage, realnameApi } from '@/api/index.js';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
import slot1Icon from '@/static/卡槽1.jpeg';
|
||||
import slot2Icon from '@/static/卡槽2.jpeg';
|
||||
import slot3Icon from '@/static/卡槽3.jpeg';
|
||||
import cmccLogo from '@/static/中国移动.png';
|
||||
import cuccLogo from '@/static/中国联通.png';
|
||||
import ctccLogo from '@/static/中国电信.png';
|
||||
import cbnLogo from '@/static/中国广电.png';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
let mchList = reactive([]);
|
||||
let loading = ref(false);
|
||||
let cardsLoaded = ref(false);
|
||||
let switching = ref(false);
|
||||
let showIccidModal = ref(false);
|
||||
let currentModalIccid = ref('');
|
||||
let realnameEntryChecking = ref(false);
|
||||
|
||||
const carrierMap = {
|
||||
CMCC: '中国移动',
|
||||
CUCC: '中国联通',
|
||||
CTCC: '中国电信',
|
||||
CBN: '中国广电'
|
||||
CMCC: { name: '中国移动', logo: cmccLogo },
|
||||
CUCC: { name: '中国联通', logo: cuccLogo },
|
||||
CTCC: { name: '中国电信', logo: ctccLogo },
|
||||
CBN: { name: '中国广电', logo: cbnLogo }
|
||||
};
|
||||
|
||||
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 slotIconMap = {
|
||||
1: slot1Icon,
|
||||
2: slot2Icon,
|
||||
3: slot3Icon
|
||||
};
|
||||
|
||||
const getCarrier = (carrierType) => {
|
||||
return {
|
||||
name: carrierMap[carrierType] || '-',
|
||||
logo: carrierLogoMap[carrierType] || carrierLogoMap.CMCC
|
||||
};
|
||||
return carrierMap[carrierType] || { name: '-', logo: '' };
|
||||
};
|
||||
|
||||
const getSlotIcon = (slotPosition) => slotIconMap[Number(slotPosition)] || slotIconMap[1];
|
||||
|
||||
const sortBySlotPosition = (cards) => cards.sort((left, right) => {
|
||||
const leftSlot = Number(left.slot_position);
|
||||
const rightSlot = Number(right.slot_position);
|
||||
const normalizedLeft = Number.isFinite(leftSlot) && leftSlot > 0 ? leftSlot : Number.MAX_SAFE_INTEGER;
|
||||
const normalizedRight = Number.isFinite(rightSlot) && rightSlot > 0 ? rightSlot : Number.MAX_SAFE_INTEGER;
|
||||
return normalizedLeft - normalizedRight;
|
||||
});
|
||||
|
||||
const copyIccid = (value) => {
|
||||
if (!value) return;
|
||||
uni.setClipboardData({
|
||||
data: String(value),
|
||||
success: () => uni.showToast({ title: 'ICCID已复制', icon: 'success' })
|
||||
});
|
||||
};
|
||||
|
||||
const loadCards = async () => {
|
||||
@@ -101,15 +123,17 @@
|
||||
// 是设备,调用设备卡列表接口
|
||||
const data = await deviceApi.getCards(userStore.state.identifier);
|
||||
if (data.cards && data.cards.length > 0) {
|
||||
mchList.splice(0, mchList.length, ...data.cards.map(card => ({
|
||||
const cards = 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,
|
||||
realname_policy: card.realname_policy,
|
||||
network_status: card.network_status,
|
||||
slot_position: card.slot_position
|
||||
})));
|
||||
}));
|
||||
mchList.splice(0, mchList.length, ...sortBySlotPosition(cards));
|
||||
}
|
||||
} else {
|
||||
// 不是设备(单卡),直接使用 asset info 数据,只有一个卡
|
||||
@@ -129,13 +153,27 @@
|
||||
console.error('加载卡列表失败', e);
|
||||
}
|
||||
loading.value = false;
|
||||
cardsLoaded.value = true;
|
||||
};
|
||||
|
||||
const switchOperator = async (item) => {
|
||||
const switchOperator = (item) => {
|
||||
if (switching.value) return;
|
||||
uni.showModal({
|
||||
title: '确认切换',
|
||||
content: `确定要切换到${getCarrier(item.carrier_type).name}吗?切换后预计 3-5 分钟生效。`,
|
||||
cancelText: '取消',
|
||||
confirmText: '确认切换',
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) executeSwitch(item);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const executeSwitch = async (item) => {
|
||||
switching.value = true;
|
||||
try {
|
||||
await deviceApi.switchCard(userStore.state.identifier, item.iccid);
|
||||
uni.showToast({ title: '切换成功,3-5分钟后生效', icon: 'success' });
|
||||
uni.showToast({ title: '切换成功,3-5分钟后生效', icon: 'none' });
|
||||
loadCards();
|
||||
} catch (e) {
|
||||
console.error('切换运营商失败', e);
|
||||
@@ -143,11 +181,52 @@
|
||||
switching.value = false;
|
||||
};
|
||||
|
||||
const toReal = (card) => {
|
||||
currentModalIccid.value = card.iccid;
|
||||
const openRealnameModal = (iccid) => {
|
||||
currentModalIccid.value = iccid;
|
||||
showIccidModal.value = true;
|
||||
};
|
||||
|
||||
const toReal = async (card) => {
|
||||
if (realnameEntryChecking.value) return;
|
||||
if (card.realname_policy !== 'after_order') {
|
||||
openRealnameModal(card.iccid);
|
||||
return;
|
||||
}
|
||||
|
||||
realnameEntryChecking.value = true;
|
||||
uni.showLoading({ title: '校验中...', mask: true });
|
||||
try {
|
||||
const data = await deviceApi.getCards(userStore.state.identifier);
|
||||
const cards = Array.isArray(data?.cards) ? data.cards : [];
|
||||
const currentCard = cards.find(item => item.iccid === card.iccid) || card;
|
||||
const hasRealNamedCard = cards.some(item => Number(item?.real_name_status) === 1);
|
||||
|
||||
if (currentCard.realname_policy !== 'after_order' || hasRealNamedCard ||
|
||||
await hasActiveOrPendingPackage(userStore.state.identifier)) {
|
||||
uni.hideLoading();
|
||||
openRealnameModal(card.iccid);
|
||||
return;
|
||||
}
|
||||
|
||||
uni.hideLoading();
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '当前设备所有卡均未实名,且暂无生效中或待生效套餐。请先订购套餐,再进行实名认证。',
|
||||
confirmText: '去订购',
|
||||
cancelText: '取消',
|
||||
success: ({ confirm }) => {
|
||||
if (confirm) uni.navigateTo({ url: '/pages/package-order/package-order' });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
console.error('校验切换运营商实名条件失败', error);
|
||||
uni.showToast({ title: '实名条件校验失败,请稍后重试', icon: 'none' });
|
||||
} finally {
|
||||
realnameEntryChecking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showIccidModal.value = false;
|
||||
};
|
||||
@@ -182,15 +261,108 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
.card {
|
||||
.logo {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 120rpx;
|
||||
border: 1rpx solid var(--primary);
|
||||
overflow: hidden;
|
||||
image { width: 100%; height: 100%; }
|
||||
}
|
||||
.carrier-card {
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.carrier-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.logo-stack {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
margin-top: 12rpx;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.carrier-logo {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slot-badge {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.carrier-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.carrier-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.carrier-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.carrier-iccid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-top: 14rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.iccid-value-text {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24rpx;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.carrier-statuses {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
width: 100%;
|
||||
margin-top: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 64rpx;
|
||||
padding: 0 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 64rpx;
|
||||
box-sizing: border-box;
|
||||
&::after { border: none; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,8 +464,15 @@
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
min-height: 400rpx;
|
||||
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||
.empty-icon { width: 120rpx; height: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
||||
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 500rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 223 KiB |
BIN
static/back.png
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 266 KiB |
BIN
static/change-shop.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 359 KiB |
BIN
static/clear.png
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 5.3 KiB |
BIN
static/data.png
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 292 KiB |
BIN
static/link.png
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 7.5 KiB |
BIN
static/login.png
|
Before Width: | Height: | Size: 180 KiB |
BIN
static/notification.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
static/order.png
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 244 KiB |
BIN
static/out.png
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.0 KiB |
BIN
static/shop.png
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 208 KiB |
BIN
static/wallet-home.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 90 KiB |
BIN
static/中国广电.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
static/中国电信.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
static/中国移动.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/中国联通.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
static/卡槽1.jpeg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
static/卡槽2.jpeg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
static/卡槽3.jpeg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
static/复制.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
static/微信支付.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
static/支付宝支付.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
static/有效期.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
static/流量.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
9
uni.scss
@@ -13,10 +13,15 @@
|
||||
*/
|
||||
|
||||
/* 颜色变量 */
|
||||
$u-primary: #55ab5c;
|
||||
$u-primary-light: #eaf6ec;
|
||||
$u-primary-dark: #3f8f47;
|
||||
$u-success: #55ab5c;
|
||||
$u-success-light: #eaf6ec;
|
||||
@import 'uview-plus/theme.scss';
|
||||
/* 行为相关颜色 */
|
||||
$uni-color-primary: #007aff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-primary: #55ab5c;
|
||||
$uni-color-success: #55ab5c;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
|
||||
18
utils/display.js
Normal 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
@@ -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 || '';
|
||||
@@ -2,6 +2,7 @@ const ALIPAY_PENDING_REFRESH_KEY = 'pending_alipay_payment_refresh';
|
||||
const ALIPAY_PAYMENT_DATA_KEY = 'pending_alipay_payment_data';
|
||||
|
||||
export const PAYMENT_REFRESH_TARGETS = {
|
||||
HOME: 'home',
|
||||
PACKAGE_ORDER: 'package-order',
|
||||
ORDER_LIST: 'order-list',
|
||||
MY_WALLET: 'my-wallet'
|
||||
|
||||