Compare commits
23 Commits
develop
...
iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c394e2790d | ||
|
|
33f3b37be0 | ||
|
|
f7cb28febf | ||
|
|
131823ca12 | ||
|
|
a7b1156ac1 | ||
|
|
d672dec0d0 | ||
|
|
697ff9c33c | ||
|
|
97dec4dc65 | ||
|
|
da95a914e3 | ||
|
|
77b0e25011 | ||
|
|
dc53295e2c | ||
|
|
65f997cbc3 | ||
|
|
e82b0ab5da | ||
|
|
a81b403cce | ||
|
|
c0cc1ae6dd | ||
|
|
8bd9de15cd | ||
|
|
6a1543f9f6 | ||
|
|
010b941d0e | ||
|
|
a25ea5954c | ||
|
|
8b6ce505b5 | ||
|
|
484a807dce | ||
|
|
6845e97b14 | ||
|
|
189d64c290 |
@@ -3,7 +3,6 @@ name: 构建并部署前端到生产环境
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
|
||||||
- dev
|
- dev
|
||||||
- test
|
- test
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ jobs:
|
|||||||
- name: 设置镜像标签
|
- name: 设置镜像标签
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/iteration/august" ]; then
|
||||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||||
echo "tag=dev" >> $GITHUB_OUTPUT
|
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||||
@@ -52,8 +51,8 @@ jobs:
|
|||||||
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
||||||
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
|
||||||
- name: 部署到本地(仅 develop 分支)
|
- name: 部署到本地(仅 iteration/august 分支)
|
||||||
if: github.ref == 'refs/heads/develop'
|
if: github.ref == 'refs/heads/iteration/august'
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ${{ env.DEPLOY_DIR }}
|
mkdir -p ${{ env.DEPLOY_DIR }}
|
||||||
|
|
||||||
|
|||||||
22
App.vue
22
App.vue
@@ -333,6 +333,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 40rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: var(--gray-100);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
&::after { border: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.btn-group {
|
.btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12rpx;
|
gap: 12rpx;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { deviceApi } from './modules/device.js';
|
|||||||
export { exchangeApi } from './modules/exchange.js';
|
export { exchangeApi } from './modules/exchange.js';
|
||||||
export { orderApi } from './modules/order.js';
|
export { orderApi } from './modules/order.js';
|
||||||
export { notificationApi } from './modules/notification.js';
|
export { notificationApi } from './modules/notification.js';
|
||||||
|
export { popupApi } from './modules/popup.js';
|
||||||
export { realnameApi } from './modules/realname.js';
|
export { realnameApi } from './modules/realname.js';
|
||||||
export { walletApi } from './modules/wallet.js';
|
export { walletApi } from './modules/wallet.js';
|
||||||
export { wechatApi } from './modules/wechat.js';
|
export { wechatApi } from './modules/wechat.js';
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import request from '@/utils/request.js';
|
import request from '@/utils/request.js';
|
||||||
|
import { useAssetStore } from '@/store/asset.js';
|
||||||
|
import { useUserStore } from '@/store/user.js';
|
||||||
|
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
|
||||||
|
|
||||||
export const normalizeAssetInfo = (data = {}) => ({
|
export const normalizeAssetInfo = (data = {}) => ({
|
||||||
...data,
|
...data,
|
||||||
@@ -19,6 +22,24 @@ export const normalizeAssetInfo = (data = {}) => ({
|
|||||||
: data.is_expiring === true || data.is_expiring === 1
|
: data.is_expiring === true || data.is_expiring === 1
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizePackageHistoryNode = (node = {}) => ({
|
||||||
|
...node,
|
||||||
|
children: Array.isArray(node.children)
|
||||||
|
? node.children.filter(child => Number(child?.status) !== 4).map(normalizePackageHistoryNode)
|
||||||
|
: [],
|
||||||
|
expand_by_default: node.expand_by_default === true || node.expand_by_default === 1
|
||||||
|
});
|
||||||
|
|
||||||
|
export const normalizeAssetPackageHistory = (data = {}) => ({
|
||||||
|
...data,
|
||||||
|
items: Array.isArray(data.items)
|
||||||
|
? data.items.filter(node => Number(node?.status) !== 4).map(normalizePackageHistoryNode)
|
||||||
|
: [],
|
||||||
|
page: Number(data.page),
|
||||||
|
size: Number(data.size),
|
||||||
|
total: Number(data.total)
|
||||||
|
});
|
||||||
|
|
||||||
export const isAssetRealNameCompleted = (assetInfo = {}) => {
|
export const isAssetRealNameCompleted = (assetInfo = {}) => {
|
||||||
if (assetInfo.asset_type === 'device') {
|
if (assetInfo.asset_type === 'device') {
|
||||||
return (assetInfo.cards || []).some((card) => Number(card?.real_name_status) === 1);
|
return (assetInfo.cards || []).some((card) => Number(card?.real_name_status) === 1);
|
||||||
@@ -38,11 +59,30 @@ export const hasActiveOrPendingPackage = async (identifier) => {
|
|||||||
|
|
||||||
export const assetApi = {
|
export const assetApi = {
|
||||||
getInfo(identifier) {
|
getInfo(identifier) {
|
||||||
|
identifier = typeof identifier === 'string' ? identifier.trim() : '';
|
||||||
|
if (!identifier) {
|
||||||
|
return Promise.reject({ msg: '缺少资产标识,请重新输入或扫码登录' });
|
||||||
|
}
|
||||||
|
const context = getSessionContext();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
if (userStore.state.loginPending || !context.token) {
|
||||||
|
return Promise.reject({ msg: '登录和微信配置尚未完成,请稍后重试' });
|
||||||
|
}
|
||||||
|
const phoneRevision = userStore.state.phoneRevision;
|
||||||
return request({
|
return request({
|
||||||
url: '/api/c/v1/asset/info',
|
url: '/api/c/v1/asset/info',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
data: { identifier }
|
data: { identifier }
|
||||||
}).then(normalizeAssetInfo);
|
}).then((response) => {
|
||||||
|
const data = normalizeAssetInfo(response);
|
||||||
|
if (identifier === context.identifier && isCurrentSession(context)) {
|
||||||
|
useAssetStore().receiveExpiryReminder(context, data.expiry_reminder);
|
||||||
|
if (phoneRevision === userStore.state.phoneRevision && typeof data.bound_phone === 'string') {
|
||||||
|
userStore.setCustomerPhone(data.bound_phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getPackageHistory(identifier, page, page_size, params = {}) {
|
getPackageHistory(identifier, page, page_size, params = {}) {
|
||||||
@@ -50,7 +90,7 @@ export const assetApi = {
|
|||||||
url: '/api/c/v1/asset/package-history',
|
url: '/api/c/v1/asset/package-history',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
data: { identifier, page, page_size, ...params }
|
data: { identifier, page, page_size, ...params }
|
||||||
});
|
}).then(normalizeAssetPackageHistory);
|
||||||
},
|
},
|
||||||
|
|
||||||
getPackages(identifier, packageType = '') {
|
getPackages(identifier, packageType = '') {
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export const authApi = {
|
|||||||
return request({
|
return request({
|
||||||
url: '/api/c/v1/auth/bind-phone',
|
url: '/api/c/v1/auth/bind-phone',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { phone, code }
|
data: { phone, code },
|
||||||
|
showError: false
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export const notificationApi = {
|
|||||||
markRead(id) {
|
markRead(id) {
|
||||||
return request({
|
return request({
|
||||||
url: `/api/c/v1/notifications/${id}/read`,
|
url: `/api/c/v1/notifications/${id}/read`,
|
||||||
method: 'PUT'
|
method: 'PUT',
|
||||||
|
data: { id }
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export const orderApi = {
|
|||||||
return request({
|
return request({
|
||||||
url: '/api/c/v1/orders/create',
|
url: '/api/c/v1/orders/create',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data
|
data,
|
||||||
|
showError: false
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ export const orderApi = {
|
|||||||
return request({
|
return request({
|
||||||
url: `/api/c/v1/orders/${order_id}/pay`,
|
url: `/api/c/v1/orders/${order_id}/pay`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data
|
data,
|
||||||
|
showError: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
21
api/modules/popup.js
Normal file
21
api/modules/popup.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import request from '@/utils/request.js';
|
||||||
|
|
||||||
|
export const popupApi = {
|
||||||
|
getCandidate(page, identifier) {
|
||||||
|
return request({
|
||||||
|
url: '/api/c/v1/popup-candidates',
|
||||||
|
method: 'GET',
|
||||||
|
data: { page, identifier },
|
||||||
|
// 资源不可见 / 无候选时静默处理,不弹全局错误提示
|
||||||
|
showError: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
submitRiskExchangeAddress(assetId, params) {
|
||||||
|
return request({
|
||||||
|
url: `/api/c/v1/risk-exchanges/${assetId}/address`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { asset_id: assetId, ...params }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -19,7 +19,8 @@ export const walletApi = {
|
|||||||
return request({
|
return request({
|
||||||
url: '/api/c/v1/wallet/recharge',
|
url: '/api/c/v1/wallet/recharge',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data
|
data,
|
||||||
|
showError: false
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<view class="info-row flex-row-sb">
|
<view class="info-row flex-row-sb">
|
||||||
<view class="info-label">
|
<view class="info-label">
|
||||||
<view class="subtitle" v-if="isDevice">IMEI:{{ deviceInfo.imei || '-' }}</view>
|
<view class="subtitle" v-if="isDevice">IMEI:{{ deviceInfo.imei || '-' }}</view>
|
||||||
<view class="subtitle" v-else>手机号:{{ deviceInfo.bound_phone || '-' }}</view>
|
<view class="subtitle" v-else>客户手机号:{{ deviceInfo.bound_phone || '-' }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
134
components/ExpiryReminderPopup.vue
Normal file
134
components/ExpiryReminderPopup.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<view class="expiry-overlay" @tap.stop>
|
||||||
|
<view class="expiry-dialog" :class="`expiry-${reminder.level}`" role="dialog"
|
||||||
|
aria-modal="true" aria-label="套餐临期提醒" @keydown.esc="$emit('close')">
|
||||||
|
<view class="reminder-header">
|
||||||
|
<button class="reminder-close" aria-label="关闭临期提醒" @tap="$emit('close')">×</button>
|
||||||
|
<view class="clock-halo" aria-hidden="true"><view class="clock-face"></view></view>
|
||||||
|
<view class="reminder-title">套餐即将到期</view>
|
||||||
|
<view class="remaining-label">当前资产套餐剩余</view>
|
||||||
|
<view class="remaining-days"><text class="day-number">{{ reminder.remaining_days }}</text><text class="day-unit">天</text></view>
|
||||||
|
</view>
|
||||||
|
<view class="reminder-content">
|
||||||
|
<view class="expiry-details">
|
||||||
|
<text class="expiry-label">预计到期时间</text>
|
||||||
|
<text class="expiry-value">{{ formatShanghaiExpiry(reminder.expires_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="reminder-description">为避免套餐到期影响使用,请及时续费。</view>
|
||||||
|
<button class="reminder-confirm" @tap="$emit('renew')">立即续费</button>
|
||||||
|
<button class="reminder-later" @tap="$emit('close')">我知道了</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { formatShanghaiExpiry } from '@/utils/expiry-reminder.js';
|
||||||
|
defineProps({ reminder: { type: Object, required: true } });
|
||||||
|
defineEmits(['close', 'renew']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.expiry-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(20, 26, 36, 0.48);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.expiry-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640rpx;
|
||||||
|
max-height: calc(100vh - 80rpx);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.reminder-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 40rpx 36rpx 28rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(180deg, var(--reminder-tint), var(--bg-primary));
|
||||||
|
}
|
||||||
|
.reminder-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 20rpx;
|
||||||
|
right: 20rpx;
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 40rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.reminder-close::after, .reminder-confirm::after, .reminder-later::after { border: none; }
|
||||||
|
.clock-halo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 108rpx;
|
||||||
|
height: 108rpx;
|
||||||
|
margin: 28rpx auto 22rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--reminder-tint);
|
||||||
|
}
|
||||||
|
.clock-face {
|
||||||
|
position: relative;
|
||||||
|
width: 50rpx;
|
||||||
|
height: 50rpx;
|
||||||
|
border: 4rpx solid var(--reminder-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.clock-face::before, .clock-face::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 23rpx;
|
||||||
|
top: 10rpx;
|
||||||
|
width: 4rpx;
|
||||||
|
height: 17rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background: var(--reminder-color);
|
||||||
|
}
|
||||||
|
.clock-face::after { top: 23rpx; height: 14rpx; transform: rotate(-55deg); transform-origin: top center; }
|
||||||
|
.reminder-title { color: var(--text-primary); font-size: 38rpx; font-weight: 700; }
|
||||||
|
.remaining-label { margin-top: 20rpx; color: var(--text-secondary); font-size: 26rpx; }
|
||||||
|
.remaining-days { margin-top: 8rpx; color: var(--reminder-color); line-height: 1.2; }
|
||||||
|
.day-number { font-size: 88rpx; font-weight: 700; letter-spacing: -3rpx; font-variant-numeric: tabular-nums; }
|
||||||
|
.day-unit { margin-left: 12rpx; font-size: 28rpx; }
|
||||||
|
.reminder-content { padding: 0 36rpx 36rpx; }
|
||||||
|
.expiry-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
padding: 22rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.expiry-label { font-size: 22rpx; color: var(--text-tertiary); }
|
||||||
|
.expiry-value { font-size: 26rpx; color: var(--text-primary); font-weight: 500; }
|
||||||
|
.reminder-description { margin: 22rpx 0 28rpx; color: var(--text-secondary); font-size: 24rpx; line-height: 1.6; text-align: center; }
|
||||||
|
.reminder-confirm {
|
||||||
|
margin: 0;
|
||||||
|
padding: 24rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--reminder-color);
|
||||||
|
}
|
||||||
|
.expiry-red { --reminder-color: #d94040; --reminder-tint: rgba(217, 64, 64, 0.08); }
|
||||||
|
.reminder-later { margin: 12rpx 0 0; padding: 14rpx; font-size: 24rpx; line-height: 1.5; color: var(--text-tertiary); background: transparent; }
|
||||||
|
.expiry-purple { --reminder-color: #8850c4; --reminder-tint: rgba(136, 80, 196, 0.08); }
|
||||||
|
.expiry-pink { --reminder-color: #ce4878; --reminder-tint: rgba(206, 72, 120, 0.08); }
|
||||||
|
</style>
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
<view class="function-icon">
|
<view class="function-icon">
|
||||||
<image class="function-image icon-bind-phone" 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>
|
||||||
<view :class="['function-name', alreadyBindPhone ? 'text-primary' : 'text-danger']">
|
<view :class="['function-name', assetPhoneBound === true && customerPhone ? 'text-primary' : '']">
|
||||||
{{ alreadyBindPhone ? '已绑定' : '未绑定' }}
|
{{ bindingLabel }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'change-phone')" role="button"
|
<view class="function-item interactive card-interactive" @tap="$emit('enter', 'change-phone')" role="button"
|
||||||
@@ -123,9 +123,13 @@
|
|||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
alreadyBindPhone: {
|
assetPhoneBound: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: null
|
||||||
|
},
|
||||||
|
customerPhone: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
},
|
},
|
||||||
isDevice: {
|
isDevice: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -151,9 +155,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const walletText = computed(() => `钱包 (${formatWalletBalance(props.walletBalance)})`);
|
const walletText = computed(() => `钱包 (${formatWalletBalance(props.walletBalance)})`);
|
||||||
|
const bindingLabel = computed(() => {
|
||||||
|
if (props.customerPhone === '') return '绑定客户号';
|
||||||
|
if (props.assetPhoneBound === false) return '绑定资产号';
|
||||||
|
if (props.assetPhoneBound === true && props.customerPhone) return '资产已绑定';
|
||||||
|
return '绑定手机号';
|
||||||
|
});
|
||||||
|
|
||||||
const handleBindPhone = () => {
|
const handleBindPhone = () => {
|
||||||
if (props.alreadyBindPhone) {
|
if (props.assetPhoneBound === true && props.customerPhone) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit('enter', 'bind');
|
emit('enter', 'bind');
|
||||||
|
|||||||
286
components/PackageHistoryNode.vue
Normal file
286
components/PackageHistoryNode.vue
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<view class="card package-node" :class="{ 'package-node-child': depth > 0, 'relationship-exception': isMasterMissing }">
|
||||||
|
<view class="package-header flex-row-sb">
|
||||||
|
<view class="package-title-wrap">
|
||||||
|
<view class="package-name">{{ node.package_name }}</view>
|
||||||
|
<text v-if="packageTypeName" class="package-type">{{ packageTypeName }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="tag-apple" :class="getStatusClass(node.status)">{{ node.status_name }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isMasterMissing" class="relationship-status">
|
||||||
|
{{ node.relationship_status_name || node.relationship_status }}
|
||||||
|
</view>
|
||||||
|
<view v-if="hasMasterUsageId" class="relationship-meta">
|
||||||
|
关联主套餐使用记录:{{ node.master_usage_id }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="package-info">
|
||||||
|
<view class="info-row flex-row-sb">
|
||||||
|
<view class="info-label">购买时间</view>
|
||||||
|
<view class="info-value">{{ formatDate(node.created_at) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row flex-row-sb">
|
||||||
|
<view class="info-label">激活时间</view>
|
||||||
|
<view class="info-value">{{ formatDate(node.activated_at) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row flex-row-sb">
|
||||||
|
<view class="info-label">到期时间</view>
|
||||||
|
<view class="info-value">{{ formatDate(node.expires_at) }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="divider"></view>
|
||||||
|
|
||||||
|
<view class="flow-info">
|
||||||
|
<view class="flow-title">流量信息</view>
|
||||||
|
<view class="flow-stats">
|
||||||
|
<view class="flow-item">
|
||||||
|
<view class="flow-label">已使用</view>
|
||||||
|
<view class="flow-value">{{ getUsedFlow(node) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="flow-item">
|
||||||
|
<view class="flow-label">总流量</view>
|
||||||
|
<view class="flow-value">{{ getTotalFlow(node) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="flow-item">
|
||||||
|
<view class="flow-label">剩余</view>
|
||||||
|
<view class="flow-value">{{ getRemainFlow(node) }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="progress-section">
|
||||||
|
<view class="progress-apple">
|
||||||
|
<view class="progress-fill" :style="{ width: getUsagePercent(node) }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="hasChildren"
|
||||||
|
class="expand-control"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-expanded="String(expanded)"
|
||||||
|
@tap.stop="toggleExpanded"
|
||||||
|
>
|
||||||
|
<text>{{ expanded ? '收起关联加油包' : '展开关联加油包' }}</text>
|
||||||
|
<text class="expand-count">({{ children.length }})</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="expanded && hasChildren" class="child-list">
|
||||||
|
<PackageHistoryNode
|
||||||
|
v-for="(child, index) in children"
|
||||||
|
:key="getNodeKey(child, index)"
|
||||||
|
:node="child"
|
||||||
|
:depth="depth + 1"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
node: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = computed(() => Array.isArray(props.node.children) ? props.node.children : []);
|
||||||
|
const hasChildren = computed(() => children.value.length > 0);
|
||||||
|
const isMasterMissing = computed(() => props.node.relationship_status === 'master_missing');
|
||||||
|
const hasMasterUsageId = computed(() => (
|
||||||
|
props.node.master_usage_id !== null && props.node.master_usage_id !== undefined
|
||||||
|
));
|
||||||
|
const packageTypeName = computed(() => ({
|
||||||
|
formal: '正式套餐',
|
||||||
|
addon: '加油包'
|
||||||
|
}[props.node.package_type] || props.node.package_type || ''));
|
||||||
|
const expanded = ref(props.node.expand_by_default === true);
|
||||||
|
|
||||||
|
const getStatusClass = (status) => ({
|
||||||
|
0: 'tag-warning',
|
||||||
|
1: 'tag-success',
|
||||||
|
2: 'tag-primary',
|
||||||
|
3: 'tag-secondary',
|
||||||
|
4: 'tag-danger'
|
||||||
|
}[Number(status)] || '');
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMB = (value) => {
|
||||||
|
const mb = toNumber(value);
|
||||||
|
if (mb >= 1024) {
|
||||||
|
return `${(mb / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
return `${mb.toFixed(2)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsedAmount = (item) => item.enable_virtual_data
|
||||||
|
? toNumber(item.virtual_used_mb)
|
||||||
|
: toNumber(item.real_used_mb);
|
||||||
|
|
||||||
|
const getUsedFlow = (item) => formatMB(getUsedAmount(item));
|
||||||
|
const getTotalFlow = (item) => formatMB(item.real_total_mb);
|
||||||
|
const getRemainFlow = (item) => formatMB(Math.max(toNumber(item.real_total_mb) - getUsedAmount(item), 0));
|
||||||
|
|
||||||
|
const getUsagePercent = (item) => {
|
||||||
|
const total = toNumber(item.real_total_mb);
|
||||||
|
if (!total) return '0%';
|
||||||
|
return `${Math.min((getUsedAmount(item) / total) * 100, 100).toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value) => value ? String(value).replace('T', ' ').slice(0, 19) : '-';
|
||||||
|
const getNodeKey = (item, index) => item.package_usage_id ?? `${props.depth}-${index}`;
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
expanded.value = !expanded.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.package-node {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
|
||||||
|
&.package-node-child {
|
||||||
|
margin: var(--space-sm) 0 0;
|
||||||
|
border-left: 6rpx solid var(--primary-light);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.relationship-exception {
|
||||||
|
border-left: 6rpx solid var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-type,
|
||||||
|
.relationship-meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationship-status {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--danger-light, #fff2f0);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationship-meta {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-info .info-row {
|
||||||
|
padding: var(--space-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1rpx;
|
||||||
|
margin: var(--space-md) 0;
|
||||||
|
background: var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-title {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-label {
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-apple {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-control {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-count {
|
||||||
|
margin-left: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-list {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-secondary {
|
||||||
|
background: var(--gray-200) !important;
|
||||||
|
color: var(--gray-600) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
509
components/PopupCandidate.vue
Normal file
509
components/PopupCandidate.vue
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if='visible' class='popup-candidate' @tap.stop>
|
||||||
|
<view class='popup-candidate-card'>
|
||||||
|
<template v-if='isCandidateStep'>
|
||||||
|
<view class='popup-header'>
|
||||||
|
<view class='popup-heading'>{{ candidate?.title || '提示' }}</view>
|
||||||
|
<view class='popup-close' role='button' aria-label='关闭' @tap='dismiss'>×</view>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y class='popup-body'>
|
||||||
|
<text>{{ candidate?.body || '' }}</text>
|
||||||
|
</scroll-view>
|
||||||
|
<view class='popup-actions'>
|
||||||
|
<up-button v-if='isRiskExchange' type='primary' @tap='goToAddress'>申请换卡</up-button>
|
||||||
|
<up-button v-else-if='actionLabel' type='primary' @tap='handleAction'>{{ actionLabel }}</up-button>
|
||||||
|
<up-button plain @tap='dismiss'>稍后处理</up-button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if='isAddressStep'>
|
||||||
|
<view class='popup-header'>
|
||||||
|
<view class='popup-heading'>换货收货信息</view>
|
||||||
|
<view class='popup-close' role='button' aria-label='关闭' @tap='dismiss'>×</view>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y class='popup-address-form'>
|
||||||
|
<view class='form-item'>
|
||||||
|
<view class='form-label'>粘贴收货信息</view>
|
||||||
|
<up-textarea
|
||||||
|
v-model='addressForm.raw_shipping_text'
|
||||||
|
placeholder='可粘贴包含收件人、电话、地址的一整段文字'
|
||||||
|
autoHeight
|
||||||
|
:maxlength='-1'
|
||||||
|
border='surround'
|
||||||
|
/>
|
||||||
|
<view class='form-helper'>识别结果可继续手动修改</view>
|
||||||
|
<view class='paste-action-row'>
|
||||||
|
<button class='btn-apple btn-secondary paste-action-btn' @tap='clearPastedText'>清除</button>
|
||||||
|
<button class='btn-apple btn-secondary paste-action-btn' @tap='fillFromPastedText'>识别并填充</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class='form-item'>
|
||||||
|
<view class='form-label'>收件人姓名</view>
|
||||||
|
<up-input v-model='addressForm.recipient_name' placeholder='请输入收件人姓名' border='surround' maxlength='50'/>
|
||||||
|
</view>
|
||||||
|
<view class='form-item'>
|
||||||
|
<view class='form-label'>收件人电话</view>
|
||||||
|
<up-input v-model='addressForm.recipient_phone' type='number' placeholder='请输入11位手机号' border='surround' maxlength='11'/>
|
||||||
|
</view>
|
||||||
|
<view class='form-item'>
|
||||||
|
<view class='form-label'>所在地区</view>
|
||||||
|
<view class='region-trigger' @tap='openAreaPicker'>
|
||||||
|
<text class='region-trigger-text' :class='{ placeholder: !regionDisplayText }'>
|
||||||
|
{{ regionDisplayText || '请选择省 / 市 / 区' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class='form-item'>
|
||||||
|
<view class='form-label'>详细地址</view>
|
||||||
|
<up-textarea
|
||||||
|
v-model='addressForm.recipient_address_detail'
|
||||||
|
placeholder='请输入详细收货地址'
|
||||||
|
autoHeight
|
||||||
|
:maxlength='-1'
|
||||||
|
border='surround'
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<view class='popup-address-actions'>
|
||||||
|
<up-button plain @tap='dismiss'>取消</up-button>
|
||||||
|
<up-button type='primary' :loading='submitting' :disabled='submitting' @tap='submitAddress'>提交</up-button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if='isDoneStep'>
|
||||||
|
<view class='popup-header'>
|
||||||
|
<view class='popup-heading'>申请已提交</view>
|
||||||
|
</view>
|
||||||
|
<view class='result-item'>
|
||||||
|
<view class='result-label'>换货单号</view>
|
||||||
|
<view class='result-value'>{{ exchange?.exchange_no || '-' }}</view>
|
||||||
|
</view>
|
||||||
|
<view class='result-item'>
|
||||||
|
<view class='result-label'>状态</view>
|
||||||
|
<view class='result-value'>{{ exchange?.status_name || '-' }}</view>
|
||||||
|
</view>
|
||||||
|
<view v-if='exchange?.recipient_address' class='result-item'>
|
||||||
|
<view class='result-label'>收货地址</view>
|
||||||
|
<view class='result-value'>{{ exchange.recipient_address }}</view>
|
||||||
|
</view>
|
||||||
|
<view class='popup-actions'>
|
||||||
|
<up-button type='primary' @tap='finish'>完成</up-button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<up-picker
|
||||||
|
:show='showAreaPicker'
|
||||||
|
:columns='areaPickerColumns'
|
||||||
|
keyName='label'
|
||||||
|
title='选择所在地区'
|
||||||
|
:defaultIndex='areaPickerDefaultIndex'
|
||||||
|
closeOnClickOverlay
|
||||||
|
@confirm='confirmAreaPicker'
|
||||||
|
@change='handleAreaPickerChange'
|
||||||
|
@cancel='showAreaPicker = false'
|
||||||
|
@close='showAreaPicker = false'
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { popupApi, notificationApi } from '@/api/index.js';
|
||||||
|
import { handledPopupNotificationIds } from '@/utils/popup.js';
|
||||||
|
import {
|
||||||
|
buildRegionColumns,
|
||||||
|
composeRecipientAddress,
|
||||||
|
getRegionDisplayText,
|
||||||
|
parseShippingText
|
||||||
|
} from '@/utils/shippingAddress.js';
|
||||||
|
|
||||||
|
const PHONE_REG = /^1[3-9]\d{9}$/;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
page: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
identifier: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const step = ref('candidate');
|
||||||
|
const candidate = ref(null);
|
||||||
|
const exchange = ref(null);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const showAreaPicker = ref(false);
|
||||||
|
const areaPickerColumns = ref([]);
|
||||||
|
const areaPickerDefaultIndex = ref([0, 0, 0]);
|
||||||
|
const addressForm = reactive({
|
||||||
|
raw_shipping_text: '',
|
||||||
|
recipient_name: '',
|
||||||
|
recipient_phone: '',
|
||||||
|
recipient_region_codes: [],
|
||||||
|
recipient_address_detail: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCandidateStep = computed(() => step.value === 'candidate');
|
||||||
|
const isAddressStep = computed(() => step.value === 'address');
|
||||||
|
const isDoneStep = computed(() => step.value === 'done');
|
||||||
|
const isRiskExchange = computed(() => candidate.value?.popup_type === 'risk_exchange');
|
||||||
|
const regionDisplayText = computed(() => getRegionDisplayText(addressForm.recipient_region_codes));
|
||||||
|
|
||||||
|
const actionLabelMap = {
|
||||||
|
package_purchase: '立即购买',
|
||||||
|
asset_wallet_recharge: '立即充值'
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionRouteMap = {
|
||||||
|
package_purchase: '/pages/package-order/package-order',
|
||||||
|
asset_wallet_recharge: '/pages/my-wallet/my-wallet'
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionLabel = computed(() => actionLabelMap[candidate.value?.action_type] || '');
|
||||||
|
|
||||||
|
const goToAddress = () => {
|
||||||
|
step.value = 'address';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
addressForm.raw_shipping_text = '';
|
||||||
|
addressForm.recipient_name = '';
|
||||||
|
addressForm.recipient_phone = '';
|
||||||
|
addressForm.recipient_region_codes = [];
|
||||||
|
addressForm.recipient_address_detail = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncAreaPickerState = (regionCodes = []) => {
|
||||||
|
const { columns, defaultIndex } = buildRegionColumns(regionCodes);
|
||||||
|
areaPickerColumns.value = columns;
|
||||||
|
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAreaPicker = () => {
|
||||||
|
syncAreaPickerState(addressForm.recipient_region_codes);
|
||||||
|
showAreaPicker.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAreaPickerChange = (event) => {
|
||||||
|
const selectedValues = event.value || [];
|
||||||
|
const selectedProvinceCode = selectedValues[0]?.value || '';
|
||||||
|
const selectedCityCode = event.columnIndex === 0 ? '' : selectedValues[1]?.value || '';
|
||||||
|
const selectedDistrictCode = event.columnIndex === 2 ? selectedValues[2]?.value || '' : '';
|
||||||
|
const { columns, defaultIndex } = buildRegionColumns([
|
||||||
|
selectedProvinceCode,
|
||||||
|
selectedCityCode,
|
||||||
|
selectedDistrictCode
|
||||||
|
]);
|
||||||
|
areaPickerColumns.value = columns;
|
||||||
|
areaPickerDefaultIndex.value = defaultIndex.map((index) => (index < 0 ? 0 : index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAreaPicker = ({ value }) => {
|
||||||
|
const selectedCodes = (value || []).map((item) => item?.value).filter(Boolean);
|
||||||
|
if (selectedCodes.length === 3) {
|
||||||
|
addressForm.recipient_region_codes = selectedCodes;
|
||||||
|
syncAreaPickerState(selectedCodes);
|
||||||
|
}
|
||||||
|
showAreaPicker.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillFromPastedText = () => {
|
||||||
|
if (!addressForm.raw_shipping_text.trim()) {
|
||||||
|
uni.showToast({ title: '请先粘贴收货信息', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseShippingText(addressForm.raw_shipping_text);
|
||||||
|
let updatedCount = 0;
|
||||||
|
if (parsed.recipient_name) {
|
||||||
|
addressForm.recipient_name = parsed.recipient_name;
|
||||||
|
updatedCount += 1;
|
||||||
|
}
|
||||||
|
if (parsed.recipient_phone) {
|
||||||
|
addressForm.recipient_phone = parsed.recipient_phone;
|
||||||
|
updatedCount += 1;
|
||||||
|
}
|
||||||
|
if (parsed.regionCodes.length === 3) {
|
||||||
|
addressForm.recipient_region_codes = parsed.regionCodes;
|
||||||
|
syncAreaPickerState(parsed.regionCodes);
|
||||||
|
updatedCount += 1;
|
||||||
|
}
|
||||||
|
if (parsed.detailAddress) {
|
||||||
|
addressForm.recipient_address_detail = parsed.detailAddress;
|
||||||
|
updatedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatedCount) {
|
||||||
|
uni.showToast({ title: '未识别到有效收货信息', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.showToast({ title: '已识别并填充', icon: 'success' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPastedText = () => {
|
||||||
|
addressForm.raw_shipping_text = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const markPopupRead = async () => {
|
||||||
|
const id = candidate.value?.notification_id;
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await notificationApi.markRead(id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('标记弹窗通知已读失败', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
visible.value = false;
|
||||||
|
showAreaPicker.value = false;
|
||||||
|
markPopupRead();
|
||||||
|
emit('close', 'dismissed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = () => {
|
||||||
|
const url = actionRouteMap[candidate.value?.action_type];
|
||||||
|
visible.value = false;
|
||||||
|
markPopupRead();
|
||||||
|
emit('close', 'action');
|
||||||
|
if (url) {
|
||||||
|
uni.navigateTo({ url });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAddress = async () => {
|
||||||
|
if (submitting.value) return;
|
||||||
|
|
||||||
|
const recipientName = addressForm.recipient_name.trim();
|
||||||
|
const recipientPhone = addressForm.recipient_phone.trim();
|
||||||
|
const detailAddress = addressForm.recipient_address_detail.trim();
|
||||||
|
if (!recipientName) {
|
||||||
|
uni.showToast({ title: '请输入收件人姓名', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!recipientPhone) {
|
||||||
|
uni.showToast({ title: '请输入收件人电话', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!PHONE_REG.test(recipientPhone)) {
|
||||||
|
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (addressForm.recipient_region_codes.length !== 3) {
|
||||||
|
uni.showToast({ title: '请选择省市区', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!detailAddress) {
|
||||||
|
uni.showToast({ title: '请输入详细收货地址', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const recipientAddress = composeRecipientAddress(addressForm.recipient_region_codes, detailAddress);
|
||||||
|
|
||||||
|
const assetId = Number(candidate.value?.asset_id);
|
||||||
|
if (!Number.isInteger(assetId) || assetId <= 0) {
|
||||||
|
uni.showToast({ title: '资产信息无效', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
exchange.value = await popupApi.submitRiskExchangeAddress(assetId, {
|
||||||
|
recipient_name: recipientName,
|
||||||
|
recipient_phone: recipientPhone,
|
||||||
|
recipient_address: recipientAddress
|
||||||
|
});
|
||||||
|
await markPopupRead();
|
||||||
|
step.value = 'done';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('提交风险换卡收货地址失败', e);
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
visible.value = false;
|
||||||
|
showAreaPicker.value = false;
|
||||||
|
emit('close', 'done');
|
||||||
|
};
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
onUnmounted(() => { mounted = false; });
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!props.identifier) {
|
||||||
|
emit('close', 'empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await popupApi.getCandidate(props.page, props.identifier);
|
||||||
|
if (!mounted) return;
|
||||||
|
const item = (data && data.candidate) || null;
|
||||||
|
if (!item || !item.notification_id || handledPopupNotificationIds.has(item.notification_id)) {
|
||||||
|
emit('close', 'empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handledPopupNotificationIds.add(item.notification_id);
|
||||||
|
candidate.value = item;
|
||||||
|
step.value = 'candidate';
|
||||||
|
resetForm();
|
||||||
|
visible.value = true;
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 资源不可见(400/code=1180)与接口异常统一静默关闭,不做 UI 分支
|
||||||
|
console.error('查询弹窗候选失败', e);
|
||||||
|
emit('close', 'invisible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang='scss'>
|
||||||
|
.popup-candidate {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1100;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-candidate-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640rpx;
|
||||||
|
max-height: calc(100vh - 80rpx);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-height: 420rpx;
|
||||||
|
padding: 12rpx 32rpx 24rpx;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 20rpx 32rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-helper {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-address-form {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-height: 60vh;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-action-row,
|
||||||
|
.popup-address-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-action-row {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
|
||||||
|
.paste-action-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 84rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
border: 2rpx solid var(--border-light);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-trigger-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&.placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-address-actions {
|
||||||
|
padding: 16rpx 32rpx 32rpx;
|
||||||
|
|
||||||
|
:deep(.u-button) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 8rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-value {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 28rpx;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
<image v-else-if="method.value === 'wechat'" 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>
|
||||||
<text class="method-name">{{ method.label }}</text>
|
<text class="method-name">{{ method.label }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="method-radio" :class="{ checked: paymentMethod === method.value }"></view>
|
<text v-if="operationMode === 'order-payment'" class="method-locked-label">订单支付方式</text>
|
||||||
|
<view v-else class="method-radio" :class="{ checked: paymentMethod === method.value }"></view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="paymentMethodOptions.length === 0" class="method-empty">暂无可用支付方式</view>
|
<view v-if="paymentMethodOptions.length === 0" class="method-empty">暂无可用支付方式</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -41,18 +42,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { assetApi, isAssetRealNameCompleted, orderApi } from '@/api/index.js';
|
import { assetApi, isAssetRealNameCompleted, orderApi } from '@/api/index.js';
|
||||||
import {
|
import {
|
||||||
handlePaymentError,
|
handlePaymentError,
|
||||||
isValidAlipayPaymentLink,
|
isValidAlipayPaymentLink,
|
||||||
isValidWechatPayConfig,
|
isValidWechatPayConfig,
|
||||||
|
markPendingPaymentRefresh,
|
||||||
openAlipayPayment,
|
openAlipayPayment,
|
||||||
PAYMENT_REFRESH_TARGETS,
|
PAYMENT_REFRESH_TARGETS,
|
||||||
showPaymentToast,
|
showPaymentToast,
|
||||||
wechatH5Pay
|
wechatH5Pay
|
||||||
} from '@/utils/payment.js';
|
} from '@/utils/payment.js';
|
||||||
import { formatMoney } from '@/utils/display.js';
|
import { formatMoney } from '@/utils/display.js';
|
||||||
|
import { getPaymentErrorMessage, isUnknownPaymentResult } from '@/utils/payment-attempt.js';
|
||||||
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -60,8 +63,9 @@
|
|||||||
paymentRefreshTarget: { type: String, default: PAYMENT_REFRESH_TARGETS.ORDER_LIST }
|
paymentRefreshTarget: { type: String, default: PAYMENT_REFRESH_TARGETS.ORDER_LIST }
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['completed']);
|
const emit = defineEmits(['completed', 'visibility-change']);
|
||||||
const show = ref(false);
|
const show = ref(false);
|
||||||
|
watch(show, (value) => emit('visibility-change', value), { flush: 'sync' });
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const packageIds = ref([]);
|
const packageIds = ref([]);
|
||||||
const packageNames = ref([]);
|
const packageNames = ref([]);
|
||||||
@@ -144,10 +148,11 @@
|
|||||||
show.value = true;
|
show.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openOrderPayment = async (orderId, names = []) => {
|
const openOrderPayment = (orderId, names = [], method = '') => {
|
||||||
if (!orderId) return;
|
if (!orderId) return;
|
||||||
if (!props.identifier) {
|
const selectedMethod = normalizePaymentMethods([String(method || '').trim().toLowerCase()])[0];
|
||||||
uni.showToast({ title: '未找到当前资产', icon: 'none' });
|
if (!selectedMethod) {
|
||||||
|
uni.showToast({ title: '订单支付方式无效,请刷新订单后重试', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,22 +161,13 @@
|
|||||||
renewalPrice.value = null;
|
renewalPrice.value = null;
|
||||||
packageIds.value = [];
|
packageIds.value = [];
|
||||||
packageNames.value = Array.isArray(names) ? names.filter(Boolean) : [];
|
packageNames.value = Array.isArray(names) ? names.filter(Boolean) : [];
|
||||||
try {
|
allowedPaymentMethods.value = [selectedMethod];
|
||||||
await loadAssetInfo();
|
paymentMethod.value = selectedMethod;
|
||||||
} 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;
|
show.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectPaymentMethod = (method) => {
|
const selectPaymentMethod = (method) => {
|
||||||
|
if (operationMode.value === 'order-payment') return;
|
||||||
if (paymentMethodOptions.value.some((item) => item.value === method)) {
|
if (paymentMethodOptions.value.some((item) => item.value === method)) {
|
||||||
paymentMethod.value = method;
|
paymentMethod.value = method;
|
||||||
}
|
}
|
||||||
@@ -277,7 +273,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
uni.showLoading({ title: '准备支付...', mask: true });
|
uni.showLoading({ title: '准备支付...', mask: true });
|
||||||
const payResult = await orderApi.pay(orderId, paymentMethod.value);
|
// Prefer payment data returned by order creation. The fallback keeps
|
||||||
|
// compatibility with older backends without a duplicate preparation
|
||||||
|
// request when the new contract is available.
|
||||||
|
const payResult = hasPreparedPaymentData(orderResult) ||
|
||||||
|
(paymentMethod.value === 'wallet' && orderResult.order.payment_status === 2)
|
||||||
|
? orderResult
|
||||||
|
: await orderApi.pay(orderId, paymentMethod.value);
|
||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
show.value = false;
|
show.value = false;
|
||||||
|
|
||||||
@@ -290,7 +292,10 @@
|
|||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
show.value = false;
|
show.value = false;
|
||||||
console.error('创建续费订单或支付失败', error);
|
console.error('创建续费订单或支付失败', error);
|
||||||
uni.showToast({ title: error.msg || error.message || '操作失败,请稍后重试', icon: 'none' });
|
if (isUnknownPaymentResult(error)) {
|
||||||
|
markPendingPaymentRefresh(props.paymentRefreshTarget);
|
||||||
|
}
|
||||||
|
uni.showToast({ title: getPaymentErrorMessage(error, '操作失败,请稍后重试'), icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -333,7 +338,10 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
console.error('订单支付失败', error);
|
console.error('订单支付失败', error);
|
||||||
uni.showToast({ title: error.msg || error.message || '支付失败,请稍后重试', icon: 'none' });
|
if (isUnknownPaymentResult(error)) {
|
||||||
|
markPendingPaymentRefresh(props.paymentRefreshTarget);
|
||||||
|
}
|
||||||
|
uni.showToast({ title: getPaymentErrorMessage(error), icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -353,7 +361,10 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
console.error('直接支付订单失败', error);
|
console.error('直接支付订单失败', error);
|
||||||
uni.showToast({ title: error.msg || error.message || '支付失败,请稍后重试', icon: 'none' });
|
if (isUnknownPaymentResult(error)) {
|
||||||
|
markPendingPaymentRefresh(props.paymentRefreshTarget);
|
||||||
|
}
|
||||||
|
uni.showToast({ title: getPaymentErrorMessage(error), icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -464,6 +475,7 @@
|
|||||||
|
|
||||||
.method-badge-alipay { background: #1677ff; }
|
.method-badge-alipay { background: #1677ff; }
|
||||||
.method-name { color: var(--text-primary); font-size: 28rpx; }
|
.method-name { color: var(--text-primary); font-size: 28rpx; }
|
||||||
|
.method-locked-label { color: var(--text-tertiary); font-size: 24rpx; white-space: nowrap; }
|
||||||
|
|
||||||
.method-radio {
|
.method-radio {
|
||||||
width: 36rpx;
|
width: 36rpx;
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
|
|
||||||
// 加载套餐流量数据
|
// 加载套餐流量数据
|
||||||
const loadPackageData = async () => {
|
const loadPackageData = async () => {
|
||||||
|
if (userStore.state.loginPending || !userStore.state.token) return;
|
||||||
const identifier = props.identifier || userStore.state.identifier;
|
const identifier = props.identifier || userStore.state.identifier;
|
||||||
if (!identifier) return;
|
if (!identifier) return;
|
||||||
|
|
||||||
@@ -151,8 +152,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 监听 identifier 变化
|
// 监听 identifier 变化
|
||||||
watch(() => props.identifier || userStore.state.identifier, (newVal) => {
|
watch(() => [props.identifier || userStore.state.identifier, userStore.state.token, userStore.state.loginPending], ([newVal, token, loginPending]) => {
|
||||||
if (newVal) {
|
if (newVal && token && !loginPending) {
|
||||||
loadPackageData();
|
loadPackageData();
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
|||||||
@@ -4,13 +4,11 @@
|
|||||||
<view class="user-details flex-col-g8">
|
<view class="user-details flex-col-g8">
|
||||||
<view class="info-copy-row">
|
<view class="info-copy-row">
|
||||||
<view class="title">{{ currentCardNo || '-' }}</view>
|
<view class="title">{{ currentCardNo || '-' }}</view>
|
||||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"
|
<button class="copy-button" @tap.stop="copyText(currentCardNo, '号码')" aria-label="复制号码">复制</button>
|
||||||
@tap.stop="copyText(currentCardNo, '号码')" aria-label="复制号码"></image>
|
|
||||||
</view>
|
</view>
|
||||||
<view v-if="!isDevice" class="info-copy-row">
|
<view v-if="!isDevice" class="info-copy-row">
|
||||||
<view class="caption">ICCID:{{ deviceInfo.iccid || '-' }}</view>
|
<view class="caption">ICCID:{{ deviceInfo.iccid || '-' }}</view>
|
||||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"
|
<button class="copy-button" @tap.stop="copyText(deviceInfo.iccid, 'ICCID')" aria-label="复制ICCID">复制</button>
|
||||||
@tap.stop="copyText(deviceInfo.iccid, 'ICCID')" aria-label="复制ICCID"></image>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="caption">套餐名称:{{ deviceInfo.packageName || '-' }}</view>
|
<view class="caption">套餐名称:{{ deviceInfo.packageName || '-' }}</view>
|
||||||
<view class="caption" :class="{ 'expiry-warning': isExpiring }">
|
<view class="caption" :class="{ 'expiry-warning': isExpiring }">
|
||||||
@@ -25,8 +23,8 @@
|
|||||||
{{ networkStatus == 1 ? '正常' : '停机' }}
|
{{ networkStatus == 1 ? '正常' : '停机' }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<up-button v-if="hasPackageName" class="renew-button" type="primary" size="small"
|
<!-- <up-button v-if="hasPackageName" class="renew-button" type="primary" size="small"
|
||||||
@tap.stop="emit('renew')">立即续费</up-button>
|
@tap.stop="emit('renew')">立即续费</up-button> -->
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -77,13 +75,6 @@
|
|||||||
gap: 12rpx;
|
gap: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-icon {
|
|
||||||
width: 28rpx;
|
|
||||||
height: 28rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.renew-button {
|
.renew-button {
|
||||||
margin: 24rpx 0 0;
|
margin: 24rpx 0 0;
|
||||||
width: 180rpx;
|
width: 180rpx;
|
||||||
|
|||||||
335
docs/10月份修改/第1批-8-9月迭代前端对接说明.md
Normal file
335
docs/10月份修改/第1批-8-9月迭代前端对接说明.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# 第 1 批(8-9 月迭代缺陷修复)前端对接说明
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 日期 | 2026-10-12 |
|
||||||
|
| 范围 | 分支 `Iteration/8-11` 上第 1 批 12 项(M1~M6、M8、M9、M12~M14、M16、M23、F1) |
|
||||||
|
| 用途 | 前端工程师对照修改:哪些接口变了、怎么变的、前端要做什么 |
|
||||||
|
| 接口细节以 | `docs/admin-openapi.yaml`(已重新生成)为准;本文来自代码与该文档的核对 |
|
||||||
|
| 环境 | 已部署测试环境,未上生产 |
|
||||||
|
|
||||||
|
> 路径前缀:后台接口为 `/api/admin/...`,H5(C 端)接口为 `/api/c/v1/...`。
|
||||||
|
> 所有改动都向后兼容:只新增字段 / 枚举值,**唯一的删除字段是 M16 的 `real_data_mb`**;唯一的校验收紧是密码规则与 CSV 表头。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、前端必须改动的清单(速查)
|
||||||
|
|
||||||
|
| # | 项 | 端 | 动作 | 优先级 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | M3 订单详情/列表 | 后台 | 加 3 个字段;详情加支付单列表 | 必改 |
|
||||||
|
| 2 | M8、M14 新状态「审批提交失败」 | 后台 + H5 | 5+1 个业务的状态枚举、筛选、标签、按钮显隐 | 必改 |
|
||||||
|
| 3 | M1 密码规则 | 后台 + H5 | 7 处设置密码的页面校验与提示文案 | 必改 |
|
||||||
|
| 4 | M12 换货被拒 | 后台 | 读取错误响应 `data.phone` 拼接提示 | 必改 |
|
||||||
|
| 5 | F1 绑定手机号 | H5 | 登录响应新增 2 个布尔字段,绑定流程判断改变 | 必改 |
|
||||||
|
| 6 | M9 临期提醒 | H5 | 资产详情新增 `expiry_reminder` 弹窗;通知中心已按资产过滤 | 必改 |
|
||||||
|
| 7 | M16 预警页面 | 后台 | 规则页去掉流量额度;预警列表加阈值流量 | 必改 |
|
||||||
|
| 8 | M2 批量换绑业务员 | 后台 | CSV 模板表头改名(若前端内置模板/文案) | 视情况 |
|
||||||
|
| 9 | M23 设备导入 | 后台 | 导入说明文案:IMEI 必填 | 文案 |
|
||||||
|
| 10 | M4~M6 导出补列 | — | 前端无需改动 | 无 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、逐项说明
|
||||||
|
|
||||||
|
### 1. M3 后台订单显示 C 端在线支付信息
|
||||||
|
|
||||||
|
**涉及接口**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 变化 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/admin/orders` | 每条订单新增 3 个字段 |
|
||||||
|
| POST | `/api/admin/orders` | 返回体同为 `OrderResponse`,同样带 3 个字段 |
|
||||||
|
| GET | `/api/admin/orders/{id}` | 3 个字段 + 新增 `payments` 数组(响应类型变为 `OrderDetailResponse`) |
|
||||||
|
|
||||||
|
**新增字段(均始终返回,无值为空串)**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `third_party_trade_no` | string | 交易流水号 |
|
||||||
|
| `payment_channel` | string | 支付渠道:`wechat` 微信直连 v3 / `wechat_v2` 微信直连 v2 / `fuiou` 富友 |
|
||||||
|
| `payment_merchant_name` | string | 收款商户名称 |
|
||||||
|
|
||||||
|
- 只有「个人客户 + 微信或支付宝 + 已支付(含之后已退款)」的订单有值;代理购买、钱包、线下、未支付订单一律为空串。
|
||||||
|
|
||||||
|
**详情新增 `payments`(订单详情专有,列表不返回)**
|
||||||
|
|
||||||
|
仅个人客户微信/支付宝订单有数据,其余为 `[]`;按创建时间倒序(最新在前)。每项:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `payment_no` | 支付单号 |
|
||||||
|
| `payment_method` | `wechat` / `alipay` |
|
||||||
|
| `payment_channel` | 同上 |
|
||||||
|
| `payment_merchant_name` | 商户名称 |
|
||||||
|
| `third_party_trade_no` | 交易流水号(未支付为空串) |
|
||||||
|
| `amount` | 金额(分) |
|
||||||
|
| `status` / `status_name` | 0 待支付 / 1 已支付 / 2 已失败 / 3 已退款 |
|
||||||
|
| `created_at` / `paid_at` / `expire_at` | 创建 / 支付 / 过期时间(后两者可为 null) |
|
||||||
|
|
||||||
|
**前端要做**:订单列表、详情加三个字段列;详情页加「支付记录」表格(可看出「付了钱没成功」、重复支付、换过支付方式)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. M8、M14 新状态「审批提交失败」(影响 6 个业务)
|
||||||
|
|
||||||
|
企微审批没能提交成功时,业务单不再卡在「审批中」,而是变为**终态**「审批提交失败」。各业务新增状态值如下(**数值各业务不同,不要共用一个枚举**):
|
||||||
|
|
||||||
|
| 业务 | 新状态值 | 字段 | 涉及接口 | 重新发起方式 |
|
||||||
|
|---|:-:|---|---|---|
|
||||||
|
| 核销申请(员工代收款) | **4** | `status` / `application_status` | `GET/POST /api/admin/employee-collection-applications`、`GET/PUT /api/admin/employee-collection-applications/{id}`、`GET /api/admin/employee-collection-bills/{id}` | `PUT /api/admin/employee-collection-applications/{id}`(重新提交,生成新审批) |
|
||||||
|
| 退款 | **7** | `status` | `GET/POST /api/admin/refunds`、`GET /api/admin/refunds/{id}`、`/{id}/trigger-approval`、`/{id}/offline-settlement` | `POST /api/admin/refunds/{id}/resubmit` |
|
||||||
|
| 扫码注册代理 | **3** | `status` | `GET /api/admin/agent-distribution-registrations`、`.../{id}`;H5 `POST /api/c/v1/agent-distribution-registrations` | 申请人重新申请(走原申请入口,生成新的注册申请) |
|
||||||
|
| 线下充值(代理自充) | **7** | `status` | `GET/POST /api/admin/agent-recharges`、`.../{id}`、`/{id}/offline-pay`、`/{id}/payment-status`、`/{id}/trigger-approval` | 重新发起(后端没有对原单的单独接口,前端调 `POST /api/admin/agent-recharges` 新建线下充值单) |
|
||||||
|
| 佣金提现 | **5** | `status` | `GET /api/admin/commission/withdrawal-requests`、`/{id}/approve`、`/{id}/reject`;`GET /api/admin/shops/{shop_id}/withdrawal-requests` | `PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}`(重新提交,按新金额重新冻结) |
|
||||||
|
| 提现资料资格 | **4** | `status` | `GET/POST /api/admin/shops/{shop_id}/withdrawal-qualifications` | 重新提交资料版本 |
|
||||||
|
|
||||||
|
**列表筛选参数的取值范围同步放宽**:
|
||||||
|
- 退款 `status` 查询:`min=1,max=6` → `max=7`
|
||||||
|
- 佣金提现 `status`:`max=4` → `max=5`
|
||||||
|
- 核销 `status`:`oneof 0 1 2 3` → 加 `4`
|
||||||
|
- 扫码注册代理 `status`:`oneof 0 1 2` → 加 `3`
|
||||||
|
- 提现资料资格 `status`:`oneof 0 1 2 3` → 加 `4`
|
||||||
|
- 线下充值 `status` 过滤:说明增加 `7`
|
||||||
|
|
||||||
|
**行为变化(前端按钮显隐)**
|
||||||
|
- 状态为「审批提交失败」的单据**不能再点「恢复审批」**(接口会拒绝):
|
||||||
|
- `POST /api/admin/refunds/{id}/recover-approval`
|
||||||
|
- `POST /api/admin/employee-collection-applications/{id}/recover-approval`
|
||||||
|
- `POST /api/admin/agent-distribution-registrations/{id}/recover-approval`
|
||||||
|
只显示「重新发起」。
|
||||||
|
- 「提交结果未知」不是失败,仍显示「恢复审批」,由后台任务确认。
|
||||||
|
- 佣金提现变为 5 后,**冻结金额已自动释放**;重新发起时重新冻结。
|
||||||
|
- 核销申请:释放预占金额,账单可再次核销。
|
||||||
|
|
||||||
|
**核销申请额外变化(M8)**:`approval_submission_status` 以前恒为空,现在返回真实值:
|
||||||
|
`0 待提交 / 1 提交中 / 2 已提交 / 3 提交失败 / 4 结果未知`(无审批上下文为 0)。列表与详情都有。
|
||||||
|
|
||||||
|
**前端要做**:
|
||||||
|
1. 6 处状态枚举/标签/颜色补充新值(建议红色或灰色,标注「终态」);
|
||||||
|
2. 列表筛选下拉加该选项;
|
||||||
|
3. 操作按钮:该状态隐藏「恢复审批」,保留「重新发起」;
|
||||||
|
4. 核销申请列表/详情展示「审批提交状态」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. M1 登录密码统一规则
|
||||||
|
|
||||||
|
**新规则(后端强制)**:长度 **8~12 位**;大写字母、小写字母、数字、特殊符号**至少包含三类**;不能有空格和中文。
|
||||||
|
例:`Abc12345` ✅,`abcd1234` ❌(只有两类)。
|
||||||
|
|
||||||
|
**涉及的 7 个入口**
|
||||||
|
|
||||||
|
| 场景 | 方法与路径 | 字段 | 旧规则 → 新规则 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 本人修改密码 | `PUT /api/auth/password` | `new_password` | 8~32 → 8~12 + 三类 |
|
||||||
|
| 创建后台账号 | `POST /api/admin/accounts` | `password` | 8~32 → 8~12 + 三类 |
|
||||||
|
| 修改账号 | `PUT /api/admin/accounts/{id}` | `password`(可选) | 同上 |
|
||||||
|
| 修改账号密码 | `PUT /api/admin/accounts/{id}/password` | `new_password` | 同上 |
|
||||||
|
| 创建店铺(含初始账号) | `POST /api/admin/shops` | `init_password` | 8~32 → 8~12 + 三类 |
|
||||||
|
| 创建企业客户 | `POST /api/admin/enterprises` | `password` | 6~20 → 8~12 + 三类 |
|
||||||
|
| 修改企业密码 | `PUT /api/admin/enterprises/{id}/password` | `password` | 6~20 → 8~12 + 三类 |
|
||||||
|
| 扫码注册代理(H5) | `POST /api/c/v1/agent-distribution-registrations` | `password` | 6~64 → 8~12 + 三类 |
|
||||||
|
|
||||||
|
**错误提示变化**:修改密码以前只返回「参数验证失败」,现在是字段级提示,直接展示 `message` 即可,例如:
|
||||||
|
- `修改密码参数不合法:新密码长度不能小于 8`
|
||||||
|
- `修改密码参数不合法:密码须包含大写字母、小写字母、数字、特殊符号中的至少三类`
|
||||||
|
|
||||||
|
**不变**:已在用的旧密码照常登录,不强制改;超级管理员操作密码、WiFi 密码不受影响。
|
||||||
|
|
||||||
|
**前端要做**:上述页面的前端校验、输入框 `maxlength`、占位/提示文案同步为新规则;错误提示直接显示后端返回的 `message`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. M12 换货:新资产已绑手机号时被拒
|
||||||
|
|
||||||
|
**涉及接口**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 场景 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/admin/exchanges` | 创建换货单 |
|
||||||
|
| POST | `/api/admin/exchanges/{id}/complete` | 物流换货确认完成 |
|
||||||
|
|
||||||
|
**变化**
|
||||||
|
- 旧逻辑「新资产被人扫过码就拒绝」**取消**。
|
||||||
|
- 新逻辑:新资产存在**有效手机号关联**才拒绝,错误码 **1202**,`message`:
|
||||||
|
`新资产已绑定手机号,请先在后台解除该资产的手机号绑定,再重新提交换货申请`
|
||||||
|
- 完整手机号放在错误响应的 **`data.phone`**(message 与日志中都不含号码)。
|
||||||
|
- `complete` 接口被拒时,换货单停在「已发货待确认」,解除后可重新确认。
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1202,
|
||||||
|
"msg": "新资产已绑定手机号,请先在后台解除该资产的手机号绑定,再重新提交换货申请",
|
||||||
|
"data": { "phone": "138xxxxxxxx" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端要做**:捕获 1202 时读取 `data.phone`,弹窗提示「新资产已绑定手机号 {phone},请先到『手机号资产关联』解除后再提交」,并可跳转/引导到解除入口。解除使用已有接口(无新增):
|
||||||
|
- `DELETE /api/admin/phone-asset-associations/{id}`(单条)
|
||||||
|
- `POST /api/admin/phone-asset-associations/batch-unbind`(按资产批量)
|
||||||
|
- `POST /api/admin/phone-asset-associations/unbind-imports`(CSV)
|
||||||
|
- `GET /api/admin/phone-asset-associations`(查询)
|
||||||
|
|
||||||
|
**换货完成后的副作用(供前端提示参考)**:旧资产的手机号关联和客户绑定都被清空,原客户已打开的旧资产页面会失效,需重新扫码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. F1 一个资产只能绑一个手机号
|
||||||
|
|
||||||
|
**涉及接口(H5)**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 变化 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/c/v1/auth/wechat-login` | 响应新增 2 个字段,`need_bind_phone` 语义改变 |
|
||||||
|
| POST | `/api/c/v1/auth/miniapp-login` | 同上 |
|
||||||
|
| POST | `/api/c/v1/auth/bind-phone` | 资产已绑其他号时返回 **1192** |
|
||||||
|
| POST | `/api/c/v1/auth/change-phone` | 只换客户手机号,不再连带改资产 |
|
||||||
|
|
||||||
|
**登录响应新增字段**
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `asset_phone_bound` | 当前资产是否已绑定手机号(不返回号码) |
|
||||||
|
| `asset_phone_is_own` | 资产绑定的号码是否为当前客户自己的号(不返回号码) |
|
||||||
|
| `need_bind_phone`(语义变更) | 强制绑定开关开启时:客户无手机号,**或**当前资产尚无有效手机号关联 → `true`;开关关闭恒为 `false`。仅前端提示用 |
|
||||||
|
|
||||||
|
**四种场景对应的前端流程**
|
||||||
|
|
||||||
|
| 客户 | 资产 | 前端应该 |
|
||||||
|
|---|---|---|
|
||||||
|
| 无号 | 无号 | 验证一次手机号,客户和资产都写入这个号 |
|
||||||
|
| 有号 | 无号 | 要求给资产绑号,可以和客户的号不同 |
|
||||||
|
| 无号 | 已有号 | 只要求客户绑自己的号,资产不动 |
|
||||||
|
| 有号 | 已有号 | 不用绑 |
|
||||||
|
|
||||||
|
**新错误**:`bind-phone` 资产已绑定其他号码时,返回 **1192**「该资产已绑定手机号,如需更换请联系管理员」(不含号码)。前端提示用户联系管理员,不要重试。手机号已被其他客户绑定的 **1184** 不变。
|
||||||
|
|
||||||
|
**前端要做**:绑定页根据 `asset_phone_bound` / `asset_phone_is_own` / `need_bind_phone` 决定是否展示绑定入口;处理 1192;客户换号后不要假设资产号码跟着变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. M9 套餐临期提醒:按资产、扫码进入时触发
|
||||||
|
|
||||||
|
**涉及接口(H5)**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 变化 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/c/v1/asset/info` | 新增可选字段 `expiry_reminder` |
|
||||||
|
| GET | `/api/c/v1/notifications` | 临期提醒只返回当前资产的(入参不变) |
|
||||||
|
| GET | `/api/c/v1/notifications/unread-count` | 未读数只计当前资产的临期提醒 |
|
||||||
|
| POST | `/api/c/v1/notifications/read-all` | 其他资产的临期提醒保持未读 |
|
||||||
|
|
||||||
|
**`expiry_reminder`(可选,无则不返回该字段)**
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `remaining_days` | 剩余自然日(0~15,按上海时区) |
|
||||||
|
| `expires_at` | 预计最终到期时间 |
|
||||||
|
| `level` | `red`(0~3 天)/ `purple`(4~7 天)/ `pink`(8~15 天) |
|
||||||
|
| `level_name` | 等级中文名 |
|
||||||
|
|
||||||
|
**规则**:同一客户、同一资产、同一天(北京时间)**只返回一次**;返回的同时后端已写一条通知。当天再次进入不再返回。
|
||||||
|
|
||||||
|
**前端要做**:资产详情页判断到 `expiry_reminder` 存在时弹窗/横幅提醒;不要用旧的「每日推送的临期通知」做弹窗依据(已不再给个人客户发);通知中心无需改,后端已按当前资产过滤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. M16 真流量预警页面口径整理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 变化 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/admin/package-traffic-alert-rules` | 规则列表**删除字段 `real_data_mb`** |
|
||||||
|
| GET | `/api/admin/package-traffic-alert-rules/{id}` | 规则详情**删除字段 `real_data_mb`** |
|
||||||
|
| GET | `/api/admin/package-traffic-alerts` | 预警列表新增 `threshold_mb` |
|
||||||
|
| GET | `/api/admin/package-traffic-alerts/{id}` | 预警详情新增 `threshold_mb` |
|
||||||
|
| 导出 | 预警导出任务 | 新增列「阈值流量」,放在**最后一列** |
|
||||||
|
|
||||||
|
- `threshold_mb`(float,保留两位小数)= 额度快照 × 阈值百分比 ÷ 100,口径为「主套餐 + 加油包」。
|
||||||
|
- `used_mb`、`limit_mb` 字段不变,说明改为「主套餐与加油包汇总」。
|
||||||
|
- 规则页现在只返回:`id`、`package_id`、`package_name`、`threshold_percent`、`enabled`、`enabled_name`、`remark`、`updated_at`。
|
||||||
|
|
||||||
|
**前端要做**:规则列表/详情去掉「套餐流量额度」列及相关引用(否则显示 `undefined`);预警列表/详情加「阈值流量(MB)」;已用/总量列标题可注明「含加油包」。
|
||||||
|
|
||||||
|
> 注意:`/api/admin/packages` 等套餐接口里的 `real_data_mb` 是套餐本身字段,**没有变化**,不要误删。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. M2 批量换绑业务员 CSV 表头改名
|
||||||
|
|
||||||
|
| 方法 | 路径 |
|
||||||
|
|---|---|
|
||||||
|
| POST | `/api/admin/shops/business-owner-imports` |
|
||||||
|
|
||||||
|
- CSV 固定表头改为:`店铺编码`、`操作类型`、**`平台登录账号`**、`备注`(原为「业务员登录账号」)。
|
||||||
|
- **「平台登录账号」这一列只填手机号**;填用户名会失败,原因提示「请填写手机号」。
|
||||||
|
- 旧表头整批失败(提示表头不符),不做新旧并存。
|
||||||
|
- 失败原因分开:格式不对、手机号不存在、不是平台用户、账号已禁用、匹配到多个账号。
|
||||||
|
- 接口路径、请求体不变;逐行独立处理,成功行与失败行互不影响。
|
||||||
|
|
||||||
|
**前端要做**:如果前端内置/下载模板或有填写说明,改为新表头并注明「填写平台用户的登录手机号」;失败明细按后端返回的原因原样展示即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. M23 设备导入 IMEI 必填
|
||||||
|
|
||||||
|
| 方法 | 路径 |
|
||||||
|
|---|---|
|
||||||
|
| POST | `/api/admin/devices/import` |
|
||||||
|
|
||||||
|
- 请求结构、模板列名/列数/列序**都不变**。
|
||||||
|
- `imei` 为空(含只填空格)的行**导入失败**,失败原因「设备IMEI(imei)不能为空」,出现在任务详情的 `failed_items`(带行号)。
|
||||||
|
- 同一文件内 IMEI 齐全的行照常导入;整份文件都失败时任务状态为「失败」。
|
||||||
|
- 只校验非空,不校验格式与重复。
|
||||||
|
|
||||||
|
**前端要做**:导入页说明文案标注「IMEI 必填」;任务详情照常展示 `failed_items`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. M4~M6 三类导出补列(前端无需改动)
|
||||||
|
|
||||||
|
| 导出 | 新增 |
|
||||||
|
|---|---|
|
||||||
|
| 订单导出 | 最后一列「操作人」 |
|
||||||
|
| IoT 卡导出 | 最后一列「套餐到期时间」(算不准留空) |
|
||||||
|
| 设备导出 | 流量表头注明「(MB)」,最后一列「设备合计(GB)」 |
|
||||||
|
|
||||||
|
只有**新建**的导出任务带新列,历史任务重新下载仍是旧表头,不会错位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. M13:企微拒绝/撤销/删除后退款单结束(后端修复,前端关注点)
|
||||||
|
|
||||||
|
**无接口字段变化**。修复后,企微侧「拒绝/撤销/删除」会让退款单正确变为「已拒绝」(status = 3),资产可再换货。
|
||||||
|
|
||||||
|
**前端关注**:
|
||||||
|
- 不支持在企微里「重新提交」(企微新审批单卡管不关联);已拒绝的退款单请在卡管用 `POST /api/admin/refunds/{id}/resubmit` 重新发起。
|
||||||
|
- 如页面有提示「可在企微重新提交」之类文案,请去掉。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、新增 / 变化的错误码
|
||||||
|
|
||||||
|
| 码 | 接口 | 含义 | 前端处理 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1202 | `POST /api/admin/exchanges`、`.../{id}/complete` | 新资产已绑定手机号 | 读 `data.phone`,引导去后台解除 |
|
||||||
|
| 1192 | `POST /api/c/v1/auth/bind-phone` | 资产已绑定其他手机号 | 提示联系管理员,不重试 |
|
||||||
|
| 参数不合法 | 密码相关 7 个入口 | 带字段级原因的 `message` | 直接展示 `message` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、联调自测建议
|
||||||
|
|
||||||
|
1. 订单:用 C 端微信已支付订单看详情,`payments` 有记录、三个字段与支付单一致;代理/钱包订单三个字段为空串、`payments=[]`。
|
||||||
|
2. 状态枚举:造一个提交失败的单据(缺必填控件映射可复现),检查 6 类业务列表、筛选、按钮显隐。
|
||||||
|
3. 密码:`Abc12345` 通过;`abcd1234`、`Abc1234`(7 位)、`Abc123456789X`(13 位)被拒且提示文案可读。
|
||||||
|
4. 换货:对已绑手机号的资产发起换货,应弹出含号码的提示;后台解除后重新提交成功。
|
||||||
|
5. H5:同一客户绑定两个临期资产,进入 A 只看到 A 的提醒,当天再进不再出现。
|
||||||
|
6. 预警:规则页无流量额度;预警列表有阈值流量。
|
||||||
|
|
||||||
|
## 五、不需要前端改动的内容
|
||||||
|
|
||||||
|
- C 端订单接口、H5 退款接口无变化。
|
||||||
|
- 预警判定逻辑、套餐接口(`real_data_mb` 在套餐里仍存在)无变化。
|
||||||
|
- M13 无字段变化;M4~M6 导出由后端新增列。
|
||||||
1754
docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md
Normal file
1754
docs/2026-10-26-轮询按运营商账户限流-前端接口变更说明.md
Normal file
File diff suppressed because it is too large
Load Diff
97
docs/API.md
97
docs/API.md
@@ -376,7 +376,102 @@ data:
|
|||||||
- wallet_balance:钱包余额(分)
|
- wallet_balance:钱包余额(分)
|
||||||
|
|
||||||
|
|
||||||
## 3.2 资产套餐历史
|
## 3.2 资产套餐历史(当前契约)
|
||||||
|
|
||||||
|
URL:
|
||||||
|
GET /api/c/v1/asset/package-history
|
||||||
|
|
||||||
|
更新说明(2026-09-08):
|
||||||
|
* 套餐历史已改为主套餐—加油包关系组。`items` 只包含顶层关系组;关联加油包位于所属主项的 `children` 中,子项绝不会跨页返回。
|
||||||
|
* `total` 为筛选后的顶层关系组数量,分页壳使用 `page`、`size`、`total`;其中 `size` 表示每页顶层关系组数量。
|
||||||
|
* 传入 `status` 和 `package_type` 时,两项必须由同一条使用记录联合命中;主项或任一子项命中时,接口均返回完整关系组,客户端不得对组内节点二次过滤。
|
||||||
|
* 加油包通过 `master_usage_id` 标识关联主套餐使用记录。关联主套餐物理缺失时,节点以独立顶层项返回并设置 `relationship_status: "master_missing"`;关联主套餐存在但读取或展示失败时,接口按统一错误响应返回失败。
|
||||||
|
|
||||||
|
Query 参数:
|
||||||
|
- identifier(string,必填)- 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN),长度 1–50
|
||||||
|
- package_type(string,可选)- 套餐类型(formal:正式套餐;addon:加油包)
|
||||||
|
- status(integer,可选)- 套餐状态(0:待生效;1:生效中;2:已用完;3:已过期;4:已失效)
|
||||||
|
- page(integer,必填)- 页码,最小为 1
|
||||||
|
- page_size(integer,必填)- 每页顶层关系组数量,范围 1–100
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"timestamp": "2026-09-08T00:00:00Z",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"activated_at": "2026-09-01T00:00:00Z",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"children": [],
|
||||||
|
"expand_by_default": false,
|
||||||
|
"master_usage_id": 5001,
|
||||||
|
"package_id": 1002,
|
||||||
|
"package_name": "5GB加油包",
|
||||||
|
"package_type": "addon",
|
||||||
|
"package_usage_id": 5002,
|
||||||
|
"status": 1,
|
||||||
|
"status_name": "生效中"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-09-01T00:00:00Z",
|
||||||
|
"enable_virtual_data": false,
|
||||||
|
"expand_by_default": true,
|
||||||
|
"expires_at": "2026-09-30T23:59:59Z",
|
||||||
|
"master_usage_id": null,
|
||||||
|
"order_id": 0,
|
||||||
|
"package_id": 1001,
|
||||||
|
"package_name": "10GB月套餐",
|
||||||
|
"package_type": "formal",
|
||||||
|
"package_usage_id": 5001,
|
||||||
|
"priority": 1,
|
||||||
|
"real_total_mb": 10240,
|
||||||
|
"real_used_mb": 2048,
|
||||||
|
"reduction_pct": 0,
|
||||||
|
"status": 1,
|
||||||
|
"status_name": "生效中",
|
||||||
|
"usage_type": "single_card",
|
||||||
|
"virtual_total_mb": 10240,
|
||||||
|
"virtual_used_mb": 2048
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"size": 10,
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`data.items[]` 为递归节点,所有节点均可包含以下字段:
|
||||||
|
- activated_at(date-time,可空):激活时间
|
||||||
|
- children(array):关联加油包或下级关联节点
|
||||||
|
- created_at(date-time):购买创建时间
|
||||||
|
- enable_virtual_data(boolean):是否启用虚流量
|
||||||
|
- expand_by_default(boolean):是否默认展开 `children`
|
||||||
|
- expires_at(date-time,可空):到期时间
|
||||||
|
- master_usage_id(integer,可空):关联主套餐使用记录 ID;普通主项为 `null`
|
||||||
|
- order_id(integer):历史兼容字段,始终输出零值 `0`,不填充真实订单 ID
|
||||||
|
- package_id、package_name、package_type、package_usage_id:套餐及使用记录标识;`package_type` 为 `formal` 或 `addon`
|
||||||
|
- priority:优先级
|
||||||
|
- real_total_mb、real_used_mb:真实总量和真实已用量(MB)
|
||||||
|
- virtual_total_mb、virtual_used_mb:业务停机阈值和展示已用量(MB)
|
||||||
|
- reduction_pct:展示增幅比例
|
||||||
|
- status、status_name:套餐状态及名称
|
||||||
|
- usage_type:使用类型(single_card/device)
|
||||||
|
- relationship_status、relationship_status_name:关系异常状态及名称;仅在主套餐物理缺失时返回 `master_missing`
|
||||||
|
|
||||||
|
错误响应:
|
||||||
|
- HTTP 400:请求参数错误
|
||||||
|
- HTTP 401:未认证或认证已过期
|
||||||
|
- HTTP 403:无权访问
|
||||||
|
- HTTP 500:服务器内部错误,包括关联主套餐存在但读取或展示失败
|
||||||
|
|
||||||
|
以上失败场景均使用统一 `ErrorResponse`:`code`、`msg`、`timestamp`,可选 `data`。H5 通过共享请求层展示后端返回的 `msg`;401 同时清除登录状态并跳转登录页。
|
||||||
|
|
||||||
|
## 3.2 资产套餐历史(旧版,已废弃)
|
||||||
|
|
||||||
URL:
|
URL:
|
||||||
GET /api/c/v1/asset/package-history
|
GET /api/c/v1/asset/package-history
|
||||||
|
|||||||
1779
docs/产品迭代8月份/11.md
Normal file
1779
docs/产品迭代8月份/11.md
Normal file
File diff suppressed because it is too large
Load Diff
15
docs/产品迭代8月份/支付.md
Normal file
15
docs/产品迭代8月份/支付.md
Normal file
File diff suppressed because one or more lines are too long
28
openspec/changes/add-h5-popup-candidate-flow/proposal.md
Normal file
28
openspec/changes/add-h5-popup-candidate-flow/proposal.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The August iteration adds backend-driven H5 popups. `GET /api/c/v1/popup-candidates` returns at most one candidate (risk-exchange or operation) for the current page and asset; risk-exchange candidates require the user to submit a shipping address through `POST /api/c/v1/risk-exchanges/{asset_id}/address`. Popups are persisted as personal notifications, so the existing notification contracts change: list items gain `popup_snapshot` and two new `type` values, unread count includes popups, and a popup is only dismissed by marking its notification read (`PUT /api/c/v1/notifications/{id}/read`). The current H5 client-side priority re-sort contradicts the new fixed backend ordering and must be removed.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Query `GET /api/c/v1/popup-candidates` once per page entry on `home` (index), `package_purchase` (package-order), and `asset_wallet_recharge` (my-wallet); `asset_detail` is explicitly out of scope
|
||||||
|
- Render the returned candidate (title/body) in a shared `PopupCandidate` component and keep the popup silent when no candidate exists
|
||||||
|
- Map `action_type` with a frontend whitelist only: `package_purchase` -> package-order page, `asset_wallet_recharge` -> my-wallet page; empty value shows no action
|
||||||
|
- Dismiss or act on a popup only by marking the notification read through `PUT /api/c/v1/notifications/{id}/read`
|
||||||
|
- Risk-exchange popups show a shipping address form and submit it with the candidate `asset_id` (never derived from the H5 identifier); success shows the exchange number and status
|
||||||
|
- Treat invisible assets (HTTP 400 / `code=1180`) uniformly as resource-invisible with no UI branching
|
||||||
|
- Keep the backend-fixed notification ordering (remove client-side priority re-sort); honor the unread badge contract; include the notification id in mark-read requests
|
||||||
|
- Never use `read-all` inside the popup display chain
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `h5-popup-candidate`: Define once-per-entry popup candidate query, rendering, whitelist action mapping, and read-mark dismissal
|
||||||
|
- `h5-risk-exchange-address`: Define the risk-exchange shipping address form and idempotent submission
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `personal-notification-list`: Align the personal notification list/unread contracts with popup notifications
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected code: new `api/modules/popup.js`, new `components/PopupCandidate.vue`, `pages/index/index.vue`, `pages/package-order/package-order.vue`, `pages/my-wallet/my-wallet.vue`, `pages/notifications/notifications.vue`, `api/modules/notification.js`
|
||||||
|
- Backend contracts: C-end popup/risk-exchange/notification endpoints only; admin endpoints are out of scope
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: H5 SHALL query popup candidates once per page entry
|
||||||
|
The system SHALL call `GET /api/c/v1/popup-candidates` exactly once per page entry with the current page and asset identifier, and SHALL render the returned candidate or stay silent when none exists. The query SHALL NOT be prefetched, polled, or repeated inside one entry.
|
||||||
|
|
||||||
|
#### Scenario: Candidate exists on page entry
|
||||||
|
- **WHEN** the user enters `home`, `package_purchase`, or `asset_wallet_recharge` and the backend returns a candidate
|
||||||
|
- **THEN** the system SHALL show the candidate title/body popup
|
||||||
|
|
||||||
|
#### Scenario: No candidate on page entry
|
||||||
|
- **WHEN** the backend returns an empty candidate
|
||||||
|
- **THEN** the system SHALL show no popup
|
||||||
|
- **AND** the page SHALL continue its normal flow
|
||||||
|
|
||||||
|
#### Scenario: Same notification id within one day
|
||||||
|
- **WHEN** a later page entry returns the same `notification_id` already handled this session
|
||||||
|
- **THEN** the system SHALL NOT re-popup the same notification
|
||||||
|
|
||||||
|
### Requirement: Popup actions SHALL use a frontend whitelist
|
||||||
|
The system SHALL map `action_type` through a frontend whitelist only and SHALL NOT accept URLs or routes from the backend. `package_purchase` routes to the package purchase page, `asset_wallet_recharge` routes to the wallet page, and an empty value shows no action.
|
||||||
|
|
||||||
|
#### Scenario: package_purchase action
|
||||||
|
- **WHEN** the candidate `action_type` is `package_purchase`
|
||||||
|
- **THEN** the popup SHALL navigate to the package purchase page
|
||||||
|
|
||||||
|
#### Scenario: asset_wallet_recharge action
|
||||||
|
- **WHEN** the candidate `action_type` is `asset_wallet_recharge`
|
||||||
|
- **THEN** the popup SHALL navigate to the asset wallet page
|
||||||
|
|
||||||
|
#### Scenario: No action
|
||||||
|
- **WHEN** `action_type` is empty
|
||||||
|
- **THEN** the popup SHALL show no action button
|
||||||
|
|
||||||
|
### Requirement: Popup dismissal SHALL mark the notification read
|
||||||
|
The system SHALL call `PUT /api/c/v1/notifications/{id}/read` with the candidate `notification_id` when the popup is dismissed or when its action is taken. Popup closing and action-taking SHALL NOT use `read-all`.
|
||||||
|
|
||||||
|
#### Scenario: User closes the popup
|
||||||
|
- **WHEN** the user closes the popup
|
||||||
|
- **THEN** the notification SHALL be marked read
|
||||||
|
|
||||||
|
#### Scenario: User takes the popup action
|
||||||
|
- **WHEN** the user taps the action button
|
||||||
|
- **THEN** the notification SHALL be marked read
|
||||||
|
- **AND** the mapped page SHALL open
|
||||||
|
|
||||||
|
### Requirement: Resource-invisible responses SHALL be silent
|
||||||
|
The system SHALL treat HTTP 400 / `code=1180` responses uniformly as resource-invisible and SHALL NOT branch UI, show special prompts, or distinguish asset-missing from not-owned states.
|
||||||
|
|
||||||
|
#### Scenario: Invisible asset on popup query
|
||||||
|
- **WHEN** the popup query returns an invisible-asset error
|
||||||
|
- **THEN** the system SHALL show no popup
|
||||||
|
- **AND** the page SHALL continue its normal flow
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Risk-exchange popup SHALL collect a shipping address
|
||||||
|
The system SHALL present a shipping address form when the candidate `popup_type` is `risk_exchange`, collecting recipient name, recipient phone, and full address, and SHALL submit them to `POST /api/c/v1/risk-exchanges/{asset_id}/address` using the candidate `asset_id` (never derived from the H5 identifier).
|
||||||
|
|
||||||
|
#### Scenario: Valid address submission
|
||||||
|
- **WHEN** the user fills the required fields and submits
|
||||||
|
- **THEN** the risk-exchange request SHALL be submitted with the candidate `asset_id`
|
||||||
|
- **AND** the system SHALL show the returned exchange number and status
|
||||||
|
|
||||||
|
#### Scenario: Invalid or incomplete fields
|
||||||
|
- **WHEN** the user submits empty or invalid fields
|
||||||
|
- **THEN** the system SHALL block submission and prompt for the missing fields
|
||||||
|
|
||||||
|
### Requirement: Address submission SHALL be idempotent
|
||||||
|
The system SHALL NOT provide an edit-address UI. Repeated or concurrent submissions return the first-created exchange order and address without overwriting.
|
||||||
|
|
||||||
|
#### Scenario: Repeated submission
|
||||||
|
- **WHEN** the user submits the address again for the same asset
|
||||||
|
- **THEN** the system SHALL return the first-created exchange order and address
|
||||||
|
|
||||||
|
### Requirement: Risk-exchange failures SHALL keep the user in place
|
||||||
|
The system SHALL keep the address form open on failure and treat invisible-asset failures uniformly without UI branching.
|
||||||
|
|
||||||
|
#### Scenario: Submission failure
|
||||||
|
- **WHEN** the address submission fails
|
||||||
|
- **THEN** the system SHALL keep the form open
|
||||||
|
- **AND** the user SHALL be able to retry
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Personal notification list SHALL preserve backend ordering
|
||||||
|
The system SHALL display personal notifications in the backend-fixed order (created time and notification id descending) and SHALL NOT re-sort items client-side by severity or priority.
|
||||||
|
|
||||||
|
#### Scenario: Backend returns a fixed order
|
||||||
|
- **WHEN** the notification list is loaded
|
||||||
|
- **THEN** the items SHALL be rendered in the returned order
|
||||||
|
|
||||||
|
### Requirement: Notification items SHALL pass through popup fields and types
|
||||||
|
The system SHALL render items with the new `type` values `h5.popup.risk_exchange` and `h5.popup.operation` (category `system`) and SHALL keep `popup_snapshot` available for popup-related actions without treating it as a route.
|
||||||
|
|
||||||
|
#### Scenario: List contains a popup notification
|
||||||
|
- **WHEN** an item has `type` `h5.popup.risk_exchange` or `h5.popup.operation`
|
||||||
|
- **THEN** the item SHALL render as a normal system-category notification
|
||||||
|
|
||||||
|
### Requirement: Unread badge SHALL reflect popup notifications
|
||||||
|
The system SHALL count popup notifications in the unread badge and collapse the badge to `99+` above 99 per the `display_count` contract.
|
||||||
|
|
||||||
|
#### Scenario: Popup notification unread
|
||||||
|
- **WHEN** a popup notification is unread
|
||||||
|
- **THEN** the unread badge SHALL increase accordingly
|
||||||
|
|
||||||
|
### Requirement: Mark-read SHALL include the notification id and be idempotent
|
||||||
|
The system SHALL send the notification `id` in the mark-read request body and SHALL treat success responses idempotently.
|
||||||
|
|
||||||
|
#### Scenario: Mark-read an already-read notification
|
||||||
|
- **WHEN** the notification is already read or owned by another customer
|
||||||
|
- **THEN** the request SHALL still succeed
|
||||||
|
|
||||||
|
### Requirement: read-all SHALL NOT close popups implicitly in the popup chain
|
||||||
|
The system SHALL NOT call `read-all` from any popup display path because it also marks today's popup notifications read.
|
||||||
|
|
||||||
|
#### Scenario: Popup display flow
|
||||||
|
- **WHEN** the popup candidate flow runs
|
||||||
|
- **THEN** the system SHALL NOT call `read-all`
|
||||||
36
openspec/changes/add-h5-popup-candidate-flow/tasks.md
Normal file
36
openspec/changes/add-h5-popup-candidate-flow/tasks.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
## 1. API Layer
|
||||||
|
|
||||||
|
- [ ] 1.1 Add `popupApi.getCandidate(page, identifier)` for `GET /api/c/v1/popup-candidates`
|
||||||
|
- [ ] 1.2 Add `popupApi.submitRiskExchangeAddress(assetId, params)` for `POST /api/c/v1/risk-exchanges/{asset_id}/address`
|
||||||
|
- [ ] 1.3 Include the notification `id` in the mark-read request body (R3)
|
||||||
|
|
||||||
|
## 2. PopupCandidate Component
|
||||||
|
|
||||||
|
- [ ] 2.1 Query the candidate once per mount and emit `empty` when none exists
|
||||||
|
- [ ] 2.2 Render title/body and hide silently on resource-invisible responses
|
||||||
|
- [ ] 2.3 Map `action_type` through the whitelist and navigate from the mapped page
|
||||||
|
- [ ] 2.4 Mark the notification read on dismiss and on action (R3)
|
||||||
|
- [ ] 2.5 Deduplicate by `notification_id` within a session so same-day repeats do not re-popup
|
||||||
|
- [ ] 2.6 Provide the risk-exchange address form, submission, and result view
|
||||||
|
|
||||||
|
## 3. Page Wiring
|
||||||
|
|
||||||
|
- [ ] 3.1 Wire `home` in `pages/index/index.vue` with priority over the legacy unread-notification popup
|
||||||
|
- [ ] 3.2 Wire `package_purchase` in `pages/package-order/package-order.vue`
|
||||||
|
- [ ] 3.3 Wire `asset_wallet_recharge` in `pages/my-wallet/my-wallet.vue`
|
||||||
|
- [ ] 3.4 Leave `asset_detail` unwired
|
||||||
|
|
||||||
|
## 4. Notification Contract Alignment
|
||||||
|
|
||||||
|
- [ ] 4.1 Remove client-side priority re-sort and rely on the backend ordering
|
||||||
|
- [ ] 4.2 Align the category label map with `approval/expiry/sync/system`
|
||||||
|
- [ ] 4.3 Keep the unread badge consistent with popup counting and `99+` display
|
||||||
|
- [ ] 4.4 Keep `read-all` out of the popup display chain
|
||||||
|
|
||||||
|
## 5. Regression Verification
|
||||||
|
|
||||||
|
- [ ] 5.1 Operation popup with action navigates to the mapped page and marks read
|
||||||
|
- [ ] 5.2 Risk-exchange popup submits the address and shows the exchange number
|
||||||
|
- [ ] 5.3 Repeat entry with the same `notification_id` does not re-popup
|
||||||
|
- [ ] 5.4 Invisible asset shows no popup and no error branch
|
||||||
|
- [ ] 5.5 Notification center renders new `h5.popup.*` types without layout breakage
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Change: Retry 19-digit ICCID login with a Luhn check digit
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Some IoT cards are entered or scanned as a 19-digit ICCID prefix. The asset-verification endpoint requires the 20-digit ICCID, so the first verification cannot return an asset token even though the identifier can be deterministically completed.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- When a login identifier is exactly 19 numeric characters and starts with `89`, call `/api/c/v1/auth/verify-asset` with the entered value first.
|
||||||
|
- If that first request fails or does not return `asset_token`, compute the twentieth digit with the ISO/IEC 7812 Luhn (mod-10) check-digit algorithm and retry verification once using the completed ICCID.
|
||||||
|
- Keep the first failure entirely silent to the customer. Only the retry failure is handled by the existing login error flow.
|
||||||
|
- Persist and use the successful 20-digit ICCID for the authenticated session without mutating the text in the input field during the silent retry.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected capability: `iccid-login-check-digit-retry` (new)
|
||||||
|
- Affected code: `pages/login/login.vue`
|
||||||
|
- Affected API: `POST /api/c/v1/auth/verify-asset`
|
||||||
|
- No backend API, request payload shape, or shared error handling is changed.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Silent 19-digit ICCID verification retry
|
||||||
|
|
||||||
|
When an asset login identifier matches `^89\\d{17}$`, the H5 client SHALL first verify the entered 19-digit value. If that attempt fails or does not return a non-empty `asset_token`, it SHALL calculate the ISO/IEC 7812 Luhn mod-10 check digit, append it as the twentieth digit, and verify the completed ICCID exactly once.
|
||||||
|
|
||||||
|
#### Scenario: Completed ICCID succeeds
|
||||||
|
|
||||||
|
- **WHEN** a customer enters a 19-digit numeric identifier beginning with `89`
|
||||||
|
- **AND** the initial verification does not provide an asset token
|
||||||
|
- **AND** verification of the Luhn-completed 20-digit ICCID returns an asset token
|
||||||
|
- **THEN** the customer continues through login without seeing an error from the first verification
|
||||||
|
- **AND** the authenticated session uses the 20-digit ICCID
|
||||||
|
|
||||||
|
#### Scenario: Initial verification succeeds
|
||||||
|
|
||||||
|
- **WHEN** a customer enters a 19-digit numeric identifier beginning with `89`
|
||||||
|
- **AND** initial verification returns an asset token
|
||||||
|
- **THEN** the client SHALL not calculate or submit a second identifier
|
||||||
|
|
||||||
|
#### Scenario: Completed ICCID fails
|
||||||
|
|
||||||
|
- **WHEN** the Luhn-completed retry does not return an asset token
|
||||||
|
- **THEN** the client SHALL invoke the existing login failure presentation once using the retry failure
|
||||||
|
|
||||||
|
#### Scenario: Identifier is not a 19-digit ICCID prefix
|
||||||
|
|
||||||
|
- **WHEN** an identifier does not match `^89\\d{17}$`
|
||||||
|
- **AND** verification fails or does not return an asset token
|
||||||
|
- **THEN** the client SHALL not issue a retry
|
||||||
|
- **AND** SHALL retain the existing login failure presentation
|
||||||
10
openspec/changes/add-iccid-login-check-digit-retry/tasks.md
Normal file
10
openspec/changes/add-iccid-login-check-digit-retry/tasks.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## 1. Login fallback
|
||||||
|
|
||||||
|
- [x] 1.1 Add a pure ISO/IEC 7812 Luhn mod-10 check-digit helper for a 19-digit ICCID prefix.
|
||||||
|
- [x] 1.2 Retry asset verification once and silently only when the initial identifier matches `^89\\d{17}$` and the first verification fails or lacks `asset_token`.
|
||||||
|
- [x] 1.3 Persist the completed ICCID only after the retry succeeds; retain the existing visible error path for all final failures and non-matching identifiers.
|
||||||
|
|
||||||
|
## 2. Verification
|
||||||
|
|
||||||
|
- [x] 2.1 Verify known Luhn vectors plus first-attempt success, fallback success, fallback failure, and non-ICCID failure behavior.
|
||||||
|
- [x] 2.2 Run the H5 production build.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 第 1 批 H5 对接实施与验证记录
|
||||||
|
|
||||||
|
2026-10-13:客户端功能已实现。仅对接 `/api/c/v1`;不处理代理注册、密码规则和后台接口。真实微信授权、短信绑定及后端 A/B 资产通知范围尚需测试环境联调,未部署或提交代码。
|
||||||
|
|
||||||
|
## 已实现
|
||||||
|
|
||||||
|
- 客户手机号、资产是否绑号、号码归属、绑定提示分别保存;登录字段按当前令牌和资产隔离,退出/过期/切换时清理,旧 phoneBindStatus 不再作为权威状态。
|
||||||
|
- 四种绑定模式使用原 bind-phone 入参;资产首次绑号可输入与客户号不同的号码,1192 提示管理员并禁用当前提交,1184 保留后端错误。按用户最新确认,强制绑定成功保留有效 token 和资产标识,直接进入首页,不再要求重登。
|
||||||
|
- 客户换号只更新后端确认的客户号码,不改资产关联;归属事实失效,下一次真实登录重新确认。换号的原号来源不再放进 URL。
|
||||||
|
- asset/info 的共享返回入口接收一次性临期提醒,首页按最新要求使用居中、可关闭弹窗,已移除旧横幅组件。0 天正常显示,使用服务器等级和上海时区;与风险换货/运营候选、普通通知依次展示。绑定页预填收到的提醒留在当前会话,进入首页展示。
|
||||||
|
- 旧首页通知弹窗排除临期项,通知中心保留。全部已读按用户 2026-10-14 最新确认恢复为 PUT,单条已读也保持 PUT;成功后重新读取列表和计数,失败不乐观清空,延迟响应不能覆盖新的资产上下文。
|
||||||
|
- 临期弹窗不再展示等级文字标签,仅使用等级配色;新增“立即续费”,先关闭提醒再打开首页原有续费窗口,复用当前套餐 ID/名称/价格和全部原有支付校验,准备和展示期间暂停其他通知。
|
||||||
|
- 通知卡片移除左侧未读强调边框和小圆点,标题右侧显示“未读/已读”小按钮。点击卡片或按钮成功标记后更新,失败仍显示未读。
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
| 证据 | 结果 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `npm run test:h5-integration` | 19/19 通过 | 实际 store/API 模块及页面 setup,使用 Vue 响应性;原生 API、生命周期和后端响应为模拟 |
|
||||||
|
| `npm run build:h5` | 通过 | uni-app H5 开发配置构建 |
|
||||||
|
| `npm run build:h5:production` | 通过 | uni-app H5 生产配置构建 |
|
||||||
|
| `openspec validate update-october-batch1-h5-integration --strict` | 通过 | 提案、需求和场景结构校验 |
|
||||||
|
| `git diff --check` | 通过 | 默认 Git 换行配置下未发现补丁空白错误;Windows 换行提示不影响构建 |
|
||||||
|
| 本地浏览器,390×844 / 320×640 | 通过 | 居中临期弹窗、0 天/上海时间、关闭、通知标题右侧未读按钮、点击卡片变已读;此前绑定表单及 1192 检查保留,无页面运行错误 |
|
||||||
|
|
||||||
|
测试文件:[tests/october-h5.test.mjs](../../../tests/october-h5.test.mjs)。覆盖绑定模式/成功后的分类更新、登录提示开关、状态持久化/隔离、换号不迁移资产、1192/1184、强制绑定直接进入首页且 token/客户信息/提醒保留、预填失败、共享接收/并发、0~15 天/上海边界、旧弹窗过滤、PUT 已读请求、通知失败与切换、模拟 A/B 全部已读、401 状态清理以及临期弹窗续费复用/队列暂停。
|
||||||
|
|
||||||
|
本次修订删除了绑定成功后的登录态清理和“绑定手机号成功,请重新登录”提示,替代旧手动重登提案;真正的退出或 401 登录过期处理不变。
|
||||||
|
|
||||||
|
最新界面修订:`ExpiryReminderPopup.vue` 替代横幅;候选请求结束后才消费临期弹窗,普通通知显示时收到临期提醒会排队。候选组件卸载后的延迟响应不再影响新页面。`notifications.vue` 标题右侧使用阅读状态按钮,原标记接口不变。
|
||||||
|
|
||||||
|
本地渲染复验:先完成生产构建,再运行以下命令。使用独立浏览器会话和虚构号码,所有业务接口改写到本地模拟服务。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node tests/mock-h5-preview.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
另开一个终端:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
agent-browser --session october-h5-ui --headed false --init-script ./tests/mock-h5-browser-init.js open http://127.0.0.1:48138/pages/index/index
|
||||||
|
agent-browser --session october-h5-ui set viewport 390 844
|
||||||
|
agent-browser --session october-h5-ui snapshot -i
|
||||||
|
agent-browser --session october-h5-ui close
|
||||||
|
```
|
||||||
|
|
||||||
|
模拟服务固定在 `127.0.0.1:48138`,init 脚本仅在该地址注入;检查后使用 Ctrl+C 停止服务。该模拟接口故意重复返回提醒用于检查前端防重复,不能证明后端每日一次规则。
|
||||||
|
|
||||||
|
需要复验通知按钮时,在启动服务的终端先设置 `$env:H5_MOCK_NOTIFICATIONS='1'`;进入首页关闭临期弹窗,再点击站内通知。模拟通知初始为未读,点击卡片后服务端模拟状态变已读。
|
||||||
|
|
||||||
|
## 仍需真实联调
|
||||||
|
|
||||||
|
1. 微信公众号真实登录应返回三个布尔字段;尤其验证客户无号/资产有号时只给客户绑定,以及客户已有号时资产可绑定不同号码。
|
||||||
|
2. 确认 asset/info.bound_phone 仍表示当前客户手机号,与登录布尔事实区分;换号后资产号码保持原值。若实际响应与对接文档冲突,先确认契约,不猜测接口。
|
||||||
|
3. 同客户 A/B 两个临期资产分别扫码进入,确认服务器每天只返回一次、临期列表/未读数仅计当前资产,以及 A 上 PUT 全部已读不改变 B 的临期通知。
|
||||||
|
4. 完整回归真实支付、实名认证、套餐订购以及运营/风险换货候选弹窗。本次没有修改这些接口或候选组件,构建成功不替代真实业务验收。
|
||||||
|
|
||||||
|
完成结果已按实际验证范围回写 [tasks.md](tasks.md),真实联调项保留未勾选;不归档历史提案。
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
项目为 Vue 3 / uni-app H5。`openspec/specs` 当前没有已归档基线,因此本提案在现有相关 capability 名称下使用 `ADDED Requirements` 建立本次需求,不对不存在的基线使用 `MODIFIED`。`phone-change-migration` 保留历史 capability 名称以便追踪,但行为明确改为“只更换客户手机号”,不表示仍支持迁移。
|
||||||
|
|
||||||
|
接口行为以 2026-10-12 的第 1 批对接说明和用户 2026-10-13 的答复为准。仓库未提供说明中引用的 `docs/admin-openapi.yaml`,不因此回退旧的换号或全部已读约定。
|
||||||
|
|
||||||
|
| 代码证据 | 问题 | 处理方向 |
|
||||||
|
|---|---|---|
|
||||||
|
| `pages/login/login.vue` 只读取 `need_bind_phone` | 登录的资产绑定事实未保存 | 保存三个布尔字段,按当前登录资产隔离 |
|
||||||
|
| `pages/index/index.vue` 用本地手机号优先覆盖 `bound_phone`,并据此计算 `alreadyBindPhone` | 客户手机号与资产关联混为一体,旧缓存可覆盖服务端 | 拆分客户手机号、资产绑定/归属、是否需要提示 |
|
||||||
|
| `pages/bind/bind.vue` 成功后无条件 `setPhoneBindStatus` | 只给客户绑号也可能被记为资产换号 | 按本次绑定对象刷新,不推断资产号码 |
|
||||||
|
| `pages/change-phone/change-phone.vue` 把 `newPhone` 写入资产缓存 | 与新文档“只换客户手机号”冲突 | 仅更新客户手机号,保留资产关联 |
|
||||||
|
| 首页、TrafficCard、绑定页等分别请求 `asset/info` | 一次性提醒可能先被非展示调用方取走 | 在 API 返回入口集中接收,再由资产信息区展示 |
|
||||||
|
| 首页 `loadUnreadNotifications` 展示所有未读项 | 仍以旧临期通知触发弹窗 | 从旧弹窗来源排除临期项,通知中心不删除 |
|
||||||
|
| `api/modules/notification.js` 的 `markAllRead` 方法约定曾冲突 | 用户最新确认应为 PUT | 保持 PUT,不采用此前 POST 约定 |
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
- Goals:正确区分客户与资产绑定、支持文档四种流程、客户换号不改变资产关联、一次性临期提醒不丢失/不重复、通知全部已读方法正确。
|
||||||
|
- Non-Goals:代理注册及密码规则、后台接口、全局通知重构、新增登录方式、重新设计支付或实名流程。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 分离客户与资产手机号状态
|
||||||
|
|
||||||
|
`asset_phone_bound` 只表示当前资产存在有效手机号关联,`asset_phone_is_own` 只表示该关联是否属于当前客户;二者不提供号码。`need_bind_phone` 是后端计算的提示开关,不能由前端用手机号为空、归属为 false 或本地缓存重新计算。
|
||||||
|
|
||||||
|
现有八月份接口定义 `docs/产品迭代8月份/11.md` 将 `asset/info.bound_phone` 描述为“当前登录用户绑定的手机号”。新对接说明声明字段为兼容性新增、未改变该字段,故保留其客户手机号用途:可作为绑定预填和更换客户手机号的原号来源,但不能作为资产关联是否存在的证据。若实施联调的实际响应与这一未变字段约定冲突,先向用户说明并确认,不猜测新增字段或号码。
|
||||||
|
|
||||||
|
在用户状态中记录当前登录资产的三个布尔字段;在退出、切换账号或资产时清理/更换。原 `phoneBindStatus` 不再作为服务端资产事实或客户原手机号的权威来源,不能让历史的 `bound: true` 绕过当前状态,也不能跨客户复用。未获取新字段时保持“未知”,不把缺失默认为已绑定,也不新增 OAuth 自动重登来刷新事实。
|
||||||
|
|
||||||
|
### 2. 按四种场景处理绑定
|
||||||
|
|
||||||
|
| 客户手机号 | 资产手机号 | 表单与成功后的处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 无 | 无 | 验证一次输入号码,由后端同时写入客户与资产 |
|
||||||
|
| 有 | 无 | 提示“给当前资产绑定手机号”;客户号码可预填且可编辑,允许提交不同号码,不据此改客户手机号 |
|
||||||
|
| 无 | 有 | 提示“绑定客户手机号”;仍使用既有 bind-phone 请求,成功后只更新客户状态,不覆盖资产关联 |
|
||||||
|
| 有 | 有 | 不要求绑定;资产号码属于他人也不要求改绑或暴露号码 |
|
||||||
|
|
||||||
|
是否触发登录后的绑定提示仍取决于 `need_bind_phone`;关闭强制开关时不产生自动跳转。首页功能入口分别展示资产绑定状态与客户换号能力,不能因客户已有手机号就禁用资产首次绑号。
|
||||||
|
|
||||||
|
对于 `1192`,展示联系管理员的明确提示,停止再次提交当前资产改绑请求;不自动重试、不换接口解除、不显示资产号码。`1184` 等其他错误按返回消息提示,失败不更新状态。请求仍使用 `{ phone, code }`,不凭空新增绑定对象参数。
|
||||||
|
|
||||||
|
用户最新确认绑定手机号不会让 token 失效。强制绑定成功后保留 token、identifier 及客户信息,直接进入首页;不清除登录态、不进行自动 OAuth 或要求手动重登。普通绑定成功返回上一页。成功后的状态更新必须对应本次实际绑定对象,不能用“请求成功”统一代表客户号和资产号均已修改。
|
||||||
|
|
||||||
|
强制与普通绑定页均可使用 asset/info 预填客户手机号:本次会话不变,共享接收的一次性提醒保留到首页展示。`asset_phone_bound=true` 且 `need_bind_phone=true` 明确对应“只给客户绑号”。预填失败时不阻塞输入;已知资产原本无号时,绑定成功更新资产已绑号,但客户号码和归属未知时不猜测,首页后续 asset/info 读取客户手机号。
|
||||||
|
|
||||||
|
### 3. 客户换号不迁移资产
|
||||||
|
|
||||||
|
保留原/新手机号和验证码的现有表单以及 `change_phone_old`、`change_phone_new` 场景。成功后只更新服务端确认的客户手机号,不调用 `setPhoneBindStatus(identifier, newPhone)`,不显示“全部资产换绑成功”或旧事务迁移文案。
|
||||||
|
|
||||||
|
原资产仍可保持已绑定,但其号码可能不再属于客户;例如原 `asset_phone_is_own=true` 在客户改成不同号码后可能为 false。若响应不提供更新后的归属事实,不能把旧 true 留作最新事实,应失效该归属状态,等下一次真实登录响应确认;不借此要求资产改绑。
|
||||||
|
|
||||||
|
### 4. 一次性临期提醒使用弹窗及共享接收
|
||||||
|
|
||||||
|
首页就是当前 H5 的资产信息展示页,本次不新增资产详情路由。按用户最新要求,移除横幅,使用 `ExpiryReminderPopup` 居中弹窗:等级配色、小型时钟图形、突出显示剩余天数和预计到期时间;不展示 level_name 标签。提供“立即续费”、右上关闭及“我知道了”操作。
|
||||||
|
|
||||||
|
首页按现有风险换货/运营候选 → 临期提醒 → 普通通知的顺序展示,一次只显示一个。asset/info 和候选请求未完成时不消费待展示提醒;通知已显示时才收到临期提醒,则等通知关闭后显示。候选按钮立即导航时保持提醒待展示;候选组件卸载后的异步响应不得回调新页面。临期关闭不再占首页布局,也不因此创建新的业务请求。
|
||||||
|
|
||||||
|
原样保留 `expiry_reminder.remaining_days`、`expires_at`、`level`、`level_name`;使用服务端等级映射 red / purple / pink 的样式,保留文本提示,不能只靠颜色传达紧急程度。`remaining_days=0` 必须正常展示。日期展示遵循 Asia/Shanghai,不能用客户端当前时区重算剩余自然日或等级。
|
||||||
|
|
||||||
|
“立即续费”先关闭临期弹窗且不推进其他通知,再调用与首页按钮相同的 startHomepageRenewal,使用当前套餐 ID、名称和续费价格。续费窗口准备中/可见时暂停其他提醒,关闭后继续排队。无可续费套餐或无支付方式时沿用原提示,不新增下单接口,也不在点击提醒按钮时自动创建订单。
|
||||||
|
|
||||||
|
`assetApi.getInfo` 成功返回处集中接收非空提醒并交给共享状态,同次资产进入期间的并发响应不允许用“未返回字段”的响应覆盖已经接收的提醒。共享状态隔离当前客户上下文与 identifier,避免切换资产/退出后显示旧资产或他人的提醒。提醒到达时若资产信息区尚不可见,应先暂存,待对应资产可见时再展示,不能静默丢弃。
|
||||||
|
|
||||||
|
后端负责“客户 + 资产 + 上海自然日”的每日一次;前端只防止同一次接收被多个组件重复展示,不增加每日定时推送、补发请求或长期缓存复播。关闭/已展示的提醒在当天后续无字段响应时不得重新出现。审核所有 `asset/info` 调用方,特别是绑定前预填及首页 TrafficCard 的请求顺序;绑定成功使用同一 token,提醒无需跨登录传递。真实退出、登录过期或切换上下文仍清理旧提醒。
|
||||||
|
|
||||||
|
临期弹窗展示/关闭不自动标记通知已读:提醒不含 notification_id,不能按标题、日期或列表位置猜测 ID,也不能调用 read-all 消掉新通知。临期通知继续由通知中心管理阅读状态。
|
||||||
|
|
||||||
|
### 5. 通知仅调整临期来源与全部已读方法
|
||||||
|
|
||||||
|
从首页旧通知弹窗来源排除临期项,沿用既有 `category=expiry` 识别方式;不删除通知中心临期项,不改变其他未读通知的顺序和展示。`popup-candidates` 的风险换货/运营链路不受影响。
|
||||||
|
|
||||||
|
列表、未读数保持原入参,不增加文档未定义的 identifier/asset_id;后端基于当前认证资产过滤临期通知,并非所有通知都只属于当前资产。切换资产后刷新列表和计数,不将 A 的未读结果拼接到 B。
|
||||||
|
|
||||||
|
全部已读按用户最新确认使用 PUT,保留通知中心主动触发;失败不提前清除未读状态。成功后以刷新结果对齐本地列表/未读数,不能把其他资产的临期通知置已读。单条已读继续 PUT。风险换货、运营及临期弹窗均不得自动调用全部已读。
|
||||||
|
|
||||||
|
通知卡片保留常规边框,移除左侧未读强调边框和小圆点。标题右侧显示绿色“未读”或灰色“已读”小按钮;点击整张卡片或状态按钮调用现有单条 PUT,成功后显示“已读”并刷新计数,失败保留“未读”。按钮阻止事件冒泡,避免一次点击触发两次标记。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- 一次性提醒在任何 asset/info 读取时就可能被后端消费:集中接收并回归首页并发、绑定预填、其他页面进入,不能只给首页 getInfo 的响应加判断。
|
||||||
|
- 客户号码与资产号码不同:服务端布尔事实和客户号分离,历史本地缓存不再覆盖服务端。
|
||||||
|
- 没有最新 YAML:按已确认的新文档实施;如实际契约缺字段或与四场景不符,提供响应结构差异(不含真实号码/令牌)并先询问用户。
|
||||||
|
- 尚未归档的旧提案仍含相反规则:本提案显式声明覆盖范围,不顺带改写或归档历史提案。
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. 提案批准后,先完成状态拆分及旧缓存失效,再调整绑定/换号行为。
|
||||||
|
2. 加入临期提醒共享接收和居中弹窗及排队显示,排除首页旧临期通知弹窗来源,调整通知阅读状态按钮。
|
||||||
|
3. 保持全部已读为 PUT,回归当前资产的列表与未读数。
|
||||||
|
4. 完成任务中的场景验证及 H5 构建,记录实际结果;不以静态构建代替真实接口联调。
|
||||||
|
5. 需要回退时只回退本次前端改动;服务端新契约不变,不能恢复“客户换号即资产迁移”的旧假设作为兼容策略。
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
提案范围和优先契约已由用户确认,无需再次决定代理注册或 read-all 方法。实施前仍应检查接口是否实际提供所需字段及一次性提醒跨页面传递条件;只在发现具体矛盾时报告并询问,不擅自扩展接口。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Change: 对接十月份第 1 批 H5 接口变更
|
||||||
|
|
||||||
|
本提案仅覆盖 `/api/c/v1` 的现有 H5 功能。用户于 2026-10-13 批准执行;实现和验证结果见 [IMPLEMENTATION.md](IMPLEMENTATION.md)。
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
[第 1 批前端对接说明](../../../docs/10月份修改/第1批-8-9月迭代前端对接说明.md)中的 F1、M9 改变了手机号关联和临期提醒的行为。当前代码仍混用客户手机号与资产绑定状态、在客户换号后覆盖资产本地手机号,并从未读通知触发临期弹窗,可能出现错误的绑定提示和重复提醒。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 接收并保存公众号登录返回的 `asset_phone_bound`、`asset_phone_is_own`、`need_bind_phone`,区分客户手机号与当前资产手机号关联,不能再用一个“已绑定”缓存表达两者。
|
||||||
|
- 按 F1 的四种场景调整绑定页文案、绑定入口及提交后的状态处理;客户已有手机号、资产无号时,允许给资产绑定不同号码。
|
||||||
|
- 对 `bind-phone` 的 `1192` 提示“该资产已绑定手机号,如需更换请联系管理员”,不提供重试当前绑定请求的操作;保留 `1184` 的既有错误处理。
|
||||||
|
- 绑定成功保留有效 token 和资产标识;登录引导的绑定页直接进入首页,不再清除登录态、提示或要求重新登录。普通绑定成功仍返回上一页。
|
||||||
|
- **BREAKING(替代旧前端假设)**:`change-phone` 只修改客户手机号,不更新任何资产手机号关联;停止将新客户手机号写入资产绑定缓存。
|
||||||
|
- 接收 `asset/info.expiry_reminder`,在首页展示居中、可关闭的临期弹窗,显示剩余天数、到期时间及等级配色,不显示等级名称标签;“立即续费”复用首页已有续费流程。无字段时不自行计算或补发提醒。
|
||||||
|
- 统一接收各 `asset/info` 调用返回的一次性提醒,防止首页子组件或其他调用方先获得字段后将其丢弃;不为提醒新增轮询或额外请求。
|
||||||
|
- 首页旧未读通知弹窗不再以临期通知作为弹窗依据;临期通知仍保留在通知中心,其他通知和既有运营/风险换货弹窗不变。
|
||||||
|
- 临期弹窗与风险换货/运营、普通通知弹窗依次展示,不叠加;通知卡片去掉左侧未读边框,标题右侧使用“未读/已读”小按钮,点击卡片或按钮后按单条已读响应更新。
|
||||||
|
- 全部已读保持 `PUT /api/c/v1/notifications/read-all`,以用户 2026-10-14 最新确认覆盖此前 POST 约定;通知列表和未读数沿用现有入参,由后端过滤当前资产的临期通知。
|
||||||
|
|
||||||
|
## Scope and confirmed decisions
|
||||||
|
|
||||||
|
用户确认:不处理代理注册;手机号行为以当前对接说明为准。全部已读以 2026-10-14 最新确认的 `PUT` 为准,不采用此前文档中的 POST。
|
||||||
|
|
||||||
|
后续用户补充确认:绑定手机号不会使 token 失效,绑定成功不得再跳转登录页。本次修订覆盖旧强制绑定后手动重登流程。
|
||||||
|
|
||||||
|
后续界面修订:临期提醒必须使用弹窗而非横幅;通知卡片用标题右侧的小按钮表达“未读/已读”,点击卡片完成已读。
|
||||||
|
|
||||||
|
| 接口 | 本次处理 | 不变部分 |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/c/v1/auth/wechat-login` | 消费两个资产绑定布尔字段及新的绑定提示语义 | OAuth、令牌、资产校验流程 |
|
||||||
|
| `POST /api/c/v1/auth/bind-phone` | 四种绑定场景、1192、成功后的分类状态更新 | `{ phone, code }`、验证码接口与场景 |
|
||||||
|
| `POST /api/c/v1/auth/change-phone` | 客户换号,不再假设资产关联迁移 | 原/新手机号及验证码表单、接口入参 |
|
||||||
|
| `GET /api/c/v1/asset/info` | 接收一次性 `expiry_reminder`,区分手机号用途 | 原资产、套餐、实名、支付字段 |
|
||||||
|
| `GET /api/c/v1/notifications` | 回归当前资产临期通知的可见性 | 分页、排序、通知中心展示 |
|
||||||
|
| `GET /api/c/v1/notifications/unread-count` | 使用后端当前上下文计数 | 原请求参数、未读入口 |
|
||||||
|
| `PUT /api/c/v1/notifications/read-all` | 保持 PUT,撤销此前 POST 改动 | 用户主动点击“全部已读”的入口 |
|
||||||
|
|
||||||
|
明确排除:
|
||||||
|
|
||||||
|
- 代理注册页面、接口封装、密码新规则及注册 `status=3`。
|
||||||
|
- 所有 `/api/admin`、`/api/auth` 的变更,以及后台订单、审批、换货、导入、预警、导出功能。
|
||||||
|
- 新增小程序登录入口;`miniapp-login` 的同名字段仅作为后端共用契约背景,本 H5 继续使用公众号登录。
|
||||||
|
- 修改 C 端订单、退款接口、WiFi 密码规则、支付路由及实名流程。
|
||||||
|
- 新增后端接口、手机号关联解除/迁移接口,或从布尔字段推测资产实际号码。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected specs: `index-phone-bind-gate`、`phone-bind-association-prefill`、`phone-change-migration`、`c-notification-reminders`、`personal-notifications`。
|
||||||
|
- Affected code: `pages/login/login.vue`、`pages/index/index.vue`、`pages/bind/bind.vue`、`pages/change-phone/change-phone.vue`、`components/FunctionCard.vue`、`components/DeviceStatusCard.vue`、`api/modules/asset.js`、`api/modules/notification.js`、`store/user.js`、`store/asset.js`、`utils/phone-bind.js`。
|
||||||
|
- 检查但不预设必须修改:`components/TrafficCard.vue`、`components/RenewalPaymentPopup.vue`、其他 `assetApi.getInfo` 调用方及通知中心的刷新逻辑。
|
||||||
|
- 不增加依赖,不执行后端或数据迁移。
|
||||||
|
|
||||||
|
## Existing change conflicts
|
||||||
|
|
||||||
|
- [update-phone-bind-association-flow](../update-phone-bind-association-flow/proposal.md):其“不同于客户主号则拒绝”“换号迁移全部资产关联”“用 bound_phone 判断资产关联”的旧约定,被本提案的 F1 约定替代;不覆盖其历史文件及完成标记。
|
||||||
|
- [update-post-bind-wechat-login-recheck](../update-post-bind-wechat-login-recheck/proposal.md):根据用户最新确认,替代其“强制绑定后清除 token 并要求手动重登”的约定;改为保留登录态直接进入首页,不改写历史提案。
|
||||||
|
- [update-july-h5-c-iteration](../update-july-h5-c-iteration/proposal.md):仅替代临期通知作为首页弹窗来源的约定,保留通知中心、其他通知和套餐到期信息展示。
|
||||||
|
- [add-personal-notifications](../add-personal-notifications/proposal.md):保持单条/全部已读的 PUT,遵循当前资产临期通知范围及最新卡片样式。
|
||||||
|
- [add-h5-popup-candidate-flow](../add-h5-popup-candidate-flow/proposal.md):保留候选弹窗与单条已读链路;候选结束后再显示临期弹窗,不调用全部已读来关闭任何弹窗。
|
||||||
|
|
||||||
|
技术选择、字段依据及风险见 [design.md](design.md),执行及验收清单见 [tasks.md](tasks.md)。
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Expiry reminders SHALL use the optional asset-info response
|
||||||
|
|
||||||
|
H5 SHALL 仅以 `GET /api/c/v1/asset/info` 返回的可选 `expiry_reminder` 作为本次临期主动提醒来源,在首页展示居中、可关闭的临期弹窗,而不是内联横幅。剩余天数、到期时间及配色 SHALL 使用 `remaining_days`、`expires_at`、`level`;SHALL NOT 展示 level_name 等级标签,也 SHALL NOT 用旧未读临期通知或客户端计算补发提醒。
|
||||||
|
|
||||||
|
#### Scenario: Asset info includes a reminder
|
||||||
|
|
||||||
|
- **WHEN** 当前资产响应含有效 expiry_reminder
|
||||||
|
- **THEN** H5 SHALL 展示该资产临期弹窗及后端提供的提醒信息
|
||||||
|
|
||||||
|
#### Scenario: Asset info omits the optional field
|
||||||
|
|
||||||
|
- **WHEN** 响应无 expiry_reminder,且当前进入没有已接收的待展示提醒
|
||||||
|
- **THEN** H5 SHALL 不显示临期弹窗,也不从 is_expiring 或通知列表补发
|
||||||
|
|
||||||
|
### Requirement: Reminder presentation SHALL preserve server calendar and levels
|
||||||
|
|
||||||
|
H5 SHALL 正确显示 0~15 天,按后端 `red`(0~3)、`purple`(4~7)、`pink`(8~15)设置视觉等级,并提供文字信息。到期时间 SHALL 按 Asia/Shanghai 展示,SHALL NOT 按客户端日期重新判定等级或剩余自然日。
|
||||||
|
|
||||||
|
#### Scenario: Zero remaining days
|
||||||
|
|
||||||
|
- **WHEN** 后端返回 remaining_days=0、level=red
|
||||||
|
- **THEN** H5 SHALL 正常展示 0 天及红色等级,不能把 0 当作缺失
|
||||||
|
|
||||||
|
#### Scenario: Client timezone differs from Shanghai
|
||||||
|
|
||||||
|
- **WHEN** 用户设备的本地时区不是 Asia/Shanghai
|
||||||
|
- **THEN** H5 SHALL 保留后端天数和等级,到期时间使用上海时区呈现
|
||||||
|
|
||||||
|
### Requirement: One-time reminder delivery SHALL survive competing asset-info consumers
|
||||||
|
|
||||||
|
H5 SHALL 在 asset/info 的共享成功返回入口接收一次性提醒,按当前客户上下文及资产隔离并暂存至对应资产可见。后端负责每日一次,前端 SHALL 防止同次接收被重复展示,SHALL NOT 因并发空字段响应覆盖已接收提醒、使用额外轮询或在关闭后复播。
|
||||||
|
|
||||||
|
#### Scenario: Child component receives the only reminder response
|
||||||
|
|
||||||
|
- **WHEN** TrafficCard 等调用方先获得 expiry_reminder,首页后续响应未返回该字段
|
||||||
|
- **THEN** H5 SHALL 通过共享接收状态在当前资产信息区展示该提醒,不将其丢弃
|
||||||
|
|
||||||
|
#### Scenario: Same asset is re-entered after dismissal
|
||||||
|
|
||||||
|
- **WHEN** 用户关闭提醒后再次进入同一资产,后端不再返回字段
|
||||||
|
- **THEN** H5 SHALL 不重播先前已展示提醒
|
||||||
|
|
||||||
|
#### Scenario: User switches customer or asset
|
||||||
|
|
||||||
|
- **WHEN** 用户退出、切换客户或进入另一资产
|
||||||
|
- **THEN** H5 SHALL 不显示旧上下文提醒;其他资产有新提醒时 SHALL 独立处理
|
||||||
|
|
||||||
|
### Requirement: Expiry notifications SHALL remain in the notification center without driving popups
|
||||||
|
|
||||||
|
H5 SHALL 从首页旧未读通知弹窗来源排除临期项,同时保留通知中心临期列表、未读数和既有阅读操作。临期弹窗展示或关闭 SHALL NOT 自动标记通知已读,其他通知及候选弹窗链路 SHALL 保持不变。
|
||||||
|
|
||||||
|
#### Scenario: Unread expiry notification exists without a new reminder
|
||||||
|
|
||||||
|
- **WHEN** 通知列表返回未读临期项,但 asset/info 无本次提醒
|
||||||
|
- **THEN** H5 SHALL 不以该项触发旧首页临期弹窗
|
||||||
|
- **AND** 通知中心 SHALL 仍可展示和阅读该项
|
||||||
|
|
||||||
|
#### Scenario: User closes an expiry popup
|
||||||
|
|
||||||
|
- **WHEN** 用户关闭临期弹窗
|
||||||
|
- **THEN** H5 SHALL 不猜测 notification_id、不发送自动单条已读或全部已读请求
|
||||||
|
|
||||||
|
### Requirement: Home popups SHALL display sequentially without overlapping
|
||||||
|
|
||||||
|
H5 SHALL 在风险换货/运营候选结束后展示待处理临期提醒,再继续既有普通通知流程,一次只显示一个弹窗。提醒 SHALL 仅在实际显示时标记本次已展示;导航或其他弹窗占用时 SHALL 保持待展示状态。
|
||||||
|
|
||||||
|
#### Scenario: Candidate and expiry reminder are both present
|
||||||
|
|
||||||
|
- **WHEN** 候选弹窗尚未结束且已收到 expiry_reminder
|
||||||
|
- **THEN** H5 SHALL 先展示候选,关闭后再展示临期提醒,不能叠加两个弹窗
|
||||||
|
|
||||||
|
#### Scenario: Notification is already open when reminder arrives
|
||||||
|
|
||||||
|
- **WHEN** 普通通知弹窗正在显示,随后收到 expiry_reminder
|
||||||
|
- **THEN** H5 SHALL 暂存临期提醒,等通知关闭后再展示
|
||||||
|
|
||||||
|
### Requirement: Expiry popup renewal SHALL reuse the homepage renewal flow
|
||||||
|
|
||||||
|
H5 SHALL 在临期弹窗提供“立即续费”,点击后先关闭提醒,再调用首页已有续费入口,使用当前套餐 ID、名称及价格。续费窗口准备/展示期间 SHALL 暂停其他通知弹窗,窗口关闭后再恢复;SHALL NOT 在提醒中另建下单或支付实现。
|
||||||
|
|
||||||
|
#### Scenario: User taps renew in an expiry popup
|
||||||
|
|
||||||
|
- **WHEN** 当前资产有可续费套餐,用户点击临期弹窗“立即续费”
|
||||||
|
- **THEN** 临期弹窗 SHALL 关闭,首页原有续费窗口 SHALL 打开
|
||||||
|
- **AND** 其他排队通知 SHALL 不与续费窗口重叠
|
||||||
|
|
||||||
|
#### Scenario: Current asset has no renewable package
|
||||||
|
|
||||||
|
- **WHEN** 用户点击“立即续费”,但当前套餐 ID 或名称无效
|
||||||
|
- **THEN** H5 SHALL 使用首页原有“当前资产暂无可续费套餐”提示,不创建订单
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Phone guidance SHALL use the current login asset facts
|
||||||
|
|
||||||
|
H5 SHALL 保存 `POST /api/c/v1/auth/wechat-login` 的 `asset_phone_bound`、`asset_phone_is_own`、`need_bind_phone`,分别表达资产是否绑号、号码归属和是否提示绑定。状态 SHALL 按当前账号及资产隔离;客户 `bound_phone` 和旧本地缓存 SHALL NOT 替代资产布尔事实。
|
||||||
|
|
||||||
|
#### Scenario: Customer has a phone but the asset is unbound
|
||||||
|
|
||||||
|
- **WHEN** 客户有手机号,登录返回 `asset_phone_bound=false` 和 `need_bind_phone=true`
|
||||||
|
- **THEN** H5 SHALL 引导用户给资产绑定号码
|
||||||
|
- **AND** SHALL NOT 因客户手机号非空或旧缓存 bound=true 跳过提示
|
||||||
|
|
||||||
|
#### Scenario: Asset belongs to another phone and guidance is not required
|
||||||
|
|
||||||
|
- **WHEN** 登录返回 `asset_phone_bound=true`、`asset_phone_is_own=false`、`need_bind_phone=false`
|
||||||
|
- **THEN** H5 SHALL 认为资产已有手机号,不要求重新绑定
|
||||||
|
- **AND** SHALL NOT 推测或显示关联号码
|
||||||
|
|
||||||
|
#### Scenario: Account or asset context changes
|
||||||
|
|
||||||
|
- **WHEN** 用户退出、切换账号或切换资产
|
||||||
|
- **THEN** H5 SHALL 清理或更换绑定事实,不能复用上一上下文的布尔值或手机号缓存
|
||||||
|
|
||||||
|
### Requirement: Automatic binding guidance SHALL follow need_bind_phone
|
||||||
|
|
||||||
|
H5 SHALL 仅按后端 `need_bind_phone` 触发自动绑定提示,SHALL NOT 根据手机号为空或 `asset_phone_is_own=false` 擅自增加自动跳转;缺失字段 SHALL 保持未知而非视为绑定成功。
|
||||||
|
|
||||||
|
#### Scenario: Force binding is disabled
|
||||||
|
|
||||||
|
- **WHEN** 强制绑定关闭,登录返回 `need_bind_phone=false`
|
||||||
|
- **THEN** H5 SHALL 正常进入首页,不自动跳转绑定页
|
||||||
|
|
||||||
|
#### Scenario: Guidance is required
|
||||||
|
|
||||||
|
- **WHEN** 登录返回 `need_bind_phone=true`
|
||||||
|
- **THEN** H5 SHALL 进入现有强制绑定流程,并按资产事实区分给客户绑号与给资产绑号
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Notification queries SHALL preserve backend current-asset expiry scope
|
||||||
|
|
||||||
|
H5 SHALL 保持 `GET /api/c/v1/notifications` 与 `GET /api/c/v1/notifications/unread-count` 的现有入参,使用后端返回的当前资产临期通知及未读计数。H5 SHALL NOT 扩大范围查询所有资产、为这些接口新增未声明参数,或将普通通知一并误过滤为仅当前资产。
|
||||||
|
|
||||||
|
#### Scenario: Customer views asset A
|
||||||
|
|
||||||
|
- **WHEN** 客户在资产 A 的认证上下文读取列表和未读数
|
||||||
|
- **THEN** H5 SHALL 展示后端结果,其中临期提醒按 A 过滤
|
||||||
|
- **AND** SHALL NOT 混入资产 B 的旧列表或计数
|
||||||
|
|
||||||
|
#### Scenario: Current asset changes
|
||||||
|
|
||||||
|
- **WHEN** 当前认证资产由 A 切换为 B
|
||||||
|
- **THEN** H5 SHALL 刷新列表和未读数,防止 A 的延迟响应覆盖 B
|
||||||
|
|
||||||
|
### Requirement: Mark-all-read SHALL use PUT and preserve other assets' expiry notifications
|
||||||
|
|
||||||
|
H5 SHALL 在用户主动点击通知中心“全部已读”时调用 `PUT /api/c/v1/notifications/read-all`,SHALL NOT 发送 POST 或用不同方法重试。其他资产的临期通知 SHALL 由后端保留未读;客户端成功后 SHALL 按服务端刷新结果更新当前视图,失败 SHALL 保留未读状态。单条已读 SHALL 继续使用 `PUT /api/c/v1/notifications/{id}/read`。
|
||||||
|
|
||||||
|
#### Scenario: User marks all notifications read on asset A
|
||||||
|
|
||||||
|
- **WHEN** 客户在 A 上主动执行全部已读
|
||||||
|
- **THEN** H5 SHALL 发送 PUT 并对齐当前视图
|
||||||
|
- **AND** 资产 B 的临期通知 SHALL 保持未读
|
||||||
|
|
||||||
|
#### Scenario: Mark-all-read fails
|
||||||
|
|
||||||
|
- **WHEN** PUT 全部已读请求失败
|
||||||
|
- **THEN** H5 SHALL 不提前清空未读计数或把本地项全部标成已读,也不改用 POST 重试
|
||||||
|
|
||||||
|
#### Scenario: User reads one notification
|
||||||
|
|
||||||
|
- **WHEN** 用户执行单条已读操作
|
||||||
|
- **THEN** H5 SHALL 继续发送对应通知的 PUT 请求
|
||||||
|
|
||||||
|
#### Scenario: Reminder or popup is displayed
|
||||||
|
|
||||||
|
- **WHEN** 临期、运营候选或风险换货弹窗展示或关闭
|
||||||
|
- **THEN** H5 SHALL 不自动调用 read-all 来结束展示链路
|
||||||
|
|
||||||
|
### Requirement: Notification cards SHALL show a right-aligned read-status button
|
||||||
|
|
||||||
|
H5 SHALL 在通知标题右侧显示小型“未读/已读”按钮,SHALL NOT 用左侧强调边框或小圆点表示未读。点击卡片或按钮 SHALL 对未读项调用现有单条已读 PUT,成功后切换为“已读”并刷新计数;失败 SHALL 保留未读状态,已读项不重复请求。
|
||||||
|
|
||||||
|
#### Scenario: User taps an unread card
|
||||||
|
|
||||||
|
- **WHEN** 用户点击未读通知卡片或标题右侧“未读”按钮,标记接口成功
|
||||||
|
- **THEN** 小按钮 SHALL 切换为“已读”,卡片不新增左侧边框
|
||||||
|
|
||||||
|
#### Scenario: Mark-read fails
|
||||||
|
|
||||||
|
- **WHEN** 点击未读通知后标记接口失败
|
||||||
|
- **THEN** 小按钮 SHALL 保持“未读”,用户仍可再次操作
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Binding SHALL distinguish customer and asset phone targets
|
||||||
|
|
||||||
|
H5 SHALL 按 F1 四种场景组织绑定表单,继续使用 `POST /api/c/v1/auth/bind-phone` 的 `{ phone, code }` 与既有验证码场景。客户号码预填 SHALL 可编辑,SHALL NOT 要求未绑定资产只能绑定客户主号,也 SHALL NOT 用资产布尔字段推导实际号码。
|
||||||
|
|
||||||
|
#### Scenario: Neither the customer nor the asset has a phone
|
||||||
|
|
||||||
|
- **WHEN** 客户和资产都无手机号,用户输入号码并成功验证
|
||||||
|
- **THEN** H5 SHALL 按后端同时绑定客户和资产的结果完成流程
|
||||||
|
|
||||||
|
#### Scenario: Only the customer has a phone
|
||||||
|
|
||||||
|
- **WHEN** 客户已有号码,资产未关联号码
|
||||||
|
- **THEN** H5 SHALL 提示给当前资产绑号,允许提交与客户号码不同的号码
|
||||||
|
- **AND** 成功后 SHALL NOT 把输入的资产号码当成客户的新号码
|
||||||
|
|
||||||
|
#### Scenario: Only the asset has a phone
|
||||||
|
|
||||||
|
- **WHEN** 客户无号码,资产已有号码,用户成功提交客户绑定
|
||||||
|
- **THEN** H5 SHALL 仅更新客户手机号状态,不覆盖或改变资产关联
|
||||||
|
|
||||||
|
#### Scenario: Both the customer and the asset have phones
|
||||||
|
|
||||||
|
- **WHEN** 客户和资产都已有号码
|
||||||
|
- **THEN** H5 SHALL 不要求绑定,无论资产号码是否属于当前客户
|
||||||
|
|
||||||
|
### Requirement: Asset phone conflicts SHALL direct the user to an administrator
|
||||||
|
|
||||||
|
H5 SHALL 对 `bind-phone` 的 `code=1192` 提示“该资产已绑定手机号,如需更换请联系管理员”,SHALL NOT 重试当前资产改绑请求、暴露号码或调用后台解除接口。其他错误 SHALL 保留后端消息,失败 SHALL NOT 更新绑定成功状态。
|
||||||
|
|
||||||
|
#### Scenario: Binding returns 1192
|
||||||
|
|
||||||
|
- **WHEN** 后端拒绝资产改绑并返回 1192
|
||||||
|
- **THEN** H5 SHALL 显示联系管理员提示并停止当前改绑操作,不提供同请求重试
|
||||||
|
|
||||||
|
#### Scenario: Phone is owned by another customer
|
||||||
|
|
||||||
|
- **WHEN** 绑定返回既有 1184 错误
|
||||||
|
- **THEN** H5 SHALL 保留相应后端错误提示,不误报为绑定成功或转换成 1192
|
||||||
|
|
||||||
|
### Requirement: Binding completion SHALL retain the valid session
|
||||||
|
|
||||||
|
H5 SHALL 在绑定成功后保留有效 token、identifier 和客户信息;强制绑定成功 SHALL 直接进入首页,普通绑定成功 SHALL 返回上一页。H5 SHALL NOT 因绑定成功清除登录态、跳转登录页或要求自动/手动重新登录。状态更新 SHALL 对应本次客户/资产实际绑定对象,SHALL NOT 一律将输入号码写入资产缓存。
|
||||||
|
|
||||||
|
#### Scenario: Mandatory binding succeeds
|
||||||
|
|
||||||
|
- **WHEN** 用户从强制绑定入口成功提交
|
||||||
|
- **THEN** H5 SHALL 直接进入首页并保留当前 token、identifier 和客户信息
|
||||||
|
- **AND** SHALL 清理旧版本的绑定后重登提示标记,不发送新的 OAuth 登录请求
|
||||||
|
|
||||||
|
#### Scenario: Ordinary binding succeeds
|
||||||
|
|
||||||
|
- **WHEN** 用户从首页普通入口成功提交
|
||||||
|
- **THEN** H5 SHALL 返回上一页,按后端结果更新相关状态,不强制返回登录页
|
||||||
|
|
||||||
|
#### Scenario: Prefill received a one-time reminder before binding
|
||||||
|
|
||||||
|
- **WHEN** 绑定页预填请求收到 expiry_reminder,随后用户绑定成功
|
||||||
|
- **THEN** H5 SHALL 保留同一会话下的共享提醒,进入对应资产首页后展示
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Customer phone changes SHALL leave asset phone associations unchanged
|
||||||
|
|
||||||
|
H5 SHALL 按当前 F1 契约调用 `POST /api/c/v1/auth/change-phone`,只更换客户手机号。原/新号码及验证码入参、`change_phone_old` / `change_phone_new` 场景 SHALL 保持不变;H5 SHALL NOT 将客户新号写入资产关联缓存或宣称所有资产关联已迁移。
|
||||||
|
|
||||||
|
#### Scenario: Customer changes to a different phone
|
||||||
|
|
||||||
|
- **WHEN** 客户换号成功,后端确认新客户手机号
|
||||||
|
- **THEN** H5 SHALL 更新客户手机号及后续换号的原号来源
|
||||||
|
- **AND** SHALL 保持资产关联不变,不调用新号写入资产绑定缓存的逻辑
|
||||||
|
|
||||||
|
#### Scenario: Asset ownership fact becomes stale
|
||||||
|
|
||||||
|
- **WHEN** 客户更换为不同号码,原归属事实为 true,响应未返回最新资产归属
|
||||||
|
- **THEN** H5 SHALL 失效旧 `asset_phone_is_own` 事实,等真实登录响应确认
|
||||||
|
- **AND** SHALL NOT 将资产误标为未绑定或要求重新绑定
|
||||||
|
|
||||||
|
### Requirement: Customer phone change failures SHALL preserve existing state
|
||||||
|
|
||||||
|
H5 SHALL 在换号失败时展示后端错误并留在表单,保留原客户及资产状态;SHALL NOT 套用旧“全部资产事务迁移/回滚”的假设或固定文案。
|
||||||
|
|
||||||
|
#### Scenario: Change-phone returns a business error
|
||||||
|
|
||||||
|
- **WHEN** 换号请求失败
|
||||||
|
- **THEN** H5 SHALL 显示实际错误消息并留在页面
|
||||||
|
- **AND** SHALL NOT 更新客户号码、资产号码或缓存
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
## 1. 状态拆分与绑定入口
|
||||||
|
|
||||||
|
- [x] 1.1 保存登录的 asset_phone_bound / asset_phone_is_own / need_bind_phone,按当前账号与资产隔离,在退出/切换时清理。
|
||||||
|
- [x] 1.2 区分客户手机号、资产关联和提示状态;bound_phone 只沿用客户手机号用途,不用它推断资产关联。
|
||||||
|
- [x] 1.3 移除首页对旧 phoneBindStatus 的优先信任,确保历史缓存不能覆盖服务端状态或跨客户复用。
|
||||||
|
- [x] 1.4 调整首页手机号展示和 FunctionCard 绑定入口:客户有号但资产无号仍可绑定;资产归属 false 不表示未绑定。
|
||||||
|
- [x] 1.5 保留 need_bind_phone 驱动的登录提示,强制开关关闭时不额外自动跳转。
|
||||||
|
|
||||||
|
## 2. 绑定与客户换号
|
||||||
|
|
||||||
|
- [x] 2.1 实现 F1 四种绑定场景的表单文案、可编辑预填、成功后的分类状态更新;不新增请求字段。
|
||||||
|
- [x] 2.2 对 1192 显示联系管理员提示并停止当前改绑重试,保留 1184 及其他后端错误提示;失败不更新绑定缓存。
|
||||||
|
- [x] 2.3 按用户最新确认,强制绑定成功保留有效 token、identifier 和客户信息,直接进入首页;移除旧重登提示,普通绑定成功仍返回上一页。
|
||||||
|
- [x] 2.4 换号只更新客户手机号,删除新号写入资产缓存的行为;不再声明全部关联迁移/事务回滚。
|
||||||
|
- [x] 2.5 换号后更新客户原号来源并失效可能过期的 asset_phone_is_own;不将原资产标记未绑定,不自动要求改绑。
|
||||||
|
|
||||||
|
## 3. 临期提醒
|
||||||
|
|
||||||
|
- [x] 3.1 保留 expiry_reminder 的可选语义及四个字段,并在 assetApi.getInfo 的共同返回入口接收一次性提醒。
|
||||||
|
- [x] 3.2 实现当前账号/资产隔离的临时共享提醒状态;同次进入的后续空字段响应不能覆盖已接收的提醒。
|
||||||
|
- [x] 3.3 按最新要求,在首页显示可关闭的居中临期弹窗,按后端 red / purple / pink 展示,正确处理 0 天及上海时区的到期时间;与其他弹窗排队展示。
|
||||||
|
- [x] 3.4 检查 TrafficCard、绑定预填、RenewalPaymentPopup 及其他 getInfo 调用方;绑定页恢复 asset/info 预填,提醒保留在同一有效会话内,进入首页展示。
|
||||||
|
- [x] 3.5 从首页旧通知弹窗来源排除临期通知,通知中心保留;不影响其他通知及风险换货/运营弹窗。
|
||||||
|
- [x] 3.6 不为临期提醒新增轮询、每日补发、无字段补弹或自动已读请求。
|
||||||
|
|
||||||
|
## 4. 当前资产通知与全部已读
|
||||||
|
|
||||||
|
- [x] 4.1 按用户最新确认,将 markAllRead 恢复为 PUT /api/c/v1/notifications/read-all,不发送 POST;单条已读继续 PUT。
|
||||||
|
- [x] 4.2 保持列表/未读数入参不变,切换资产后刷新相关结果,禁止旧资产结果覆盖新资产。
|
||||||
|
- [x] 4.3 全部已读成功后按服务端结果对齐当前列表/计数,失败保留未读状态;不在任何提醒/弹窗展示链路调用 read-all。
|
||||||
|
|
||||||
|
## 5. 验证与交付
|
||||||
|
|
||||||
|
- [x] 5.1 通过自动/模拟测试验证四种绑定模式及强制开关关闭;真实微信授权验收另见 IMPLEMENTATION.md。
|
||||||
|
- [x] 5.2 通过模拟测试和本地浏览器验证不同号码输入、1192 联系管理员且不重试、1184 提示、失败状态不变。
|
||||||
|
- [x] 5.3 通过自动/模拟测试验证换号只更新客户、资产关联保持、归属失效及旧缓存/账号/资产隔离。
|
||||||
|
- [x] 5.4 通过自动/模拟测试验证字段缺省、0/3/4/7/8/15 天及等级、上海日期边界、重复进入和 A/B 上下文;浏览器检查 0 天弹窗及关闭行为。
|
||||||
|
- [x] 5.5 通过共享接收和首页脚本测试验证并发/其他调用方先返回提醒不丢失,以及旧首页弹窗只排除临期通知。
|
||||||
|
- [ ] 5.6 用同一客户的 A/B 两资产验证列表、未读数与 A 上 PUT 全部已读;B 的临期通知仍保持未读,普通通知范围不被前端扩大过滤。
|
||||||
|
- [ ] 5.7 验证单条已读仍 PUT、read-all 失败不提前清空、运营/风险换货弹窗和支付/实名/套餐展示不回归。
|
||||||
|
- [x] 5.8 执行 npm run build:h5、npm run build:h5:production,均通过;实施记录明确区分模拟验证与待真实联调场景。
|
||||||
|
- [x] 5.9 执行 openspec validate update-october-batch1-h5-integration --strict,校验通过;仅按实际完成结果更新本任务清单。
|
||||||
|
- [x] 5.10 新增并执行 npm run test:h5-integration,19 组自动/模拟测试通过,覆盖强制绑定、token 保留、弹窗排队、续费复用、PUT 全部已读及通知状态。
|
||||||
|
- [x] 5.11 使用本地模拟预览检查移动端居中临期弹窗、绑定表单预填/不同号输入及 1192 弹窗/禁用操作;不发送真实业务请求。
|
||||||
|
- [x] 5.12 去掉通知卡片左侧未读边框,改为标题右侧未读/已读小按钮;验证点击卡片成功后更新、失败不提前显示已读。
|
||||||
|
- [x] 5.13 移除临期弹窗等级文字标签,新增“立即续费”并复用首页入口;续费窗口打开时不推进其他通知。
|
||||||
|
|
||||||
|
5.6 的客户端 A/B 隔离逻辑、5.7 的单条 PUT 和失败不提前清空均已模拟测试通过;真实后端范围及完整支付/实名/候选弹窗回归未完成,不勾选。
|
||||||
33
openspec/changes/update-payment-routing-policy/design.md
Normal file
33
openspec/changes/update-payment-routing-policy/design.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The August backend change is intentionally compatible at the HTTP surface: the two C端 payment creation paths remain unchanged, while merchant selection and retry semantics move to a stricter merchant-pool policy. The H5 client already has separate WeChat, Alipay, and wallet result handling, submit guards, and post-payment status refreshes; this change must tighten creation-attempt boundaries without changing those payment contracts.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
- Goals: make one accepted user submission correspond to one payment-creation attempt; prevent client-side merchant failover and duplicate creation; preserve backend error semantics; keep status reconciliation read-only.
|
||||||
|
- Goals: make the attempt-count implication explicit so UI retries and polling do not silently exceed payment limits.
|
||||||
|
- Non-Goals: changing endpoint paths, adding merchant credentials/IDs to the client, redesigning payment pages, or changing WeChat/Alipay payment invocation contracts.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Decision: merchant selection is opaque to the client. The client sends the documented request payload and never chooses or rotates a merchant.
|
||||||
|
- Decision: classify an API-declared pre-order failure as terminal for that attempt. The UI reports the backend error and exits the submit flow.
|
||||||
|
- Decision: classify timeout/network interruption as unknown rather than failed-success. The client does not create a replacement payment automatically; it refreshes status where supported and leaves any retry to an explicit user action.
|
||||||
|
- Decision: status polling and page refresh use read-only order/recharge queries. They must not call either payment creation endpoint.
|
||||||
|
- Decision: treat every invocation of a payment creation endpoint as an attempt for quota/count semantics, regardless of the eventual payment result.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- A user may need to retry manually after a transient network problem. This is intentional to avoid duplicate payment creation; provide clear “check status/retry” feedback.
|
||||||
|
- A delayed backend result can leave the UI temporarily indeterminate. Reconciliation through existing status APIs reduces this risk without creating another payment.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Audit API adapters and all payment callers for hidden retries or merchant parameters.
|
||||||
|
2. Add/adjust terminal and unknown-result branches while retaining existing result handlers.
|
||||||
|
3. Verify read-only status refresh and submit guards across package and wallet flows.
|
||||||
|
4. Roll back the client branch if backend rollout is delayed; endpoint contracts remain backward compatible.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Which order/recharge status endpoint and polling window should be used for an unknown creation result in each deployment environment?
|
||||||
29
openspec/changes/update-payment-routing-policy/proposal.md
Normal file
29
openspec/changes/update-payment-routing-policy/proposal.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Change: Align C端 payment flows with merchant-pool routing policy
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The August payment iteration keeps the existing C端 payment creation endpoints, but changes how the backend routes payment creation through its merchant pool. The client must stop assuming that a failed or unknown payment-creation result can be retried against another merchant, otherwise one user action can create duplicate payment attempts and consume payment limits unexpectedly.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Keep `POST /api/c/v1/orders/create` and `POST /api/c/v1/wallet/recharge` as the payment creation endpoints; do not add a merchant ID to client requests.
|
||||||
|
- Treat merchant selection as backend-owned and preserve the selected payment method and existing request fields (`identifier`, package IDs or amount, and WeChat-only `app_type`).
|
||||||
|
- Treat an explicit payment pre-order failure as the terminal result for the current attempt; surface the API error without automatically switching merchants or creating another payment.
|
||||||
|
- Treat timeout, connection interruption, and other unknown payment-creation results as indeterminate; do not automatically switch merchants or repeat payment creation. Any retry must be a deliberate, user-visible action after the applicable order/payment status is reconciled.
|
||||||
|
- Ensure payment-limit and attempt-count behavior is modeled as “every payment creation attempt counts”, rather than “only successful payments count”; client polling/status refresh must never create another payment.
|
||||||
|
- Continue to use the API-defined error codes and messages as the source of truth.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `payment-routing-policy`: Apply merchant-pool routing, terminal-failure, unknown-result, and payment-attempt accounting rules to C端 payment creation.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected API adapters: `api/modules/order.js`, `api/modules/wallet.js`.
|
||||||
|
- Affected payment callers: package order, wallet recharge, order-list payment, and shared payment-result/status refresh flows.
|
||||||
|
- No endpoint path or client merchant-selection configuration changes.
|
||||||
|
- Existing WeChat `pay_config`, Alipay `payment_link`, wallet payment, submit guards, and backend status reconciliation remain compatible.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Payment creation SHALL use the stable C端 API contract
|
||||||
|
|
||||||
|
The client SHALL create package/strong-recharge orders through `POST /api/c/v1/orders/create` and wallet recharge orders through `POST /api/c/v1/wallet/recharge`. It SHALL submit the documented identifier, package or amount fields, and selected `payment_method`; it SHALL submit `app_type` only when required for WeChat payment and SHALL NOT submit a merchant ID.
|
||||||
|
|
||||||
|
#### Scenario: Package order creation uses the stable route
|
||||||
|
|
||||||
|
- **WHEN** a customer confirms a package or strong-recharge order
|
||||||
|
- **THEN** the client SHALL call `POST /api/c/v1/orders/create`
|
||||||
|
- **AND** the request SHALL not contain a client-selected merchant ID
|
||||||
|
|
||||||
|
#### Scenario: Wallet recharge creation uses the stable route
|
||||||
|
|
||||||
|
- **WHEN** a customer confirms a wallet recharge
|
||||||
|
- **THEN** the client SHALL call `POST /api/c/v1/wallet/recharge`
|
||||||
|
- **AND** the request SHALL contain the selected payment method and amount in the documented unit
|
||||||
|
|
||||||
|
### Requirement: Merchant selection SHALL remain backend-owned
|
||||||
|
|
||||||
|
The system SHALL rely on the backend merchant pool to select a payment merchant. The client SHALL not rotate merchants, expose merchant selection, or retry the same creation request against another merchant automatically.
|
||||||
|
|
||||||
|
#### Scenario: Payment creation is accepted
|
||||||
|
|
||||||
|
- **WHEN** the backend receives a valid payment creation request
|
||||||
|
- **THEN** the client SHALL use the returned payment preparation data (`pay_config`, `payment_link`, or wallet result) without attempting merchant selection locally
|
||||||
|
|
||||||
|
### Requirement: Explicit pre-order failure SHALL be terminal for the current attempt
|
||||||
|
|
||||||
|
When a payment creation endpoint returns an explicit failure, the client SHALL treat that attempt as failed, display the API-defined error code/message, and SHALL NOT automatically switch merchants or create another payment.
|
||||||
|
|
||||||
|
#### Scenario: Package order pre-order fails
|
||||||
|
|
||||||
|
- **WHEN** `/api/c/v1/orders/create` returns an error response before payment preparation succeeds
|
||||||
|
- **THEN** the current package/strong-recharge attempt SHALL end as failed
|
||||||
|
- **AND** the client SHALL surface the backend error without an automatic second creation request
|
||||||
|
|
||||||
|
#### Scenario: Wallet recharge pre-order fails
|
||||||
|
|
||||||
|
- **WHEN** `/api/c/v1/wallet/recharge` returns an error response before payment preparation succeeds
|
||||||
|
- **THEN** the current recharge attempt SHALL end as failed
|
||||||
|
- **AND** the client SHALL not switch merchants or silently create a replacement recharge order
|
||||||
|
|
||||||
|
### Requirement: Unknown payment-creation results SHALL not create duplicates
|
||||||
|
|
||||||
|
Timeouts, connection interruptions, and other results where the creation outcome is unknown SHALL be treated as indeterminate. The client SHALL not automatically switch merchants or repeat payment creation, and any status refresh SHALL be read-only.
|
||||||
|
|
||||||
|
#### Scenario: Creation request times out
|
||||||
|
|
||||||
|
- **WHEN** a payment creation request times out or the connection is interrupted before a definitive response is received
|
||||||
|
- **THEN** the client SHALL make no automatic replacement creation request
|
||||||
|
- **AND** the client SHALL allow the existing status-reconciliation flow to determine whether an order or recharge was created
|
||||||
|
|
||||||
|
#### Scenario: Status polling follows an unknown result
|
||||||
|
|
||||||
|
- **WHEN** the client polls or refreshes order/recharge status after an unknown result
|
||||||
|
- **THEN** it SHALL call only read-only status APIs
|
||||||
|
- **AND** it SHALL not count the refresh as a new payment creation attempt or invoke either creation endpoint
|
||||||
|
|
||||||
|
### Requirement: Payment attempt accounting SHALL include every creation attempt
|
||||||
|
|
||||||
|
The client SHALL behave consistently with the backend rule that amount/attempt limits count every invocation of a payment creation endpoint, regardless of whether the resulting payment later succeeds. It SHALL not loop until success or assume a failed/unknown attempt is free.
|
||||||
|
|
||||||
|
#### Scenario: Explicit failure consumes an attempt
|
||||||
|
|
||||||
|
- **WHEN** a payment creation request is sent and the backend returns an explicit failure
|
||||||
|
- **THEN** the client SHALL finish the current flow without automatic repetition
|
||||||
|
- **AND** subsequent creation SHALL require a new deliberate user action
|
||||||
|
|
||||||
|
#### Scenario: Successful payment follows one creation attempt
|
||||||
|
|
||||||
|
- **WHEN** a payment creation request returns valid payment preparation data
|
||||||
|
- **THEN** the client SHALL proceed with the existing WeChat, Alipay, or wallet flow
|
||||||
|
- **AND** status polling or payment-link return handling SHALL not create an additional payment
|
||||||
|
|
||||||
|
### Requirement: Existing payment result contracts SHALL remain compatible
|
||||||
|
|
||||||
|
The routing-policy change SHALL preserve existing WeChat `pay_config`, Alipay `payment_link`, wallet payment, submit-guard, and backend status-reconciliation behavior. API-defined error codes and messages SHALL remain authoritative.
|
||||||
|
|
||||||
|
#### Scenario: WeChat or Alipay preparation succeeds
|
||||||
|
|
||||||
|
- **WHEN** the backend returns valid `pay_config` or `payment_link`
|
||||||
|
- **THEN** the client SHALL invoke the existing corresponding payment flow
|
||||||
|
- **AND** it SHALL determine final success from the existing backend status refresh rather than from a routing decision
|
||||||
|
|
||||||
|
#### Scenario: Backend returns a documented error
|
||||||
|
|
||||||
|
- **WHEN** the API returns a documented error code and message
|
||||||
|
- **THEN** the client SHALL preserve and present that error according to the existing error-handling behavior
|
||||||
|
- **AND** it SHALL not replace it with a merchant-specific or generic retry result
|
||||||
19
openspec/changes/update-payment-routing-policy/tasks.md
Normal file
19
openspec/changes/update-payment-routing-policy/tasks.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## 1. Request contract and routing
|
||||||
|
|
||||||
|
- [x] 1.1 Audit order and wallet payment creation adapters to keep the documented endpoint paths and required fields unchanged.
|
||||||
|
- [x] 1.2 Ensure no payment creation request accepts or forwards a merchant ID; keep `app_type` conditional on WeChat payment only.
|
||||||
|
- [x] 1.3 Document the backend-owned merchant-pool selection and the fact that the client must issue one creation request per accepted user action.
|
||||||
|
|
||||||
|
## 2. Failure and unknown-result handling
|
||||||
|
|
||||||
|
- [x] 2.1 Handle explicit pre-order failures as terminal for the current attempt and display the backend-provided error code/message.
|
||||||
|
- [x] 2.2 Ensure timeout, connection interruption, and other unknown creation results do not trigger merchant switching or a second automatic creation request.
|
||||||
|
- [x] 2.3 Keep status polling/refresh flows read-only; they must reconcile order or recharge state without creating another payment.
|
||||||
|
- [x] 2.4 Provide a deliberate, user-visible retry path only after the current order/payment state can be safely reconciled, preserving existing submit guards.
|
||||||
|
|
||||||
|
## 3. Attempt accounting and regression verification
|
||||||
|
|
||||||
|
- [x] 3.1 Verify client behavior does not treat a failed or unknown attempt as free and does not loop until payment succeeds.
|
||||||
|
- [x] 3.2 Verify package order creation and wallet recharge each make at most one payment-creation call per accepted submission.
|
||||||
|
- [x] 3.3 Verify WeChat, Alipay, and wallet result handling remains unchanged after routing-policy updates.
|
||||||
|
- [x] 3.4 Verify API error codes/messages are passed through according to the interface documentation.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The August 2026 customer API document (`docs/产品迭代8月份/11.md`) freezes the customer-side authentication contracts used by H5. The behavioral contract changes are:
|
||||||
|
|
||||||
|
- `need_bind_phone` widens to a three-state decision: no main phone, or main phone exists but the current asset is unassociated -> `true`; already associated -> `false`; global switch off -> always `false`. The frontend still uses it only to decide whether to guide the user into phone verification.
|
||||||
|
- `POST /api/c/v1/auth/bind-phone` becomes idempotent: when the account already has a main phone, submitting the same main phone plus a valid code creates the association instead of rejecting it.
|
||||||
|
- `POST /api/c/v1/auth/change-phone` migrates all valid phone-asset associations in one transaction and rolls back the whole operation on the 10-asset limit or an existing same-asset relation.
|
||||||
|
|
||||||
|
The current H5 bind page starts with an empty phone input and has no prefill. Existing users who have a main phone but whose current asset is unassociated are guided to verify, yet every submitted number is rejected - a deadlock. Homepage-gate and login routing must also follow the widened `need_bind_phone` semantics instead of raw bound-phone presence.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Bind page prefills the phone input with the account main phone from `GET /api/c/v1/asset/info` -> `bound_phone` and allows sending a `bind_phone` verification code to that number
|
||||||
|
- Bind submission treats `bind-phone` as idempotent for the account main phone; other numbers show the backend `msg` and keep the user on the page
|
||||||
|
- Keep the existing bind-completion flow: ordinary entry returns back, mandatory login-gate entry triggers the manual re-login flow
|
||||||
|
- `change-phone` keeps the old/new phone and verification-code inputs with the `change_phone_old` / `change_phone_new` send-code scenes; failures show backend rollback copy (10-item limit and same-asset conflict) and keep the user on the page
|
||||||
|
- Login and homepage entry keep `need_bind_phone` as the only phone-verification guide signal and follow the three-state semantic
|
||||||
|
- `send-code` request/response stays unchanged (`cooldown_seconds`); the new `verify-asset` fields `miniapp_app_id` / `oa_app_id` are a backend confirm item, not consumed yet
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `phone-bind-association-prefill`: Define bind-page prefill of the account main phone and idempotent bind submission behavior
|
||||||
|
- `phone-change-migration`: Define change-phone transaction-migration behavior and backend rollback copy display
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `index-phone-bind-gate`: Widen the bind-phone gate to the three-state `need_bind_phone` semantic
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected code: `pages/bind/bind.vue`, `pages/change-phone/change-phone.vue`, `pages/index/index.vue`, `pages/login/login.vue`
|
||||||
|
- API module: `api/modules/auth.js` unchanged - endpoints and response shapes already match `11.md`
|
||||||
|
- Backend contract deltas (per `11.md`): bind-phone idempotency, change-phone transactional migration, three-state `need_bind_phone`
|
||||||
|
- Backend decisions (confirmed 2026-09-15):
|
||||||
|
- `need_bind_phone` stays wechat-login-only; `asset/info` and `verify-asset` do not add the field. The homepage gate keeps evaluating the per-entry association state via `asset/info.bound_phone`.
|
||||||
|
- No new prefill field: bind-page prefill keeps using `asset/info.bound_phone`.
|
||||||
|
- `verify-asset`'s new `miniapp_app_id` / `oa_app_id` are not consumed; H5 login keeps using `/api/c/v1/wechat/appid`, and `/api/c/v1/auth/wechat-login` remains the login authority.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Index entry SHALL enforce the bind-phone gate from the asset association state
|
||||||
|
The system SHALL determine whether phone-verification guidance is required whenever the homepage is entered, using the current asset association state returned by `GET /api/c/v1/asset/info` -> `bound_phone` (empty means the asset is not associated and binding is required). The widened `need_bind_phone` decision stays wechat-login-only: no main phone -> required; main phone exists but the current asset is unassociated -> required; already associated -> not required; global switch off -> never required. The frontend still uses the signal only to decide whether to guide the user, and the backend does not block any business interface on the missing relation.
|
||||||
|
|
||||||
|
#### Scenario: User has no main phone
|
||||||
|
- **WHEN** the account has no main phone and the user enters `pages/index/index`
|
||||||
|
- **THEN** the system SHALL treat phone verification as required
|
||||||
|
- **AND** the system SHALL route the user into the bind-phone guide
|
||||||
|
|
||||||
|
#### Scenario: User has a main phone but the current asset is unassociated
|
||||||
|
- **WHEN** the account has a main phone but the current asset is not associated with it
|
||||||
|
- **THEN** the system SHALL treat phone verification as required
|
||||||
|
- **AND** the bind page SHALL prefill the account main phone from `asset/info.bound_phone`
|
||||||
|
|
||||||
|
#### Scenario: User is already associated
|
||||||
|
- **WHEN** the current asset is already associated with the account main phone
|
||||||
|
- **THEN** the system SHALL keep the user on the homepage
|
||||||
|
- **AND** the system SHALL NOT route the user into the bind-phone guide
|
||||||
|
|
||||||
|
#### Scenario: Global switch off
|
||||||
|
- **WHEN** the global phone-bind switch is off
|
||||||
|
- **THEN** the system SHALL never route the user into the bind-phone guide regardless of account state
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Bind page SHALL prefill the account main phone
|
||||||
|
The system SHALL prefill the bind-phone input with the logged-in account's main phone when one exists, using `GET /api/c/v1/asset/info` -> `bound_phone`, and SHALL allow sending a `bind_phone` verification code to that number.
|
||||||
|
|
||||||
|
#### Scenario: Existing account with a main phone enters the bind page
|
||||||
|
- **WHEN** the account already has a main phone and the user enters `pages/bind/bind`
|
||||||
|
- **THEN** the phone input SHALL be prefilled with the main phone
|
||||||
|
- **AND** the user SHALL be able to request a `bind_phone` verification code for that number without being rejected
|
||||||
|
|
||||||
|
#### Scenario: New account without a main phone enters the bind page
|
||||||
|
- **WHEN** the account has no main phone
|
||||||
|
- **THEN** the phone input SHALL remain empty
|
||||||
|
- **AND** the user SHALL enter a new number and follow the normal bind flow
|
||||||
|
|
||||||
|
### Requirement: Bind submission SHALL be idempotent for the account main phone
|
||||||
|
When `POST /api/c/v1/auth/bind-phone` is submitted with the account main phone and a valid code, the system SHALL treat it as a successful idempotent association instead of rejecting because the phone is already in use.
|
||||||
|
|
||||||
|
#### Scenario: Main phone plus valid code
|
||||||
|
- **WHEN** the submitted phone equals the account main phone and the code is valid
|
||||||
|
- **THEN** the bind request SHALL succeed
|
||||||
|
- **AND** the response SHALL keep the existing shape (`phone`, `bound_at`)
|
||||||
|
- **AND** the existing bind-completion routing SHALL apply unchanged
|
||||||
|
|
||||||
|
#### Scenario: Number different from the main phone
|
||||||
|
- **WHEN** the submitted phone differs from the account main phone
|
||||||
|
- **THEN** the bind request SHALL be rejected
|
||||||
|
- **AND** the system SHALL display the backend `msg` and keep the user on the bind page
|
||||||
|
|
||||||
|
### Requirement: Bind failures SHALL display backend messages
|
||||||
|
The system SHALL present the backend-provided error message for all bind-phone failures instead of a generic failure text, and SHALL keep the user on the bind page.
|
||||||
|
|
||||||
|
#### Scenario: Backend rejection with message
|
||||||
|
- **WHEN** `bind-phone` returns a business error
|
||||||
|
- **THEN** the bind page SHALL show the returned `msg`
|
||||||
|
- **AND** the page SHALL NOT navigate away
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Change-phone SHALL follow transactional full migration
|
||||||
|
The system SHALL rely on `POST /api/c/v1/auth/change-phone` to migrate all valid phone-asset associations in one transaction, and SHALL treat any failure as a rollback of the whole operation.
|
||||||
|
|
||||||
|
#### Scenario: Successful migration
|
||||||
|
- **WHEN** the old-phone code and the new-phone code are valid and no limit or conflict occurs
|
||||||
|
- **THEN** the change request SHALL succeed
|
||||||
|
- **AND** the response SHALL keep the existing shape (`phone`, `changed_at`)
|
||||||
|
- **AND** the user SHALL return to the previous page and the homepage SHALL refresh the bound state
|
||||||
|
|
||||||
|
#### Scenario: New number exceeds the association limit
|
||||||
|
- **WHEN** the new number would exceed 10 valid associated assets
|
||||||
|
- **THEN** the whole operation SHALL be rolled back
|
||||||
|
- **AND** the system SHALL display the backend copy: 该手机号最多关联10项有效资产
|
||||||
|
|
||||||
|
#### Scenario: New number conflicts with an existing same-asset relation
|
||||||
|
- **WHEN** the new number already has a valid association with an asset being migrated
|
||||||
|
- **THEN** the whole operation SHALL be rolled back
|
||||||
|
- **AND** the system SHALL display the backend copy: 新手机号已存在与待迁移资产相同的有效关联,换绑已回滚
|
||||||
|
|
||||||
|
### Requirement: Change-phone failures SHALL keep the user on the page
|
||||||
|
The system SHALL stay on the change-phone page and show the backend error message when migration fails so the user can correct the input and retry.
|
||||||
|
|
||||||
|
#### Scenario: Migration failure with message
|
||||||
|
- **WHEN** `change-phone` returns a business error
|
||||||
|
- **THEN** the page SHALL show the returned `msg`
|
||||||
|
- **AND** the page SHALL NOT navigate away
|
||||||
|
|
||||||
29
openspec/changes/update-phone-bind-association-flow/tasks.md
Normal file
29
openspec/changes/update-phone-bind-association-flow/tasks.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
## 1. Bind Page Prefill and Idempotent Bind
|
||||||
|
|
||||||
|
- [x] 1.1 Load the account main phone on bind-page mount via `assetApi.getInfo` and prefill the phone input when present
|
||||||
|
- [x] 1.2 Allow sending a `bind_phone` verification code to the prefilled main phone
|
||||||
|
- [x] 1.3 Keep ordinary bind success returning to the previous page
|
||||||
|
- [x] 1.4 Keep mandatory login-gate bind success returning to the login page for manual re-login
|
||||||
|
- [x] 1.5 Show backend `msg` on bind rejection and stay on the page
|
||||||
|
|
||||||
|
## 2. Change-Phone Transactional Migration Display
|
||||||
|
|
||||||
|
- [x] 2.1 Keep old/new phone and verification-code inputs with `change_phone_old` / `change_phone_new` scenes
|
||||||
|
- [x] 2.2 Show backend rollback copy for the 10-item limit and the same-asset conflict
|
||||||
|
- [x] 2.3 Stay on the change-phone page on failure
|
||||||
|
- [x] 2.4 Refresh the homepage bound state after success (existing `onShow` reload)
|
||||||
|
|
||||||
|
## 3. Three-State Need-Bind-Phone Routing
|
||||||
|
|
||||||
|
- [x] 3.1 Keep `need_bind_phone` as the only phone-verification guide signal in login success
|
||||||
|
- [x] 3.2 Evaluate the three-state semantic in the homepage bind gate
|
||||||
|
- [x] 3.3 Re-check the binding state on every homepage entry, not only after login
|
||||||
|
- [x] 3.4 Confirm the homepage gate signal source with backend - decision: keep `asset/info.bound_phone`; `need_bind_phone` stays wechat-login-only
|
||||||
|
|
||||||
|
## 4. Regression Verification
|
||||||
|
|
||||||
|
- [ ] 4.1 New user without a main phone still binds a fresh number
|
||||||
|
- [ ] 4.2 Existing user with a main phone and an unassociated asset can prefill, send code, and bind idempotently
|
||||||
|
- [ ] 4.3 Bind with a number different from the main phone shows the backend rejection message
|
||||||
|
- [ ] 4.4 Change-phone limit/conflict errors show the fixed backend copy
|
||||||
|
- [ ] 4.5 Global-switch-off keeps the bind guide hidden
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:h5": "uni",
|
"dev:h5": "uni",
|
||||||
"build:h5": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode development",
|
"build:h5": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode development",
|
||||||
"build:h5:production": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode production"
|
"build:h5:production": "cross-env UNI_INPUT_DIR=. uni build -p h5 --mode production",
|
||||||
|
"test:h5-integration": "node --experimental-vm-modules --test tests/october-h5.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dcloudio/types": "3.4.19",
|
"@dcloudio/types": "3.4.19",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<!-- 空状态提示 -->
|
|
||||||
<view v-if="!historyLoaded || (loading && packageList.length === 0)" class="loading-state">
|
<view v-if="!historyLoaded || (loading && packageList.length === 0)" class="loading-state">
|
||||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||||
</view>
|
</view>
|
||||||
@@ -10,169 +9,82 @@
|
|||||||
<view class="empty-desc">当前账号下暂无套餐历史信息</view>
|
<view class="empty-desc">当前账号下暂无套餐历史信息</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 套餐列表 -->
|
<view v-else>
|
||||||
<view v-else class="card package-card" v-for="(item, index) in packageList" :key="index">
|
<PackageHistoryNode
|
||||||
<view class="package-header flex-row-sb">
|
v-for="(item, index) in packageList"
|
||||||
<view class="package-name">{{ item.package_name }}</view>
|
:key="getGroupKey(item, index)"
|
||||||
<view class="tag-apple" :class="getStatusClass(item.status)">{{ item.status_name }}</view>
|
:node="item"
|
||||||
</view>
|
/>
|
||||||
|
|
||||||
<view class="package-info">
|
|
||||||
<view class="info-row flex-row-sb">
|
|
||||||
<view class="info-label">激活时间</view>
|
|
||||||
<view class="info-value">{{ item.activated_at || '-' }}</view>
|
|
||||||
</view>
|
|
||||||
<view class="info-row flex-row-sb">
|
|
||||||
<view class="info-label">到期时间</view>
|
|
||||||
<view class="info-value">{{ item.expires_at || '-' }}</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="divider"></view>
|
|
||||||
|
|
||||||
<view class="flow-info">
|
|
||||||
<view class="flow-title">流量信息</view>
|
|
||||||
<view class="flow-stats">
|
|
||||||
<view class="flow-item">
|
|
||||||
<view class="flow-label">已使用</view>
|
|
||||||
<view class="flow-value">{{ getUsedFlow(item) }}</view>
|
|
||||||
</view>
|
|
||||||
<view class="flow-item">
|
|
||||||
<view class="flow-label">总流量</view>
|
|
||||||
<view class="flow-value">{{ getTotalFlow(item) }}</view>
|
|
||||||
</view>
|
|
||||||
<view class="flow-item">
|
|
||||||
<view class="flow-label">剩余</view>
|
|
||||||
<view class="flow-value">{{ getRemainFlow(item) }}</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="progress-section">
|
|
||||||
<view class="progress-apple">
|
|
||||||
<view class="progress-fill" :style="{width: getUsagePercent(item) + '%'}"></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 加载更多 -->
|
<view v-if="packageList.length > 0" class="load-more">
|
||||||
<view class="load-more" v-if="packageList.length > 0">
|
|
||||||
<text v-if="loading">加载中...</text>
|
<text v-if="loading">加载中...</text>
|
||||||
<text v-else-if="noMore">没有更多了</text>
|
<text v-else-if="noMore">没有更多套餐</text>
|
||||||
<text v-else @tap="loadMore">点击加载更多</text>
|
<text v-else role="button" tabindex="0" @tap="loadMore">点击加载更多</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { assetApi } from '@/api/index.js';
|
import { assetApi } from '@/api/index.js';
|
||||||
|
import PackageHistoryNode from '@/components/PackageHistoryNode.vue';
|
||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const packageList = ref([]);
|
||||||
let packageList = reactive([]);
|
const loading = ref(false);
|
||||||
let loading = ref(false);
|
const historyLoaded = ref(false);
|
||||||
let historyLoaded = ref(false);
|
const noMore = ref(false);
|
||||||
let noMore = ref(false);
|
const page = ref(1);
|
||||||
let page = ref(1);
|
const total = ref(0);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
const getStatusClass = (status) => {
|
const getGroupKey = (item, index) => item.package_usage_id ?? `group-${index}`;
|
||||||
const classMap = {
|
|
||||||
'0': 'tag-warning',
|
|
||||||
'1': 'tag-success',
|
|
||||||
'2': 'tag-primary',
|
|
||||||
'3': 'tag-secondary',
|
|
||||||
'4': 'tag-danger'
|
|
||||||
};
|
|
||||||
return classMap[status] || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMB = (mb) => {
|
|
||||||
if (!mb && mb !== 0) return '0 MB';
|
|
||||||
if (mb >= 1024) {
|
|
||||||
return (mb / 1024).toFixed(2) + ' GB';
|
|
||||||
}
|
|
||||||
return mb.toFixed(2) + ' MB';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取已使用流量
|
|
||||||
const getUsedFlow = (item) => {
|
|
||||||
if (item.enable_virtual_data) {
|
|
||||||
return formatMB(item.virtual_used_mb || 0);
|
|
||||||
} else {
|
|
||||||
return formatMB(item.real_used_mb || 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取总流量
|
|
||||||
const getTotalFlow = (item) => {
|
|
||||||
return formatMB(item.real_total_mb || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取剩余流量
|
|
||||||
const getRemainFlow = (item) => {
|
|
||||||
const total = item.real_total_mb || 0;
|
|
||||||
const used = item.enable_virtual_data ? (item.virtual_used_mb || 0) : (item.real_used_mb || 0);
|
|
||||||
const remain = Math.max(total - used, 0);
|
|
||||||
return formatMB(remain);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取使用百分比
|
|
||||||
const getUsagePercent = (item) => {
|
|
||||||
const used = item.real_used_mb || 0;
|
|
||||||
const total = item.real_total_mb || 0;
|
|
||||||
|
|
||||||
if (!total) return 0;
|
|
||||||
return Math.min((used / total) * 100, 100).toFixed(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
|
||||||
if (!dateStr) return '-';
|
|
||||||
return dateStr.split('T').join(' ').slice(0, 19);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadPackageList = async (append = false) => {
|
const loadPackageList = async (append = false) => {
|
||||||
if (loading.value || noMore.value) return;
|
if (loading.value || (append && noMore.value)) return;
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await assetApi.getPackageHistory(
|
let groups;
|
||||||
userStore.state.identifier,
|
do {
|
||||||
page.value,
|
const requestedPage = page.value;
|
||||||
pageSize
|
const data = await assetApi.getPackageHistory(
|
||||||
);
|
userStore.state.identifier,
|
||||||
|
requestedPage,
|
||||||
const newData = (data.items || []).map(item => ({
|
pageSize
|
||||||
...item,
|
);
|
||||||
activated_at: item.activated_at ? formatDate(item.activated_at) : '',
|
groups = data.items;
|
||||||
created_at: formatDate(item.created_at),
|
const responsePage = Number.isInteger(data.page) && data.page > 0
|
||||||
expires_at: item.expires_at ? formatDate(item.expires_at) : ''
|
? Math.max(data.page, requestedPage)
|
||||||
}));
|
: requestedPage;
|
||||||
|
const responseSize = Number.isInteger(data.size) && data.size > 0
|
||||||
|
? data.size
|
||||||
|
: pageSize;
|
||||||
|
total.value = Number.isFinite(data.total) && data.total >= 0 ? data.total : 0;
|
||||||
|
// Total and pagination describe the unfiltered backend pages. Skip a
|
||||||
|
// page containing only invalidated records so later records stay reachable.
|
||||||
|
noMore.value = responsePage * responseSize >= total.value;
|
||||||
|
page.value = responsePage + 1;
|
||||||
|
} while (groups.length === 0 && !noMore.value);
|
||||||
|
|
||||||
if (append) {
|
if (append) {
|
||||||
packageList.push(...newData);
|
packageList.value.push(...groups);
|
||||||
} else {
|
} else {
|
||||||
packageList.splice(0, packageList.length, ...newData);
|
packageList.value = groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newData.length < pageSize) {
|
} catch (error) {
|
||||||
noMore.value = true;
|
// The shared request layer handles the API's unified error response and authentication errors.
|
||||||
} else {
|
console.error('加载套餐历史失败', error);
|
||||||
page.value++;
|
} finally {
|
||||||
}
|
loading.value = false;
|
||||||
} catch (e) {
|
historyLoaded.value = true;
|
||||||
console.error('加载套餐历史失败', e);
|
|
||||||
}
|
}
|
||||||
loading.value = false;
|
|
||||||
historyLoaded.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMore = () => {
|
const loadMore = () => loadPackageList(true);
|
||||||
if (!noMore.value) {
|
|
||||||
loadPackageList(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPackageList();
|
loadPackageList();
|
||||||
@@ -180,83 +92,46 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.container {
|
.loading-state {
|
||||||
.loading-state {
|
display: flex;
|
||||||
display: flex;
|
align-items: center;
|
||||||
align-items: center;
|
justify-content: center;
|
||||||
justify-content: center;
|
min-height: 500rpx;
|
||||||
min-height: 500rpx;
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400rpx;
|
||||||
|
padding: 120rpx 40rpx;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-title {
|
||||||
display: flex;
|
margin-bottom: 16rpx;
|
||||||
flex-direction: column;
|
font-size: 32rpx;
|
||||||
align-items: center;
|
font-weight: 600;
|
||||||
justify-content: center;
|
color: var(--text-primary);
|
||||||
padding: 120rpx 40rpx;
|
|
||||||
min-height: 400rpx;
|
|
||||||
.empty-icon { width: 120rpx; height: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
|
||||||
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
|
||||||
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.package-card {
|
.empty-desc {
|
||||||
margin-bottom: var(--space-md);
|
font-size: 26rpx;
|
||||||
.package-header {
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
.package-name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.package-info {
|
|
||||||
.info-row {
|
|
||||||
padding: var(--space-xs) 0;
|
|
||||||
.info-label { font-size: 24rpx; color: var(--text-tertiary); }
|
|
||||||
.info-value { font-size: 24rpx; color: var(--text-primary); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1rpx;
|
|
||||||
background: var(--gray-200);
|
|
||||||
margin: var(--space-md) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flow-info {
|
|
||||||
.flow-title { font-size: 26rpx; font-weight: 600; color: var(--text-primary); margin-bottom: var(--space-sm); }
|
|
||||||
.flow-stats {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
.flow-item {
|
|
||||||
text-align: center;
|
|
||||||
.flow-label { font-size: 20rpx; color: var(--text-tertiary); margin-bottom: 4rpx; }
|
|
||||||
.flow-value { font-size: 26rpx; font-weight: 600; color: var(--text-primary); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.progress-section {
|
|
||||||
.progress-apple {
|
|
||||||
width: 100%;
|
|
||||||
height: 8rpx;
|
|
||||||
background: var(--gray-200);
|
|
||||||
border-radius: var(--radius-small);
|
|
||||||
overflow: hidden;
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
|
||||||
border-radius: var(--radius-small);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-lg);
|
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 24rpx;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-secondary { background: var(--gray-200) !important; color: var(--gray-600) !important; }
|
.load-more {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,8 +21,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="carrier-iccid">
|
<view class="carrier-iccid">
|
||||||
<text class="iccid-value-text">{{ item.iccid }}</text>
|
<text class="iccid-value-text">{{ item.iccid }}</text>
|
||||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"
|
<button class="copy-button" @tap.stop="copyIccid(item.iccid)" aria-label="复制ICCID">复制</button>
|
||||||
@tap.stop="copyIccid(item.iccid)" aria-label="复制ICCID"></image>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="card-actions">
|
<view class="card-actions">
|
||||||
<button v-if="item.isRealName" class="btn-apple btn-primary action-button" disabled>已实名</button>
|
<button v-if="item.isRealName" class="btn-apple btn-primary action-button" disabled>已实名</button>
|
||||||
@@ -240,13 +239,6 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-icon {
|
|
||||||
width: 30rpx;
|
|
||||||
height: 30rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 20rpx;
|
margin-top: 20rpx;
|
||||||
|
|||||||
@@ -5,12 +5,14 @@
|
|||||||
<view class="navbar-back" @tap="goBack">
|
<view class="navbar-back" @tap="goBack">
|
||||||
<image class="back-icon" src="../../static/left.png" mode="aspectFit" />
|
<image class="back-icon" src="../../static/left.png" mode="aspectFit" />
|
||||||
</view>
|
</view>
|
||||||
<view class="navbar-title">绑定手机号</view>
|
<view class="navbar-title">{{ bindingTitle }}</view>
|
||||||
<view class="navbar-placeholder"></view>
|
<view class="navbar-placeholder"></view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="container" :style="{ paddingTop: fromLogin ? '0' : '0' }">
|
<view class="container" :style="{ paddingTop: fromLogin ? '0' : '0' }">
|
||||||
<view class="card flex-col-g20">
|
<view class="card flex-col-g20">
|
||||||
|
<view class="binding-description">{{ bindingDescription }}</view>
|
||||||
|
<view v-if="assetConflict" class="binding-conflict">该资产已绑定手机号,如需更换请联系管理员</view>
|
||||||
<view class="flex-row-g20">
|
<view class="flex-row-g20">
|
||||||
<label for="">手机号:</label>
|
<label for="">手机号:</label>
|
||||||
<up-input placeholder="请输入绑定的手机号" border="surround" v-model="bind.phone" />
|
<up-input placeholder="请输入绑定的手机号" border="surround" v-model="bind.phone" />
|
||||||
@@ -20,13 +22,13 @@
|
|||||||
<label for="">验证码:</label>
|
<label for="">验证码:</label>
|
||||||
<up-input placeholder="验证码" v-model="bind.code">
|
<up-input placeholder="验证码" v-model="bind.code">
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<up-button @tap="getCode" :text="codeText" type="success" :disabled="cooldown > 0"></up-button>
|
<up-button @tap="getCode" :text="codeText" type="success" :disabled="cooldown > 0 || assetConflict || initializing || bindingMode === 'none'"></up-button>
|
||||||
</template>
|
</template>
|
||||||
</up-input>
|
</up-input>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="btn">
|
<view class="btn">
|
||||||
<up-button type="primary" @tap="bindPhone">绑定手机号</up-button>
|
<up-button type="primary" @tap="bindPhone" :loading="submitting" :disabled="initializing || submitting || assetConflict || bindingMode === 'none'">{{ bindingTitle }}</up-button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 如果从登录页面跳转过来,显示退出按钮 -->
|
<!-- 如果从登录页面跳转过来,显示退出按钮 -->
|
||||||
@@ -39,13 +41,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { authApi } from '@/api/index.js';
|
import { authApi, assetApi } from '@/api/index.js';
|
||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
import { getPhoneBindingMode } from '@/utils/phone-bind.js';
|
||||||
const POST_BIND_RELOGIN_NOTICE_KEY = 'postBindReloginNotice';
|
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const initializing = ref(true);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const assetConflict = ref(false);
|
||||||
|
const bindingMode = computed(() => getPhoneBindingMode(userStore.state.customerPhone, userStore.state.assetPhoneBound));
|
||||||
|
const bindingTitle = computed(() => {
|
||||||
|
if (bindingMode.value === 'customer') return '绑定客户手机号';
|
||||||
|
if (bindingMode.value === 'asset') return '给资产绑定手机号';
|
||||||
|
return '绑定手机号';
|
||||||
|
});
|
||||||
|
const bindingDescription = computed(() => ({
|
||||||
|
customer: '请绑定您自己的手机号,当前资产的手机号关联不会改变。',
|
||||||
|
asset: '请给当前资产绑定手机号,可使用与您的客户手机号不同的号码。',
|
||||||
|
'customer-asset': '验证一次手机号,将同时绑定您的客户账号和当前资产。',
|
||||||
|
none: '您的客户账号和当前资产均已绑定手机号,无需重复绑定。',
|
||||||
|
unknown: '请验证手机号。客户与资产的绑定将由系统按当前状态处理,已有的资产手机号不会被替换。'
|
||||||
|
}[bindingMode.value]));
|
||||||
|
|
||||||
let bind = reactive({
|
let bind = reactive({
|
||||||
phone: '',
|
phone: '',
|
||||||
@@ -57,32 +75,41 @@
|
|||||||
let timer = null;
|
let timer = null;
|
||||||
let fromLogin = ref(false);
|
let fromLogin = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// 获取页面参数,判断是否从登录页面跳转过来
|
// 获取页面参数,判断是否从登录页面跳转过来
|
||||||
const pages = getCurrentPages();
|
const pages = getCurrentPages();
|
||||||
const currentPage = pages[pages.length - 1];
|
const currentPage = pages[pages.length - 1];
|
||||||
const options = currentPage.options;
|
const options = currentPage.options;
|
||||||
fromLogin.value = options.fromLogin === 'true';
|
fromLogin.value = options.fromLogin === 'true';
|
||||||
|
|
||||||
|
if (userStore.state.customerPhone) bind.phone = userStore.state.customerPhone;
|
||||||
|
// Binding keeps the current session. Any one-time expiry reminder from
|
||||||
|
// this prefill request is retained by the shared receiver for the homepage.
|
||||||
|
const token = uni.getStorageSync('token');
|
||||||
|
const identifier = userStore.state.identifier || uni.getStorageSync('identifier') || '';
|
||||||
|
if (!token || !identifier) {
|
||||||
|
initializing.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = getSessionContext();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await assetApi.getInfo(identifier);
|
||||||
|
if (!isCurrentSession(context)) return;
|
||||||
|
const mainPhone = data?.bound_phone || '';
|
||||||
|
if (mainPhone) {
|
||||||
|
bind.phone = mainPhone;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 预填失败不阻塞绑定流程,用户仍可手动输入手机号
|
||||||
|
console.error('预填客户手机号失败');
|
||||||
|
} finally {
|
||||||
|
initializing.value = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const prepareManualRelogin = () => {
|
|
||||||
const preservedIdentifier = userStore.state.identifier || uni.getStorageSync('identifier') || '';
|
|
||||||
|
|
||||||
userStore.clearUser();
|
|
||||||
if (preservedIdentifier) {
|
|
||||||
userStore.setIdentifier(preservedIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.setStorageSync(POST_BIND_RELOGIN_NOTICE_KEY, '1');
|
|
||||||
|
|
||||||
// #ifdef H5
|
|
||||||
if (typeof sessionStorage !== 'undefined') {
|
|
||||||
sessionStorage.removeItem('assetToken');
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCode = async () => {
|
const getCode = async () => {
|
||||||
|
if (cooldown.value > 0 || initializing.value || assetConflict.value || bindingMode.value === 'none') return;
|
||||||
if (!bind.phone) {
|
if (!bind.phone) {
|
||||||
uni.showToast({ title: '请输入手机号', icon: 'none' });
|
uni.showToast({ title: '请输入手机号', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
@@ -93,8 +120,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const context = getSessionContext();
|
||||||
try {
|
try {
|
||||||
const data = await authApi.sendCode(bind.phone, 'bind_phone');
|
const data = await authApi.sendCode(bind.phone, 'bind_phone');
|
||||||
|
if (!isCurrentSession(context)) return;
|
||||||
cooldown.value = data.cooldown_seconds || 60;
|
cooldown.value = data.cooldown_seconds || 60;
|
||||||
codeText.value = `${cooldown.value}s`;
|
codeText.value = `${cooldown.value}s`;
|
||||||
|
|
||||||
@@ -115,27 +144,46 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const bindPhone = async () => {
|
const bindPhone = async () => {
|
||||||
|
if (initializing.value || submitting.value || assetConflict.value || bindingMode.value === 'none') return;
|
||||||
if (!bind.phone || !bind.code) {
|
if (!bind.phone || !bind.code) {
|
||||||
uni.showToast({ title: '手机号和验证码都不能为空', icon: 'none' });
|
uni.showToast({ title: '手机号和验证码都不能为空', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(bind.phone)) {
|
||||||
|
uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = getSessionContext();
|
||||||
|
const mode = bindingMode.value;
|
||||||
|
const submittedPhone = bind.phone;
|
||||||
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await authApi.bindPhone(bind.phone, bind.code);
|
await authApi.bindPhone(submittedPhone, bind.code);
|
||||||
|
if (!isCurrentSession(context)) return;
|
||||||
|
userStore.completePhoneBinding(submittedPhone, mode);
|
||||||
|
|
||||||
if (fromLogin.value) {
|
if (fromLogin.value) {
|
||||||
prepareManualRelogin();
|
uni.removeStorageSync('postBindReloginNotice');
|
||||||
uni.reLaunch({ url: '/pages/login/login' });
|
uni.reLaunch({ url: '/pages/index/index' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uni.showToast({ title: '绑定成功', icon: 'success' });
|
uni.showToast({ title: '绑定成功', icon: 'success' });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 从其他页面过来的,返回上一页
|
// 从其他页面过来的,返回上一页
|
||||||
uni.navigateBack();
|
if (isCurrentSession(context)) uni.navigateBack();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('绑定手机号失败', e);
|
if (!isCurrentSession(context)) return;
|
||||||
|
if (Number(e?.code) === 1192) {
|
||||||
|
assetConflict.value = true;
|
||||||
|
uni.showModal({ title: '无法更换资产手机号', content: '该资产已绑定手机号,如需更换请联系管理员', showCancel: false });
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: e?.msg || e?.message || '绑定失败,请稍后重试', icon: 'none' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,12 +193,14 @@
|
|||||||
content: '确定要退出登录吗?',
|
content: '确定要退出登录吗?',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
|
userStore.clearUser();
|
||||||
uni.clearStorageSync();
|
uni.clearStorageSync();
|
||||||
uni.reLaunch({ url: '/pages/login/login' });
|
uni.reLaunch({ url: '/pages/login/login' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
onUnmounted(() => clearInterval(timer));
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
@@ -217,4 +267,6 @@
|
|||||||
.container {
|
.container {
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
}
|
}
|
||||||
|
.binding-description { color: var(--text-secondary); font-size: 26rpx; line-height: 1.6; }
|
||||||
|
.binding-conflict { color: var(--danger); font-size: 26rpx; line-height: 1.6; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<view class="card">
|
<view class="card">
|
||||||
|
<view class="caption mb-md">仅更换您的客户手机号,资产已绑定的手机号不会改变。</view>
|
||||||
<view class="form-item">
|
<view class="form-item">
|
||||||
<view class="label">原手机号</view>
|
<view class="label">原手机号</view>
|
||||||
<up-input placeholder="请输入原手机号" border="surround" v-model="form.oldPhone" disabled/>
|
<up-input placeholder="请输入原手机号" border="surround" v-model="form.oldPhone" disabled/>
|
||||||
@@ -30,15 +31,20 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="btn-wrapper">
|
<view class="btn-wrapper">
|
||||||
<up-button type="primary" @click="submit">确认更换</up-button>
|
<up-button type="primary" :loading="submitting" :disabled="submitting" @click="submit">确认更换</up-button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, onMounted } from 'vue';
|
import { reactive, ref, onMounted, onUnmounted } from 'vue';
|
||||||
import { authApi } from '@/api/index.js';
|
import { authApi, assetApi } from '@/api/index.js';
|
||||||
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
oldPhone: '',
|
oldPhone: '',
|
||||||
@@ -47,14 +53,18 @@
|
|||||||
newCode: ''
|
newCode: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// 获取 URL 参数中的原手机号
|
form.oldPhone = userStore.state.customerPhone || '';
|
||||||
const pages = getCurrentPages();
|
const context = getSessionContext();
|
||||||
const currentPage = pages[pages.length - 1];
|
const revision = userStore.state.phoneRevision;
|
||||||
const options = currentPage.options;
|
try {
|
||||||
|
const data = await assetApi.getInfo(context.identifier);
|
||||||
if (options.oldPhone) {
|
if (isCurrentSession(context)) {
|
||||||
form.oldPhone = options.oldPhone;
|
form.oldPhone = revision === userStore.state.phoneRevision
|
||||||
|
? data.bound_phone || '' : userStore.state.customerPhone || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载客户手机号失败');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,6 +132,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
if (submitting.value) return;
|
||||||
if (!form.oldPhone || !form.oldCode) {
|
if (!form.oldPhone || !form.oldCode) {
|
||||||
uni.showToast({ title: '请填写原手机号信息', icon: 'none' });
|
uni.showToast({ title: '请填写原手机号信息', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
@@ -130,17 +141,34 @@
|
|||||||
uni.showToast({ title: '请填写新手机号信息', icon: 'none' });
|
uni.showToast({ title: '请填写新手机号信息', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(form.newPhone)) {
|
||||||
|
uni.showToast({ title: '请输入正确的新手机号', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = getSessionContext();
|
||||||
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await authApi.changePhone(form.oldPhone, form.oldCode, form.newPhone, form.newCode);
|
const data = await authApi.changePhone(form.oldPhone, form.oldCode, form.newPhone, form.newCode);
|
||||||
|
if (!isCurrentSession(context)) return;
|
||||||
|
if (typeof data?.phone === 'string') {
|
||||||
|
userStore.completeCustomerPhoneChange(data.phone);
|
||||||
|
form.oldPhone = data.phone;
|
||||||
|
}
|
||||||
uni.showToast({ title: '更换成功', icon: 'success' });
|
uni.showToast({ title: '更换成功', icon: 'success' });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.navigateBack();
|
if (isCurrentSession(context)) uni.navigateBack();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('更换手机号失败', e);
|
console.error('更换手机号失败', e);
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(oldTimer);
|
||||||
|
clearInterval(newTimer);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -160,4 +188,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<!-- <VoiceCard v-if="!userInfo.isDevice" :voiceStats="voiceStats" :dateRangeText="dateRangeText"
|
<!-- <VoiceCard v-if="!userInfo.isDevice" :voiceStats="voiceStats" :dateRangeText="dateRangeText"
|
||||||
@openDatePicker="openDateRangePicker" /> -->
|
@openDatePicker="openDateRangePicker" /> -->
|
||||||
|
|
||||||
<TrafficCard :identifier="currentCardNo" />
|
<TrafficCard :identifier="userStore.state.identifier" />
|
||||||
|
|
||||||
<!-- <WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
|
<!-- <WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
|
||||||
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" /> -->
|
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" /> -->
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi"
|
<WifiCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" @modify="modifyWifi"
|
||||||
@copy-config="copyWifiConfig" />
|
@copy-config="copyWifiConfig" />
|
||||||
|
|
||||||
<FunctionCard :realNameStatus="realNameStatus" :alreadyBindPhone="alreadyBindPhone"
|
<FunctionCard :realNameStatus="realNameStatus" :assetPhoneBound="userStore.state.assetPhoneBound"
|
||||||
|
:customerPhone="userStore.state.customerPhone"
|
||||||
:isDevice="userInfo.isDevice" :walletBalance="deviceInfo.walletBalance" :unreadCount="notificationUnreadCount"
|
:isDevice="userInfo.isDevice" :walletBalance="deviceInfo.walletBalance" :unreadCount="notificationUnreadCount"
|
||||||
@enter="enterDetail" @sync="onSync">
|
@enter="enterDetail" @sync="onSync">
|
||||||
<!-- 修改配置弹窗 -->
|
<!-- 修改配置弹窗 -->
|
||||||
@@ -96,13 +97,18 @@
|
|||||||
</FunctionCard>
|
</FunctionCard>
|
||||||
|
|
||||||
<view class="bottom-spacer"></view>
|
<view class="bottom-spacer"></view>
|
||||||
|
<ExpiryReminderPopup v-if="visibleExpiryReminder" :reminder="visibleExpiryReminder"
|
||||||
|
@close="closeExpiryReminder" @renew="renewFromExpiryReminder" />
|
||||||
|
|
||||||
<NotificationPopup :show="notificationPopupShow" :items="notificationItems"
|
<NotificationPopup :show="notificationPopupShow" :items="notificationItems"
|
||||||
:current="notificationPopupCurrent" @change="onNotificationChange" @close="closeNotificationPopup"
|
:current="notificationPopupCurrent" @change="onNotificationChange" @close="closeNotificationPopup"
|
||||||
@renew="startHomepageRenewal" />
|
@renew="startHomepageRenewal" />
|
||||||
|
|
||||||
<RenewalPaymentPopup ref="renewalPopupRef" :identifier="userStore.state.identifier"
|
<RenewalPaymentPopup ref="renewalPopupRef" :identifier="userStore.state.identifier"
|
||||||
paymentRefreshTarget="home" @completed="loadAssetInfo" />
|
paymentRefreshTarget="home" @completed="loadAssetInfo" @visibility-change="onRenewalVisibilityChange" />
|
||||||
|
|
||||||
|
<PopupCandidate v-if='popupCandidateShow' page='home' :identifier='userStore.state.identifier'
|
||||||
|
@close='onPopupCandidateClose' />
|
||||||
|
|
||||||
<!-- 日期选择器弹窗 -->
|
<!-- 日期选择器弹窗 -->
|
||||||
<up-datetime-picker v-if="!userInfo.isDevice" :show="showStartDatePicker" v-model="startDateTimestamp"
|
<up-datetime-picker v-if="!userInfo.isDevice" :show="showStartDatePicker" v-model="startDateTimestamp"
|
||||||
@@ -121,9 +127,10 @@
|
|||||||
ref,
|
ref,
|
||||||
reactive,
|
reactive,
|
||||||
onMounted,
|
onMounted,
|
||||||
computed
|
computed,
|
||||||
|
watch
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
import { onShow, onHide } from '@dcloudio/uni-app';
|
||||||
import UserInfoCard from '@/components/UserInfoCard.vue';
|
import UserInfoCard from '@/components/UserInfoCard.vue';
|
||||||
import DeviceStatusCard from '@/components/DeviceStatusCard.vue';
|
import DeviceStatusCard from '@/components/DeviceStatusCard.vue';
|
||||||
import VoiceCard from '@/components/VoiceCard.vue';
|
import VoiceCard from '@/components/VoiceCard.vue';
|
||||||
@@ -133,7 +140,9 @@
|
|||||||
import FunctionCard from '@/components/FunctionCard.vue';
|
import FunctionCard from '@/components/FunctionCard.vue';
|
||||||
import FloatingButton from '@/components/FloatingButton.vue';
|
import FloatingButton from '@/components/FloatingButton.vue';
|
||||||
import NotificationPopup from '@/components/NotificationPopup.vue';
|
import NotificationPopup from '@/components/NotificationPopup.vue';
|
||||||
|
import ExpiryReminderPopup from '@/components/ExpiryReminderPopup.vue';
|
||||||
import RenewalPaymentPopup from '@/components/RenewalPaymentPopup.vue';
|
import RenewalPaymentPopup from '@/components/RenewalPaymentPopup.vue';
|
||||||
|
import PopupCandidate from '@/components/PopupCandidate.vue';
|
||||||
import {
|
import {
|
||||||
assetApi,
|
assetApi,
|
||||||
deviceApi,
|
deviceApi,
|
||||||
@@ -141,11 +150,55 @@
|
|||||||
notificationApi
|
notificationApi
|
||||||
} from '@/api/index.js';
|
} from '@/api/index.js';
|
||||||
import {
|
import {
|
||||||
useUserStore
|
useUserStore,
|
||||||
|
useAssetStore
|
||||||
} from '@/store/index.js';
|
} from '@/store/index.js';
|
||||||
import { consumePendingPaymentRefresh, PAYMENT_REFRESH_TARGETS } from '@/utils/payment.js';
|
import { consumePendingPaymentRefresh, PAYMENT_REFRESH_TARGETS } from '@/utils/payment.js';
|
||||||
|
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
|
||||||
|
import { getShanghaiDay } from '@/utils/expiry-reminder.js';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const assetStore = useAssetStore();
|
||||||
|
const homeActive = ref(false);
|
||||||
|
const shownExpiryReminder = ref(null);
|
||||||
|
const awaitingHomeReminders = ref(false);
|
||||||
|
const renewalOpening = ref(false);
|
||||||
|
const renewalVisible = ref(false);
|
||||||
|
let notificationFallbackPending = false;
|
||||||
|
let notificationQueued = false;
|
||||||
|
let homeEntryId = 0;
|
||||||
|
const visibleExpiryReminder = computed(() => {
|
||||||
|
const entry = shownExpiryReminder.value;
|
||||||
|
return homeActive.value && entry && isCurrentSession(entry) && entry.day === getShanghaiDay()
|
||||||
|
? entry.reminder : null;
|
||||||
|
});
|
||||||
|
const showPendingExpiryReminder = () => {
|
||||||
|
const entry = assetStore.state.expiryReminder;
|
||||||
|
if (!homeActive.value || awaitingHomeReminders.value || renewalOpening.value || renewalVisible.value || popupCandidateShow.value || notificationPopupShow.value ||
|
||||||
|
visibleExpiryReminder.value || !entry || entry.shown || !isCurrentSession(entry) || entry.day !== getShanghaiDay()) return;
|
||||||
|
shownExpiryReminder.value = { ...entry };
|
||||||
|
assetStore.markExpiryReminderShown();
|
||||||
|
};
|
||||||
|
const closeExpiryReminder = (resumeQueue = true) => {
|
||||||
|
shownExpiryReminder.value = null;
|
||||||
|
assetStore.dismissExpiryReminder();
|
||||||
|
if (resumeQueue) advanceHomePopupQueue();
|
||||||
|
};
|
||||||
|
const advanceHomePopupQueue = () => {
|
||||||
|
if (!homeActive.value || awaitingHomeReminders.value || renewalOpening.value || renewalVisible.value || popupCandidateShow.value ||
|
||||||
|
visibleExpiryReminder.value || notificationPopupShow.value) return;
|
||||||
|
showPendingExpiryReminder();
|
||||||
|
if (visibleExpiryReminder.value) return;
|
||||||
|
if (notificationQueued) {
|
||||||
|
notificationQueued = false;
|
||||||
|
notificationPopupShow.value = true;
|
||||||
|
markNotificationRead(notificationItems.value[0]);
|
||||||
|
} else if (notificationFallbackPending) {
|
||||||
|
notificationFallbackPending = false;
|
||||||
|
loadUnreadNotifications();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
watch(() => assetStore.state.expiryReminder, advanceHomePopupQueue);
|
||||||
|
|
||||||
let showModifyWifi = ref(false);
|
let showModifyWifi = ref(false);
|
||||||
let restartShow = ref(false);
|
let restartShow = ref(false);
|
||||||
@@ -195,8 +248,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let isRealName = ref(false);
|
let isRealName = ref(false);
|
||||||
let alreadyBindPhone = ref(false);
|
const boundPhone = computed(() => userStore.state.customerPhone || '');
|
||||||
let boundPhone = ref('');
|
watch(boundPhone, (phone) => { deviceInfo.bound_phone = phone; });
|
||||||
let realNameStatus = ref('');
|
let realNameStatus = ref('');
|
||||||
let effectiveRealnamePolicy = ref('none');
|
let effectiveRealnamePolicy = ref('none');
|
||||||
let realnameEntryChecking = ref(false);
|
let realnameEntryChecking = ref(false);
|
||||||
@@ -238,6 +291,7 @@
|
|||||||
let notificationPopupCurrent = ref(0);
|
let notificationPopupCurrent = ref(0);
|
||||||
let renewalPopupRef = ref(null);
|
let renewalPopupRef = ref(null);
|
||||||
const notificationReadPending = new Set();
|
const notificationReadPending = new Set();
|
||||||
|
let popupCandidateShow = ref(false);
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
@@ -251,12 +305,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadAssetInfo = async () => {
|
const loadAssetInfo = async () => {
|
||||||
const identifier = userStore.state.identifier;
|
const identifier = String(userStore.state.identifier || uni.getStorageSync('identifier') || '').trim();
|
||||||
if (!identifier) return;
|
if (!identifier) return;
|
||||||
|
if (userStore.state.identifier !== identifier) userStore.setIdentifier(identifier);
|
||||||
|
const context = getSessionContext();
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await assetApi.getInfo(identifier);
|
const data = await assetApi.getInfo(identifier);
|
||||||
|
if (!isCurrentSession(context)) return null;
|
||||||
|
|
||||||
// 基本信息
|
// 基本信息
|
||||||
userInfo.isDevice = data.asset_type === 'device';
|
userInfo.isDevice = data.asset_type === 'device';
|
||||||
@@ -265,7 +322,7 @@
|
|||||||
deviceInfo.status = data.status;
|
deviceInfo.status = data.status;
|
||||||
deviceInfo.imei = data.imei || '-';
|
deviceInfo.imei = data.imei || '-';
|
||||||
deviceInfo.asset_type = data.asset_type || 'device';
|
deviceInfo.asset_type = data.asset_type || 'device';
|
||||||
deviceInfo.bound_phone = data.bound_phone || '';
|
deviceInfo.bound_phone = userStore.state.customerPhone || '';
|
||||||
deviceInfo.packageName = data.current_package || '-';
|
deviceInfo.packageName = data.current_package || '-';
|
||||||
deviceInfo.currentPackageId = Number(data.current_package_id || 0);
|
deviceInfo.currentPackageId = Number(data.current_package_id || 0);
|
||||||
deviceInfo.renewalPrice = data.renewal_price ?? null;
|
deviceInfo.renewalPrice = data.renewal_price ?? null;
|
||||||
@@ -280,8 +337,6 @@
|
|||||||
realNameStatus.value = isRealName.value ? '已实名' : '未实名';
|
realNameStatus.value = isRealName.value ? '已实名' : '未实名';
|
||||||
effectiveRealnamePolicy.value = data.effective_realname_policy || 'none';
|
effectiveRealnamePolicy.value = data.effective_realname_policy || 'none';
|
||||||
userStore.setRealNameStatus(data.real_name_status);
|
userStore.setRealNameStatus(data.real_name_status);
|
||||||
boundPhone.value = data.bound_phone || '';
|
|
||||||
alreadyBindPhone.value = !!data.bound_phone;
|
|
||||||
|
|
||||||
// 流量信息已由 TrafficCard 组件独立获取,这里不再处理
|
// 流量信息已由 TrafficCard 组件独立获取,这里不再处理
|
||||||
|
|
||||||
@@ -347,11 +402,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleIndexEntry = async () => {
|
const handleIndexEntry = async () => {
|
||||||
|
if (userStore.state.loginPending) return;
|
||||||
const token = uni.getStorageSync('token');
|
const token = uni.getStorageSync('token');
|
||||||
if (!token) {
|
const identifier = String(userStore.state.identifier || uni.getStorageSync('identifier') || '').trim();
|
||||||
|
if (!token || !identifier) {
|
||||||
|
if (token && !identifier) {
|
||||||
|
userStore.clearUser();
|
||||||
|
uni.showToast({ title: '缺少资产标识,请重新输入或扫码登录', icon: 'none' });
|
||||||
|
}
|
||||||
uni.reLaunch({ url: '/pages/login/login' });
|
uni.reLaunch({ url: '/pages/login/login' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (userStore.state.identifier !== identifier) userStore.setIdentifier(identifier);
|
||||||
|
|
||||||
if (indexEntryChecking.value) return;
|
if (indexEntryChecking.value) return;
|
||||||
indexEntryChecking.value = true;
|
indexEntryChecking.value = true;
|
||||||
@@ -363,7 +425,12 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startHomepageRenewal = () => {
|
const onRenewalVisibilityChange = (visible) => {
|
||||||
|
renewalVisible.value = visible;
|
||||||
|
if (!visible) advanceHomePopupQueue();
|
||||||
|
};
|
||||||
|
const startHomepageRenewal = async () => {
|
||||||
|
if (renewalOpening.value || renewalVisible.value) return;
|
||||||
const packageId = Number(deviceInfo.currentPackageId || 0);
|
const packageId = Number(deviceInfo.currentPackageId || 0);
|
||||||
const packageName = String(deviceInfo.packageName || '').trim();
|
const packageName = String(deviceInfo.packageName || '').trim();
|
||||||
if (!packageId || !packageName || packageName === '-') {
|
if (!packageId || !packageName || packageName === '-') {
|
||||||
@@ -371,7 +438,19 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renewalPopupRef.value?.open([packageId], [packageName], deviceInfo.renewalPrice);
|
renewalOpening.value = true;
|
||||||
|
try {
|
||||||
|
await renewalPopupRef.value?.open([packageId], [packageName], deviceInfo.renewalPrice);
|
||||||
|
} finally {
|
||||||
|
renewalOpening.value = false;
|
||||||
|
advanceHomePopupQueue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const renewFromExpiryReminder = async () => {
|
||||||
|
if (!visibleExpiryReminder.value || renewalOpening.value || renewalVisible.value) return;
|
||||||
|
closeExpiryReminder(false);
|
||||||
|
await startHomepageRenewal();
|
||||||
|
advanceHomePopupQueue();
|
||||||
};
|
};
|
||||||
|
|
||||||
const modifyWifi = () => {
|
const modifyWifi = () => {
|
||||||
@@ -527,6 +606,7 @@
|
|||||||
recoverShow.value = false;
|
recoverShow.value = false;
|
||||||
};
|
};
|
||||||
const YesLogout = () => {
|
const YesLogout = () => {
|
||||||
|
userStore.clearUser();
|
||||||
uni.clearStorageSync();
|
uni.clearStorageSync();
|
||||||
uni.reLaunch({
|
uni.reLaunch({
|
||||||
url: '/pages/login/login'
|
url: '/pages/login/login'
|
||||||
@@ -808,7 +888,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/change-phone/change-phone?oldPhone=${boundPhone.value}`
|
url: '/pages/change-phone/change-phone'
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'device-exchange':
|
case 'device-exchange':
|
||||||
@@ -837,8 +917,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadNotificationUnreadCount = async () => {
|
const loadNotificationUnreadCount = async () => {
|
||||||
|
const context = getSessionContext();
|
||||||
try {
|
try {
|
||||||
const data = await notificationApi.getUnreadCount();
|
const data = await notificationApi.getUnreadCount();
|
||||||
|
if (!isCurrentSession(context)) return;
|
||||||
notificationUnreadCount.value = Number(data?.count || 0);
|
notificationUnreadCount.value = Number(data?.count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载通知未读数失败', error);
|
console.error('加载通知未读数失败', error);
|
||||||
@@ -848,8 +930,10 @@
|
|||||||
const markNotificationRead = async (item) => {
|
const markNotificationRead = async (item) => {
|
||||||
if (!item || item.is_read || notificationReadPending.has(item.id)) return;
|
if (!item || item.is_read || notificationReadPending.has(item.id)) return;
|
||||||
notificationReadPending.add(item.id);
|
notificationReadPending.add(item.id);
|
||||||
|
const context = getSessionContext();
|
||||||
try {
|
try {
|
||||||
await notificationApi.markRead(item.id);
|
await notificationApi.markRead(item.id);
|
||||||
|
if (!isCurrentSession(context)) return;
|
||||||
item.is_read = true;
|
item.is_read = true;
|
||||||
item.read_at = new Date().toISOString();
|
item.read_at = new Date().toISOString();
|
||||||
notificationUnreadCount.value = Math.max(notificationUnreadCount.value - 1, 0);
|
notificationUnreadCount.value = Math.max(notificationUnreadCount.value - 1, 0);
|
||||||
@@ -861,18 +945,19 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadUnreadNotifications = async () => {
|
const loadUnreadNotifications = async () => {
|
||||||
|
const context = getSessionContext();
|
||||||
|
const entryId = homeEntryId;
|
||||||
try {
|
try {
|
||||||
const data = await notificationApi.getList(1, 50, false);
|
const data = await notificationApi.getList(1, 50, false);
|
||||||
const items = (data?.items || [])
|
if (!homeActive.value || entryId !== homeEntryId || !isCurrentSession(context)) return;
|
||||||
.filter(item => !item.is_read)
|
const items = (data?.items || [])
|
||||||
.sort((a, b) => getNotificationPriority(b) - getNotificationPriority(a));
|
.filter(item => !item.is_read && item.category !== 'expiry');
|
||||||
if (!items.length) return;
|
if (!items.length) return;
|
||||||
|
|
||||||
notificationItems.value = items;
|
notificationItems.value = items;
|
||||||
notificationPopupCurrent.value = 0;
|
notificationPopupCurrent.value = 0;
|
||||||
notificationPopupShow.value = true;
|
notificationQueued = true;
|
||||||
// 弹窗打开即视为用户已看到第一条通知。
|
advanceHomePopupQueue();
|
||||||
markNotificationRead(items[0]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载首页未读通知失败', error);
|
console.error('加载首页未读通知失败', error);
|
||||||
}
|
}
|
||||||
@@ -885,17 +970,6 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNotificationPriority = (item) => {
|
|
||||||
const severityRank = { info: 10, warning: 20, error: 30, critical: 40 };
|
|
||||||
const remainingDays = Number(item?.days_until_expiry ?? item?.days_remaining ?? item?.remaining_days);
|
|
||||||
const expiryLevel = String(item?.expiry_level || '').toLowerCase();
|
|
||||||
if (item?.category === 'expiry' && ((Number.isFinite(remainingDays) && remainingDays >= 0 && remainingDays <= 3) ||
|
|
||||||
['0_3', '0-3', '0~3', 'critical'].includes(expiryLevel))) {
|
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
return severityRank[item?.severity] || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onNotificationChange = (event) => {
|
const onNotificationChange = (event) => {
|
||||||
const index = Number(event?.detail?.current ?? event?.current ?? 0);
|
const index = Number(event?.detail?.current ?? event?.current ?? 0);
|
||||||
notificationPopupCurrent.value = index;
|
notificationPopupCurrent.value = index;
|
||||||
@@ -904,19 +978,72 @@
|
|||||||
|
|
||||||
const closeNotificationPopup = () => {
|
const closeNotificationPopup = () => {
|
||||||
notificationPopupShow.value = false;
|
notificationPopupShow.value = false;
|
||||||
|
advanceHomePopupQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runPopupCandidate = () => {
|
||||||
|
popupCandidateShow.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPopupCandidateClose = (reason) => {
|
||||||
|
if (!homeActive.value) return;
|
||||||
|
popupCandidateShow.value = false;
|
||||||
|
// The candidate action navigates away immediately. Keep any unshown
|
||||||
|
// expiry reminder pending rather than consuming it before that navigation.
|
||||||
|
if (reason === 'action') return;
|
||||||
|
if (reason === 'empty' || reason === 'invisible') {
|
||||||
|
notificationFallbackPending = true;
|
||||||
|
}
|
||||||
|
advanceHomePopupQueue();
|
||||||
|
loadNotificationUnreadCount();
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initCurrentMonth();
|
initCurrentMonth();
|
||||||
});
|
});
|
||||||
|
|
||||||
onShow(() => {
|
onShow(async () => {
|
||||||
|
const entryId = ++homeEntryId;
|
||||||
|
homeActive.value = true;
|
||||||
|
shownExpiryReminder.value = null;
|
||||||
|
awaitingHomeReminders.value = true;
|
||||||
|
notificationFallbackPending = false;
|
||||||
|
notificationQueued = false;
|
||||||
|
if (userStore.state.loginPending) {
|
||||||
|
awaitingHomeReminders.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!getSessionContext().token || !String(userStore.state.identifier || uni.getStorageSync('identifier') || '').trim()) {
|
||||||
|
awaitingHomeReminders.value = false;
|
||||||
|
handleIndexEntry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.HOME)) {
|
if (consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.HOME)) {
|
||||||
loadAssetInfo();
|
loadAssetInfo();
|
||||||
}
|
}
|
||||||
handleIndexEntry();
|
|
||||||
loadNotificationUnreadCount();
|
loadNotificationUnreadCount();
|
||||||
loadUnreadNotifications();
|
runPopupCandidate();
|
||||||
|
await handleIndexEntry();
|
||||||
|
if (!homeActive.value || entryId !== homeEntryId) return;
|
||||||
|
awaitingHomeReminders.value = false;
|
||||||
|
advanceHomePopupQueue();
|
||||||
|
});
|
||||||
|
onHide(() => {
|
||||||
|
homeEntryId++;
|
||||||
|
homeActive.value = false;
|
||||||
|
shownExpiryReminder.value = null;
|
||||||
|
notificationPopupShow.value = false;
|
||||||
|
popupCandidateShow.value = false;
|
||||||
|
notificationQueued = false;
|
||||||
|
notificationFallbackPending = false;
|
||||||
|
});
|
||||||
|
watch(() => [userStore.state.token, userStore.state.identifier], () => {
|
||||||
|
shownExpiryReminder.value = null;
|
||||||
|
notificationItems.value = [];
|
||||||
|
notificationUnreadCount.value = 0;
|
||||||
|
notificationPopupShow.value = false;
|
||||||
|
notificationQueued = false;
|
||||||
|
notificationFallbackPending = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -71,9 +71,14 @@
|
|||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
import { initWxConfig, wxScan, isInWechat } from '@/utils/wxsdk.js';
|
import { initWxConfig, wxScan, isInWechat } from '@/utils/wxsdk.js';
|
||||||
|
|
||||||
const POST_BIND_RELOGIN_NOTICE_KEY = 'postBindReloginNotice';
|
const WECHAT_LOGIN_CONTEXT_KEY = 'wechatLoginContext';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
// Close the asset-request gate synchronously, before cached homepage
|
||||||
|
// components can react to the callback's old token or saved identifier.
|
||||||
|
if (new URLSearchParams(window.location.search).get('code') && sessionStorage.getItem('assetToken')) {
|
||||||
|
userStore.setLoginPending(true);
|
||||||
|
}
|
||||||
const identifier = ref(uni.getStorageSync('identifier') || '');
|
const identifier = ref(uni.getStorageSync('identifier') || '');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const agreed = ref(true);
|
const agreed = ref(true);
|
||||||
@@ -84,17 +89,60 @@
|
|||||||
const resetLoadingState = () => {
|
const resetLoadingState = () => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
const initializeLoginSdk = async () => {
|
||||||
|
if (isInWechat()) await initWxConfig();
|
||||||
|
};
|
||||||
|
const resumeStoredSession = async (assetIdentifier) => {
|
||||||
|
userStore.setLoginPending(true);
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
userStore.setIdentifier(assetIdentifier);
|
||||||
|
await initializeLoginSdk();
|
||||||
|
sessionStorage.removeItem('assetToken');
|
||||||
|
sessionStorage.removeItem(WECHAT_LOGIN_CONTEXT_KEY);
|
||||||
|
userStore.setLoginPending(false);
|
||||||
|
uni.reLaunch({ url: '/pages/index/index' });
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e?.msg || e?.message || '微信初始化失败,请重新登录', icon: 'none' });
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Remove the obsolete post-bind re-login notice from older client builds.
|
||||||
|
uni.removeStorageSync('postBindReloginNotice');
|
||||||
|
const urlIdentifier = getPathDeviceId();
|
||||||
const token = uni.getStorageSync('token');
|
const token = uni.getStorageSync('token');
|
||||||
if (token) {
|
const storedIdentifier = uni.getStorageSync('identifier') || '';
|
||||||
uni.reLaunch({ url: '/pages/index/index' });
|
// Complete OAuth first. An existing token or the callback's identifier
|
||||||
|
// must not bypass the callback or start another authorization round.
|
||||||
|
if (getCode() && sessionStorage.getItem('assetToken')) {
|
||||||
|
await handleWechatCallback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showPostBindReloginNotice();
|
// 从外部链接进入时,链接中的资产标识优先于本地登录态。
|
||||||
handleWechatCallback();
|
// 只有相同资产才能复用已登录的会话,避免将上一台设备的数据带到新链接中。
|
||||||
getPathDeviceId();
|
if (urlIdentifier) {
|
||||||
|
identifier.value = urlIdentifier;
|
||||||
|
if (token && urlIdentifier === storedIdentifier) {
|
||||||
|
await resumeStoredSession(urlIdentifier);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token || storedIdentifier) {
|
||||||
|
userStore.clearUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
await doLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token && storedIdentifier.trim()) {
|
||||||
|
await resumeStoredSession(storedIdentifier.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token) userStore.clearUser();
|
||||||
|
|
||||||
// 初始化微信 SDK (仅在微信浏览器内执行)
|
// 初始化微信 SDK (仅在微信浏览器内执行)
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -121,19 +169,6 @@
|
|||||||
resetLoadingState();
|
resetLoadingState();
|
||||||
});
|
});
|
||||||
|
|
||||||
const showPostBindReloginNotice = () => {
|
|
||||||
if (uni.getStorageSync(POST_BIND_RELOGIN_NOTICE_KEY) !== '1') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.removeStorageSync(POST_BIND_RELOGIN_NOTICE_KEY);
|
|
||||||
uni.showToast({
|
|
||||||
title: '绑定手机号成功,请重新登录',
|
|
||||||
icon: 'none',
|
|
||||||
duration: 2500
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCode = () => {
|
const getCode = () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
return params.get('code');
|
return params.get('code');
|
||||||
@@ -158,10 +193,13 @@
|
|||||||
return wechatAppId.value;
|
return wechatAppId.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const redirectToWxAuth = async (assetToken) => {
|
const redirectToWxAuth = async (assetToken, assetIdentifier) => {
|
||||||
sessionStorage.setItem('assetToken', assetToken);
|
sessionStorage.setItem('assetToken', assetToken);
|
||||||
|
sessionStorage.setItem(WECHAT_LOGIN_CONTEXT_KEY, JSON.stringify({ assetToken, identifier: assetIdentifier }));
|
||||||
const appId = await getWechatAppId();
|
const appId = await getWechatAppId();
|
||||||
const redirectUri = encodeURIComponent(window.location.origin + window.location.pathname);
|
const callbackUrl = new URL(window.location.pathname, window.location.origin);
|
||||||
|
callbackUrl.searchParams.set('identifier', assetIdentifier);
|
||||||
|
const redirectUri = encodeURIComponent(callbackUrl.toString());
|
||||||
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=wechat_auth#wechat_redirect`;
|
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=wechat_auth#wechat_redirect`;
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
};
|
};
|
||||||
@@ -236,30 +274,72 @@
|
|||||||
|
|
||||||
const getPathDeviceId = () => {
|
const getPathDeviceId = () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const idf = params.get('identifier');
|
return (params.get('identifier') || '').trim();
|
||||||
if (idf) {
|
};
|
||||||
identifier.value = idf;
|
|
||||||
handleLogin();
|
const isNineteenDigitIccidPrefix = (value) => /^89\d{17}$/.test(value);
|
||||||
|
|
||||||
|
const appendIccidLuhnCheckDigit = (prefix) => {
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
for (let index = prefix.length - 1, offset = 0; index >= 0; index--, offset++) {
|
||||||
|
let digit = Number(prefix[index]);
|
||||||
|
if (offset % 2 === 0) {
|
||||||
|
digit *= 2;
|
||||||
|
if (digit > 9) digit -= 9;
|
||||||
|
}
|
||||||
|
sum += digit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${(10 - (sum % 10)) % 10}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyAssetToken = async (assetIdentifier) => {
|
||||||
|
const verifyData = await authApi.verifyAsset(assetIdentifier);
|
||||||
|
if (!verifyData?.asset_token) {
|
||||||
|
throw { msg: '资产校验未返回有效登录凭证' };
|
||||||
|
}
|
||||||
|
return verifyData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyAssetWithIccidRetry = async (enteredIdentifier) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
verifyData: await verifyAssetToken(enteredIdentifier),
|
||||||
|
verifiedIdentifier: enteredIdentifier
|
||||||
|
};
|
||||||
|
} catch (firstError) {
|
||||||
|
if (!isNineteenDigitIccidPrefix(enteredIdentifier)) {
|
||||||
|
throw firstError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedIccid = appendIccidLuhnCheckDigit(enteredIdentifier);
|
||||||
|
return {
|
||||||
|
verifyData: await verifyAssetToken(completedIccid),
|
||||||
|
verifiedIdentifier: completedIccid
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const doLogin = async () => {
|
const doLogin = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
userStore.setLoginPending(true);
|
||||||
// 丢弃上一轮未完成授权留下的临时凭证,避免校验失败时继续复用。
|
// 丢弃上一轮未完成授权留下的临时凭证,避免校验失败时继续复用。
|
||||||
if (typeof sessionStorage !== 'undefined') {
|
if (typeof sessionStorage !== 'undefined') {
|
||||||
sessionStorage.removeItem('assetToken');
|
sessionStorage.removeItem('assetToken');
|
||||||
|
sessionStorage.removeItem(WECHAT_LOGIN_CONTEXT_KEY);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const verifyData = await authApi.verifyAsset(identifier.value);
|
const enteredIdentifier = String(identifier.value || '').trim();
|
||||||
if (!verifyData?.asset_token) {
|
if (!enteredIdentifier) throw { msg: '请输入有效的资产标识' };
|
||||||
throw { msg: '资产校验未返回有效登录凭证' };
|
const { verifyData, verifiedIdentifier } = await verifyAssetWithIccidRetry(enteredIdentifier);
|
||||||
}
|
|
||||||
|
|
||||||
userStore.setAssetToken(verifyData.asset_token);
|
userStore.setAssetToken(verifyData.asset_token);
|
||||||
userStore.setIdentifier(identifier.value);
|
userStore.setIdentifier(verifiedIdentifier);
|
||||||
|
|
||||||
await redirectToWxAuth(verifyData.asset_token);
|
await redirectToWxAuth(verifyData.asset_token, verifiedIdentifier);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
userStore.setLoginPending(false);
|
||||||
console.error('登录失败', e);
|
console.error('登录失败', e);
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: e?.msg || e?.message || '资产校验失败,请稍后重试',
|
title: e?.msg || e?.message || '资产校验失败,请稍后重试',
|
||||||
@@ -275,14 +355,38 @@
|
|||||||
const assetToken = sessionStorage.getItem('assetToken');
|
const assetToken = sessionStorage.getItem('assetToken');
|
||||||
|
|
||||||
if (!code || !assetToken) return;
|
if (!code || !assetToken) return;
|
||||||
|
userStore.setLoginPending(true);
|
||||||
|
let loginContext = null;
|
||||||
|
try {
|
||||||
|
loginContext = JSON.parse(sessionStorage.getItem(WECHAT_LOGIN_CONTEXT_KEY) || 'null');
|
||||||
|
} catch (e) {
|
||||||
|
// Old clients may only have the legacy assetToken and saved identifier.
|
||||||
|
}
|
||||||
|
const contextIdentifier = loginContext?.assetToken === assetToken && typeof loginContext.identifier === 'string'
|
||||||
|
? loginContext.identifier.trim() : '';
|
||||||
|
const loginIdentifier = contextIdentifier || getPathDeviceId() ||
|
||||||
|
String(uni.getStorageSync('identifier') || userStore.state.identifier || identifier.value || '').trim();
|
||||||
|
if (!loginIdentifier) {
|
||||||
|
clearCode();
|
||||||
|
sessionStorage.removeItem('assetToken');
|
||||||
|
sessionStorage.removeItem(WECHAT_LOGIN_CONTEXT_KEY);
|
||||||
|
userStore.clearUser();
|
||||||
|
uni.showToast({ title: '缺少资产标识,请重新输入或扫码登录', icon: 'none' });
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
clearCode();
|
clearCode();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const loginData = await authApi.wechatLogin(assetToken, code);
|
const loginData = await authApi.wechatLogin(assetToken, code);
|
||||||
|
// Restore the verified identifier before publishing the login state or
|
||||||
|
// mounting homepage components that immediately request asset/info.
|
||||||
|
userStore.setIdentifier(loginIdentifier);
|
||||||
userStore.setToken(loginData.token);
|
userStore.setToken(loginData.token);
|
||||||
uni.setStorageSync('identifier', userStore.state.identifier);
|
userStore.setLoginPhoneState(loginData);
|
||||||
|
identifier.value = loginIdentifier;
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (loginData.user_info) {
|
if (loginData.user_info) {
|
||||||
@@ -293,7 +397,14 @@
|
|||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
if (loginData.need_bind_phone) {
|
// Keep the gate closed while JSSDK config/ready is still pending.
|
||||||
|
// Homepage and bind-page asset/info requests only start afterwards.
|
||||||
|
await initializeLoginSdk();
|
||||||
|
sessionStorage.removeItem('assetToken');
|
||||||
|
sessionStorage.removeItem(WECHAT_LOGIN_CONTEXT_KEY);
|
||||||
|
userStore.setLoginPending(false);
|
||||||
|
|
||||||
|
if (userStore.state.needBindPhone === true) {
|
||||||
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
|
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
|
||||||
} else {
|
} else {
|
||||||
uni.redirectTo({ url: '/pages/index/index' });
|
uni.redirectTo({ url: '/pages/index/index' });
|
||||||
|
|||||||
@@ -221,6 +221,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<PopupCandidate v-if='popupCandidateShow' page='asset_wallet_recharge'
|
||||||
|
:identifier='userStore.state.identifier' @close='onPopupCandidateClose' />
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -228,20 +232,28 @@
|
|||||||
import { onShow } from '@dcloudio/uni-app';
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
import { walletApi } from '@/api/index.js';
|
import { walletApi } from '@/api/index.js';
|
||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
import PopupCandidate from '@/components/PopupCandidate.vue';
|
||||||
import {
|
import {
|
||||||
consumePendingPaymentRefresh,
|
consumePendingPaymentRefresh,
|
||||||
handlePaymentError,
|
handlePaymentError,
|
||||||
isValidAlipayPaymentLink,
|
isValidAlipayPaymentLink,
|
||||||
isValidWechatPayConfig,
|
isValidWechatPayConfig,
|
||||||
|
markPendingPaymentRefresh,
|
||||||
openAlipayPayment,
|
openAlipayPayment,
|
||||||
PAYMENT_REFRESH_TARGETS,
|
PAYMENT_REFRESH_TARGETS,
|
||||||
showPaymentToast,
|
showPaymentToast,
|
||||||
wechatH5Pay
|
wechatH5Pay
|
||||||
} from '@/utils/payment.js';
|
} from '@/utils/payment.js';
|
||||||
|
import { getPaymentErrorMessage, isUnknownPaymentResult } from '@/utils/payment-attempt.js';
|
||||||
import { formatMoney } from '@/utils/display.js';
|
import { formatMoney } from '@/utils/display.js';
|
||||||
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const popupCandidateShow = ref(false);
|
||||||
|
|
||||||
|
const onPopupCandidateClose = () => {
|
||||||
|
popupCandidateShow.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const currentTab = ref(0);
|
const currentTab = ref(0);
|
||||||
const walletDetail = reactive({
|
const walletDetail = reactive({
|
||||||
@@ -673,9 +685,12 @@
|
|||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
console.error('充值失败', error);
|
console.error('充值失败', error);
|
||||||
showRechargeModal.value = false;
|
showRechargeModal.value = false;
|
||||||
|
if (isUnknownPaymentResult(error)) {
|
||||||
|
markPendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.MY_WALLET);
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: error.msg || error.message || '充值失败,请稍后重试',
|
title: getPaymentErrorMessage(error, '充值失败,请稍后重试'),
|
||||||
icon: 'none'
|
icon: 'none'
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -734,8 +749,11 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
console.error('获取支付配置失败', error);
|
console.error('获取支付配置失败', error);
|
||||||
|
if (isUnknownPaymentResult(error)) {
|
||||||
|
markPendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.MY_WALLET);
|
||||||
|
}
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: error.msg || error.message || '支付失败,请稍后重试',
|
title: getPaymentErrorMessage(error),
|
||||||
icon: 'none'
|
icon: 'none'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -765,6 +783,7 @@
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncWalletStatus();
|
syncWalletStatus();
|
||||||
|
popupCandidateShow.value = true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<view class="container">
|
<view class="container">
|
||||||
<view class="toolbar flex-row-sb">
|
<view class="toolbar flex-row-sb">
|
||||||
<view class="summary">共 {{ total }} 条通知</view>
|
<view class="summary">共 {{ total }} 条通知</view>
|
||||||
<button class="btn-apple btn-secondary mark-all" :disabled="unreadCount === 0" @tap="markAllRead">
|
<button class="btn-apple btn-secondary mark-all" :disabled="unreadCount === 0 || markingAll" @tap="markAllRead">
|
||||||
全部已读
|
全部已读
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="notification-list">
|
<view v-else class="notification-list">
|
||||||
<view v-for="item in notifications" :key="item.id" class="card notification-card"
|
<view v-for="item in notifications" :key="item.id" class="card notification-card" @tap="markItemRead(item)">
|
||||||
:class="{ unread: !item.is_read }" @tap="markItemRead(item)">
|
|
||||||
<view class="notification-header flex-row-sb">
|
<view class="notification-header flex-row-sb">
|
||||||
<view class="notification-title">{{ item.title || '业务通知' }}</view>
|
<view class="notification-title">{{ item.title || '业务通知' }}</view>
|
||||||
<view v-if="!item.is_read" class="unread-dot"></view>
|
<button class="read-status" :class="item.is_read ? 'status-read' : 'status-unread'"
|
||||||
|
@tap.stop="markItemRead(item)">{{ item.is_read ? '已读' : '未读' }}</button>
|
||||||
</view>
|
</view>
|
||||||
<view class="notification-body">{{ item.body || '-' }}</view>
|
<view class="notification-body">{{ item.body || '-' }}</view>
|
||||||
<view class="notification-footer flex-row-sb">
|
<view class="notification-footer flex-row-sb">
|
||||||
@@ -40,9 +40,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue';
|
import { ref, reactive } from 'vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
import { notificationApi } from '@/api/index.js';
|
import { notificationApi } from '@/api/index.js';
|
||||||
|
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
|
||||||
|
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const notifications = reactive([]);
|
const notifications = reactive([]);
|
||||||
@@ -52,6 +53,10 @@
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const notificationsLoaded = ref(false);
|
const notificationsLoaded = ref(false);
|
||||||
const noMore = ref(false);
|
const noMore = ref(false);
|
||||||
|
const markingAll = ref(false);
|
||||||
|
let listRequestId = 0;
|
||||||
|
let countRequestId = 0;
|
||||||
|
const readPending = new Set();
|
||||||
|
|
||||||
const formatDate = (value) => {
|
const formatDate = (value) => {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@@ -61,7 +66,6 @@
|
|||||||
const getCategoryText = (category) => ({
|
const getCategoryText = (category) => ({
|
||||||
approval: '审批',
|
approval: '审批',
|
||||||
expiry: '临期',
|
expiry: '临期',
|
||||||
exchange: '换货',
|
|
||||||
sync: '同步',
|
sync: '同步',
|
||||||
system: '系统'
|
system: '系统'
|
||||||
}[category] || '通知');
|
}[category] || '通知');
|
||||||
@@ -73,20 +77,12 @@
|
|||||||
critical: '严重'
|
critical: '严重'
|
||||||
}[severity] || '提示');
|
}[severity] || '提示');
|
||||||
|
|
||||||
const getNotificationPriority = (item) => {
|
|
||||||
const severityRank = { info: 10, warning: 20, error: 30, critical: 40 };
|
|
||||||
const remainingDays = Number(item?.days_until_expiry ?? item?.days_remaining ?? item?.remaining_days);
|
|
||||||
const expiryLevel = String(item?.expiry_level || '').toLowerCase();
|
|
||||||
if (item?.category === 'expiry' && ((Number.isFinite(remainingDays) && remainingDays >= 0 && remainingDays <= 3) ||
|
|
||||||
['0_3', '0-3', '0~3', 'critical'].includes(expiryLevel))) {
|
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
return severityRank[item?.severity] || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadUnreadCount = async () => {
|
const loadUnreadCount = async () => {
|
||||||
|
const context = getSessionContext();
|
||||||
|
const requestId = ++countRequestId;
|
||||||
try {
|
try {
|
||||||
const data = await notificationApi.getUnreadCount();
|
const data = await notificationApi.getUnreadCount();
|
||||||
|
if (!isCurrentSession(context) || requestId !== countRequestId) return;
|
||||||
unreadCount.value = Number(data?.count || 0);
|
unreadCount.value = Number(data?.count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载通知未读数失败', error);
|
console.error('加载通知未读数失败', error);
|
||||||
@@ -94,11 +90,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadNotifications = async (append = false) => {
|
const loadNotifications = async (append = false) => {
|
||||||
if (loading.value || (append && noMore.value)) return;
|
if (append && (loading.value || noMore.value)) return;
|
||||||
|
const context = getSessionContext();
|
||||||
|
const requestId = ++listRequestId;
|
||||||
|
const requestedPage = append ? page.value : 1;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await notificationApi.getList(page.value, pageSize);
|
const data = await notificationApi.getList(requestedPage, pageSize);
|
||||||
const items = (data?.items || []).sort((a, b) => getNotificationPriority(b) - getNotificationPriority(a));
|
if (!isCurrentSession(context) || requestId !== listRequestId) return;
|
||||||
|
const items = (data?.items || []);
|
||||||
if (append) {
|
if (append) {
|
||||||
notifications.push(...items);
|
notifications.push(...items);
|
||||||
} else {
|
} else {
|
||||||
@@ -106,39 +106,48 @@
|
|||||||
}
|
}
|
||||||
total.value = Number(data?.total || 0);
|
total.value = Number(data?.total || 0);
|
||||||
noMore.value = notifications.length >= total.value || items.length < pageSize;
|
noMore.value = notifications.length >= total.value || items.length < pageSize;
|
||||||
if (!noMore.value) page.value += 1;
|
page.value = requestedPage + 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载通知列表失败', error);
|
console.error('加载通知列表失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (isCurrentSession(context) && requestId === listRequestId) {
|
||||||
notificationsLoaded.value = true;
|
loading.value = false;
|
||||||
|
notificationsLoaded.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const markItemRead = async (item) => {
|
const markItemRead = async (item) => {
|
||||||
if (item.is_read) return;
|
if (item.is_read || readPending.has(item.id) || markingAll.value) return;
|
||||||
|
const context = getSessionContext();
|
||||||
|
readPending.add(item.id);
|
||||||
try {
|
try {
|
||||||
await notificationApi.markRead(item.id);
|
await notificationApi.markRead(item.id);
|
||||||
|
if (!isCurrentSession(context)) return;
|
||||||
item.is_read = true;
|
item.is_read = true;
|
||||||
item.read_at = new Date().toISOString();
|
item.read_at = new Date().toISOString();
|
||||||
unreadCount.value = Math.max(unreadCount.value - 1, 0);
|
await loadUnreadCount();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('标记通知已读失败', error);
|
console.error('标记通知已读失败', error);
|
||||||
|
} finally {
|
||||||
|
readPending.delete(item.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAllRead = async () => {
|
const markAllRead = async () => {
|
||||||
if (!unreadCount.value) return;
|
if (!unreadCount.value || markingAll.value) return;
|
||||||
|
const context = getSessionContext();
|
||||||
|
markingAll.value = true;
|
||||||
try {
|
try {
|
||||||
await notificationApi.markAllRead();
|
await notificationApi.markAllRead();
|
||||||
notifications.forEach(item => {
|
if (!isCurrentSession(context)) return;
|
||||||
item.is_read = true;
|
await Promise.all([loadNotifications(), loadUnreadCount()]);
|
||||||
item.read_at = item.read_at || new Date().toISOString();
|
if (!isCurrentSession(context)) return;
|
||||||
});
|
|
||||||
unreadCount.value = 0;
|
|
||||||
uni.showToast({ title: '已全部标记为已读', icon: 'success' });
|
uni.showToast({ title: '已全部标记为已读', icon: 'success' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('全部标记通知已读失败', error);
|
console.error('全部标记通知已读失败', error);
|
||||||
|
} finally {
|
||||||
|
markingAll.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,13 +155,14 @@
|
|||||||
if (!noMore.value) loadNotifications(true);
|
if (!noMore.value) loadNotifications(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onShow(async () => {
|
||||||
loadNotifications();
|
notifications.splice(0);
|
||||||
loadUnreadCount();
|
total.value = 0;
|
||||||
});
|
unreadCount.value = 0;
|
||||||
|
page.value = 1;
|
||||||
onShow(() => {
|
noMore.value = false;
|
||||||
loadUnreadCount();
|
notificationsLoaded.value = false;
|
||||||
|
await Promise.all([loadNotifications(), loadUnreadCount()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -161,11 +171,13 @@
|
|||||||
.toolbar { margin-bottom: var(--space-md); }
|
.toolbar { margin-bottom: var(--space-md); }
|
||||||
.summary { color: var(--text-tertiary); font-size: 24rpx; }
|
.summary { color: var(--text-tertiary); font-size: 24rpx; }
|
||||||
.mark-all { margin: 0; padding: 0 24rpx; height: 64rpx; line-height: 64rpx; 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 { margin-bottom: var(--space-md); }
|
||||||
.notification-card.unread { border-left-color: var(--primary); }
|
.notification-header { margin-bottom: var(--space-sm); gap: 20rpx; align-items: flex-start; }
|
||||||
.notification-header { margin-bottom: var(--space-sm); }
|
|
||||||
.notification-title { color: var(--text-primary); font-size: 30rpx; font-weight: 600; flex: 1; }
|
.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); }
|
.read-status { flex-shrink: 0; margin: 0; padding: 6rpx 18rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 500; line-height: 1.5; }
|
||||||
|
.read-status::after { border: none; }
|
||||||
|
.status-unread { color: var(--primary-dark); background: var(--success-light); }
|
||||||
|
.status-read { color: var(--text-tertiary); background: var(--gray-100); }
|
||||||
.notification-body { color: var(--text-secondary); font-size: 26rpx; line-height: 1.6; white-space: pre-wrap; }
|
.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-footer { margin-top: var(--space-md); }
|
||||||
.notification-meta, .notification-time { color: var(--text-tertiary); font-size: 22rpx; }
|
.notification-meta, .notification-time { color: var(--text-tertiary); font-size: 22rpx; }
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<view class="card-header">
|
<view class="card-header">
|
||||||
<view class="order-number" @tap.stop="copyOrderNo(item.order_no)">
|
<view class="order-number" @tap.stop="copyOrderNo(item.order_no)">
|
||||||
<text class="order-no">{{ item.order_no || '-' }}</text>
|
<text class="order-no">{{ item.order_no || '-' }}</text>
|
||||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"></image>
|
<button class="copy-button" @tap.stop="copyOrderNo(item.order_no)" aria-label="复制订单号">复制</button>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-badge" :class="getStatusClass(item.payment_status)">
|
<view class="status-badge" :class="getStatusClass(item.payment_status)">
|
||||||
{{ item.payment_status_name }}
|
{{ item.payment_status_name }}
|
||||||
@@ -222,11 +222,12 @@
|
|||||||
const showOrderPaymentMethods = (order) => {
|
const showOrderPaymentMethods = (order) => {
|
||||||
if (!order?.order_id) return;
|
if (!order?.order_id) return;
|
||||||
const packageNames = Array.isArray(order.package_names) ? order.package_names : [];
|
const packageNames = Array.isArray(order.package_names) ? order.package_names : [];
|
||||||
if (String(order.payment_method || '').toLowerCase() === 'wallet') {
|
const paymentMethod = String(order.payment_method || '').trim().toLowerCase();
|
||||||
|
if (paymentMethod === 'wallet') {
|
||||||
renewalPopupRef.value?.payOrderDirectly(order.order_id, 'wallet');
|
renewalPopupRef.value?.payOrderDirectly(order.order_id, 'wallet');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renewalPopupRef.value?.openOrderPayment(order.order_id, packageNames);
|
renewalPopupRef.value?.openOrderPayment(order.order_id, packageNames, paymentMethod);
|
||||||
};
|
};
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
@@ -602,13 +603,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-icon {
|
|
||||||
width: 30rpx;
|
|
||||||
height: 30rpx;
|
|
||||||
margin-left: 14rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-card .card-body {
|
.order-card .card-body {
|
||||||
padding: 0 0 28rpx;
|
padding: 0 0 28rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</up-popup>
|
</up-popup>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<PopupCandidate v-if='popupCandidateShow' page='package_purchase'
|
||||||
|
:identifier='userStore.state.identifier' @close='onPopupCandidateClose' />
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -102,20 +106,28 @@
|
|||||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||||
import { assetApi, isAssetRealNameCompleted, orderApi, walletApi } from '@/api/index.js';
|
import { assetApi, isAssetRealNameCompleted, orderApi, walletApi } from '@/api/index.js';
|
||||||
import { useUserStore } from '@/store/index.js';
|
import { useUserStore } from '@/store/index.js';
|
||||||
|
import PopupCandidate from '@/components/PopupCandidate.vue';
|
||||||
import {
|
import {
|
||||||
consumePendingPaymentRefresh,
|
consumePendingPaymentRefresh,
|
||||||
handlePaymentError,
|
handlePaymentError,
|
||||||
isValidAlipayPaymentLink,
|
isValidAlipayPaymentLink,
|
||||||
isValidWechatPayConfig,
|
isValidWechatPayConfig,
|
||||||
|
markPendingPaymentRefresh,
|
||||||
openAlipayPayment,
|
openAlipayPayment,
|
||||||
PAYMENT_REFRESH_TARGETS,
|
PAYMENT_REFRESH_TARGETS,
|
||||||
showPaymentToast,
|
showPaymentToast,
|
||||||
wechatH5Pay
|
wechatH5Pay
|
||||||
} from '@/utils/payment.js';
|
} from '@/utils/payment.js';
|
||||||
|
import { getPaymentErrorMessage, isUnknownPaymentResult } from '@/utils/payment-attempt.js';
|
||||||
import { formatMoney } from '@/utils/display.js';
|
import { formatMoney } from '@/utils/display.js';
|
||||||
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const popupCandidateShow = ref(false);
|
||||||
|
|
||||||
|
const onPopupCandidateClose = () => {
|
||||||
|
popupCandidateShow.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const showModal = ref(false);
|
const showModal = ref(false);
|
||||||
const currentPackage = ref(null);
|
const currentPackage = ref(null);
|
||||||
@@ -433,7 +445,13 @@
|
|||||||
mask: true
|
mask: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const payResult = await orderApi.pay(orderResult.order.order_id, paymentMethod.value);
|
// Newer create responses already contain payment preparation data.
|
||||||
|
// Reuse it to keep one user submission to one payment-creation
|
||||||
|
// attempt; retain the pay endpoint fallback for older responses.
|
||||||
|
const payResult = hasPreparedPaymentData(orderResult) ||
|
||||||
|
(paymentMethod.value === 'wallet' && orderResult.order.payment_status === 2)
|
||||||
|
? orderResult
|
||||||
|
: await orderApi.pay(orderResult.order.order_id, paymentMethod.value);
|
||||||
|
|
||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
showModal.value = false;
|
showModal.value = false;
|
||||||
@@ -457,6 +475,9 @@
|
|||||||
uni.hideLoading();
|
uni.hideLoading();
|
||||||
showModal.value = false;
|
showModal.value = false;
|
||||||
console.error('创建订单或支付失败', error);
|
console.error('创建订单或支付失败', error);
|
||||||
|
if (isUnknownPaymentResult(error)) {
|
||||||
|
markPendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.PACKAGE_ORDER);
|
||||||
|
}
|
||||||
|
|
||||||
if (error.code === 1008) {
|
if (error.code === 1008) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
@@ -473,7 +494,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: error.msg || error.message || '操作失败,请稍后重试',
|
title: getPaymentErrorMessage(error, '操作失败,请稍后重试'),
|
||||||
icon: 'none'
|
icon: 'none'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -515,6 +536,7 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
renewalPackageNames.value = [];
|
renewalPackageNames.value = [];
|
||||||
}
|
}
|
||||||
|
popupCandidateShow.value = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -27,8 +27,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="carrier-iccid">
|
<view class="carrier-iccid">
|
||||||
<text class="iccid-value-text">{{ item.iccid }}</text>
|
<text class="iccid-value-text">{{ item.iccid }}</text>
|
||||||
<image class="copy-icon" src="/static/复制.png" mode="aspectFit"
|
<button class="copy-button" @tap.stop="copyIccid(item.iccid)" aria-label="复制ICCID">复制</button>
|
||||||
@tap.stop="copyIccid(item.iccid)" aria-label="复制ICCID"></image>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="card-actions">
|
<view class="card-actions">
|
||||||
<button v-if="item.is_current" class="btn-apple btn-primary action-button" disabled>当前使用</button>
|
<button v-if="item.is_current" class="btn-apple btn-primary action-button" disabled>当前使用</button>
|
||||||
@@ -330,13 +329,6 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-icon {
|
|
||||||
width: 30rpx;
|
|
||||||
height: 30rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carrier-statuses {
|
.carrier-statuses {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
2
qt2.md
Normal file
2
qt2.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<view class="bottom-spacer"></view>
|
||||||
|
<view class="plain"></view>
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
import { reactive, ref } from 'vue';
|
import { reactive } from 'vue';
|
||||||
|
import { isCurrentSession } from '@/utils/session-context.js';
|
||||||
|
import { getShanghaiDay, normalizeExpiryReminder } from '@/utils/expiry-reminder.js';
|
||||||
|
|
||||||
|
const receivedReminders = new Set();
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
assetInfo: null,
|
assetInfo: null,
|
||||||
deviceRealtime: null,
|
deviceRealtime: null,
|
||||||
currentPackage: null
|
currentPackage: null,
|
||||||
|
expiryReminder: null
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useAssetStore = () => {
|
export const useAssetStore = () => {
|
||||||
|
const receiveExpiryReminder = (context, value) => {
|
||||||
|
const reminder = normalizeExpiryReminder(value);
|
||||||
|
if (!reminder || !isCurrentSession(context)) return;
|
||||||
|
const day = getShanghaiDay();
|
||||||
|
const key = JSON.stringify([context.token, context.identifier, day]);
|
||||||
|
if (receivedReminders.has(key)) return;
|
||||||
|
receivedReminders.add(key);
|
||||||
|
state.expiryReminder = { ...context, day, reminder, shown: false };
|
||||||
|
};
|
||||||
|
const markExpiryReminderShown = () => {
|
||||||
|
if (state.expiryReminder) state.expiryReminder.shown = true;
|
||||||
|
};
|
||||||
|
const dismissExpiryReminder = () => { state.expiryReminder = null; };
|
||||||
const setAssetInfo = (info) => {
|
const setAssetInfo = (info) => {
|
||||||
state.assetInfo = info;
|
state.assetInfo = info;
|
||||||
};
|
};
|
||||||
@@ -23,6 +41,8 @@ export const useAssetStore = () => {
|
|||||||
state.assetInfo = null;
|
state.assetInfo = null;
|
||||||
state.deviceRealtime = null;
|
state.deviceRealtime = null;
|
||||||
state.currentPackage = null;
|
state.currentPackage = null;
|
||||||
|
state.expiryReminder = null;
|
||||||
|
receivedReminders.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -30,6 +50,9 @@ export const useAssetStore = () => {
|
|||||||
setAssetInfo,
|
setAssetInfo,
|
||||||
setDeviceRealtime,
|
setDeviceRealtime,
|
||||||
setCurrentPackage,
|
setCurrentPackage,
|
||||||
|
receiveExpiryReminder,
|
||||||
|
markExpiryReminderShown,
|
||||||
|
dismissExpiryReminder,
|
||||||
clearAsset
|
clearAsset
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
import { reactive, ref } from 'vue';
|
import { reactive } from 'vue';
|
||||||
|
import { optionalBoolean, getBindingSuccessState } from '@/utils/phone-bind.js';
|
||||||
|
import { useAssetStore } from './asset.js';
|
||||||
|
|
||||||
|
const PHONE_STATE_KEY = 'customerAssetPhoneState';
|
||||||
|
const emptyPhoneState = () => ({
|
||||||
|
customerPhone: null,
|
||||||
|
assetPhoneBound: null,
|
||||||
|
assetPhoneIsOwn: null,
|
||||||
|
needBindPhone: null
|
||||||
|
});
|
||||||
|
const savedPhoneState = uni.getStorageSync(PHONE_STATE_KEY);
|
||||||
|
const token = uni.getStorageSync('token') || '';
|
||||||
|
const identifier = uni.getStorageSync('identifier') || '';
|
||||||
|
const isOAuthCallback = typeof window !== 'undefined' && typeof sessionStorage !== 'undefined' &&
|
||||||
|
!!new URLSearchParams(window.location.search).get('code') && !!sessionStorage.getItem('assetToken');
|
||||||
|
const phoneState = savedPhoneState?.token === token && savedPhoneState?.identifier === identifier && token
|
||||||
|
? {
|
||||||
|
customerPhone: typeof savedPhoneState.customerPhone === 'string' ? savedPhoneState.customerPhone : null,
|
||||||
|
assetPhoneBound: optionalBoolean(savedPhoneState.assetPhoneBound),
|
||||||
|
assetPhoneIsOwn: optionalBoolean(savedPhoneState.assetPhoneIsOwn),
|
||||||
|
needBindPhone: optionalBoolean(savedPhoneState.needBindPhone)
|
||||||
|
}
|
||||||
|
: emptyPhoneState();
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
token: uni.getStorageSync('token') || '',
|
token,
|
||||||
assetToken: '',
|
assetToken: '',
|
||||||
identifier: uni.getStorageSync('identifier') || '',
|
identifier,
|
||||||
|
loginPending: isOAuthCallback,
|
||||||
|
...phoneState,
|
||||||
|
phoneRevision: 0,
|
||||||
isDevice: false,
|
isDevice: false,
|
||||||
realNameStatus: 0,
|
realNameStatus: 0,
|
||||||
userInfo: uni.getStorageSync('userInfo') || {
|
userInfo: uni.getStorageSync('userInfo') || {
|
||||||
@@ -13,7 +39,26 @@ const state = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const useUserStore = () => {
|
export const useUserStore = () => {
|
||||||
|
const setLoginPending = (pending) => { state.loginPending = pending === true; };
|
||||||
|
const persistPhoneState = () => {
|
||||||
|
uni.setStorageSync(PHONE_STATE_KEY, {
|
||||||
|
token: state.token, identifier: state.identifier,
|
||||||
|
customerPhone: state.customerPhone,
|
||||||
|
assetPhoneBound: state.assetPhoneBound,
|
||||||
|
assetPhoneIsOwn: state.assetPhoneIsOwn,
|
||||||
|
needBindPhone: state.needBindPhone
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const resetContext = () => {
|
||||||
|
Object.assign(state, emptyPhoneState());
|
||||||
|
state.phoneRevision++;
|
||||||
|
uni.removeStorageSync(PHONE_STATE_KEY);
|
||||||
|
// The legacy per-asset cache mixed customer phones with asset phones.
|
||||||
|
uni.removeStorageSync('phoneBindStatus');
|
||||||
|
useAssetStore().clearAsset();
|
||||||
|
};
|
||||||
const setToken = (token) => {
|
const setToken = (token) => {
|
||||||
|
if (state.token !== token) resetContext();
|
||||||
state.token = token;
|
state.token = token;
|
||||||
uni.setStorageSync('token', token);
|
uni.setStorageSync('token', token);
|
||||||
};
|
};
|
||||||
@@ -23,6 +68,7 @@ export const useUserStore = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setIdentifier = (identifier) => {
|
const setIdentifier = (identifier) => {
|
||||||
|
if (state.identifier !== identifier) resetContext();
|
||||||
state.identifier = identifier;
|
state.identifier = identifier;
|
||||||
uni.setStorageSync('identifier', identifier);
|
uni.setStorageSync('identifier', identifier);
|
||||||
};
|
};
|
||||||
@@ -40,9 +86,40 @@ export const useUserStore = () => {
|
|||||||
uni.setStorageSync('userInfo', userInfo);
|
uni.setStorageSync('userInfo', userInfo);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setLoginPhoneState = (data) => {
|
||||||
|
state.assetPhoneBound = optionalBoolean(data.asset_phone_bound);
|
||||||
|
state.assetPhoneIsOwn = optionalBoolean(data.asset_phone_is_own);
|
||||||
|
state.needBindPhone = optionalBoolean(data.need_bind_phone);
|
||||||
|
// A required prompt with an already-bound asset means only the customer
|
||||||
|
// needs a phone. Other login combinations do not expose the customer phone.
|
||||||
|
if (state.assetPhoneBound === true && state.needBindPhone === true) state.customerPhone = '';
|
||||||
|
persistPhoneState();
|
||||||
|
};
|
||||||
|
const setCustomerPhone = (phone) => {
|
||||||
|
if (typeof phone !== 'string') return;
|
||||||
|
state.customerPhone = phone;
|
||||||
|
persistPhoneState();
|
||||||
|
};
|
||||||
|
const completePhoneBinding = (phone, mode) => {
|
||||||
|
Object.assign(state, getBindingSuccessState(state, phone, mode));
|
||||||
|
state.phoneRevision++;
|
||||||
|
persistPhoneState();
|
||||||
|
};
|
||||||
|
const completeCustomerPhoneChange = (phone) => {
|
||||||
|
state.customerPhone = phone;
|
||||||
|
state.assetPhoneIsOwn = null;
|
||||||
|
state.phoneRevision++;
|
||||||
|
persistPhoneState();
|
||||||
|
};
|
||||||
|
|
||||||
const clearUser = () => {
|
const clearUser = () => {
|
||||||
|
resetContext();
|
||||||
state.token = '';
|
state.token = '';
|
||||||
|
state.loginPending = false;
|
||||||
state.assetToken = '';
|
state.assetToken = '';
|
||||||
|
state.identifier = '';
|
||||||
|
state.isDevice = false;
|
||||||
|
state.realNameStatus = 0;
|
||||||
state.userInfo = { avatar: '', nickname: '' };
|
state.userInfo = { avatar: '', nickname: '' };
|
||||||
uni.removeStorageSync('token');
|
uni.removeStorageSync('token');
|
||||||
uni.removeStorageSync('identifier');
|
uni.removeStorageSync('identifier');
|
||||||
@@ -52,11 +129,16 @@ export const useUserStore = () => {
|
|||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
setToken,
|
setToken,
|
||||||
|
setLoginPending,
|
||||||
setAssetToken,
|
setAssetToken,
|
||||||
setIdentifier,
|
setIdentifier,
|
||||||
setIsDevice,
|
setIsDevice,
|
||||||
setRealNameStatus,
|
setRealNameStatus,
|
||||||
setUserInfo,
|
setUserInfo,
|
||||||
|
setLoginPhoneState,
|
||||||
|
setCustomerPhone,
|
||||||
|
completePhoneBinding,
|
||||||
|
completeCustomerPhoneChange,
|
||||||
clearUser
|
clearUser
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
19
tests/mock-h5-browser-init.js
Normal file
19
tests/mock-h5-browser-init.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Load with agent-browser --init-script when checking the local preview.
|
||||||
|
if (location.hostname === '127.0.0.1' && location.port === '48138') {
|
||||||
|
localStorage.setItem('token', 'mock-session');
|
||||||
|
localStorage.setItem('identifier', 'A');
|
||||||
|
localStorage.setItem('customerAssetPhoneState', JSON.stringify({
|
||||||
|
type: 'object',
|
||||||
|
data: {
|
||||||
|
token: 'mock-session', identifier: 'A', customerPhone: '13800000001',
|
||||||
|
assetPhoneBound: false, assetPhoneIsOwn: false, needBindPhone: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const originalOpen = XMLHttpRequest.prototype.open;
|
||||||
|
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||||
|
if (typeof url === 'string' && url.includes('/api/c/v1/')) {
|
||||||
|
url = location.origin + '/__mock/' + url.split('/api/c/v1/')[1];
|
||||||
|
}
|
||||||
|
return originalOpen.call(this, method, url, ...rest);
|
||||||
|
};
|
||||||
|
}
|
||||||
39
tests/mock-h5-preview.mjs
Normal file
39
tests/mock-h5-preview.mjs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Local-only visual QA: no request reaches the real backend.
|
||||||
|
import { preview } from 'vite';
|
||||||
|
|
||||||
|
let notificationRead = false;
|
||||||
|
|
||||||
|
const server = await preview({
|
||||||
|
configFile: false,
|
||||||
|
build: { outDir: 'dist/build/h5' },
|
||||||
|
preview: { host: '127.0.0.1', port: 48138, strictPort: true },
|
||||||
|
plugins: [{
|
||||||
|
name: 'mock-h5-api',
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
if (!req.url.startsWith('/__mock/')) return next();
|
||||||
|
let data = {};
|
||||||
|
if (req.url.startsWith('/__mock/asset/info')) data = {
|
||||||
|
identifier: 'A', asset_type: 'card', bound_phone: '13800000001',
|
||||||
|
current_package: '测试套餐', current_package_id: 1, current_package_usage_id: 1,
|
||||||
|
real_total_mb: 1024, real_used_mb: 256,
|
||||||
|
estimated_final_expires_at: '2026-10-13T16:00:00Z', days_until_final_expiry: 0, is_expiring: true,
|
||||||
|
expiry_reminder: { remaining_days: 0, expires_at: '2026-10-13T16:00:00Z', level: 'red', level_name: '即将到期' }
|
||||||
|
};
|
||||||
|
if (req.url.startsWith('/__mock/notifications')) data = { count: 0, items: [], total: 0 };
|
||||||
|
if (process.env.H5_MOCK_NOTIFICATIONS === '1') {
|
||||||
|
if (req.url.startsWith('/__mock/notifications/unread-count')) data = { count: notificationRead ? 0 : 1 };
|
||||||
|
else if (/^\/__mock\/notifications\/42\/read/.test(req.url)) { notificationRead = true; data = { success: true }; }
|
||||||
|
else if (req.url.startsWith('/__mock/notifications/read-all')) { notificationRead = true; data = { updated_count: 1 }; }
|
||||||
|
else if (req.url.startsWith('/__mock/notifications')) data = {
|
||||||
|
items: [{ id: 42, title: '套餐临期提醒', body: '您的套餐即将到期,请及时续费。', category: 'expiry', severity: 'warning', is_read: notificationRead, created_at: '2026-10-13T08:00:00+08:00' }], total: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const conflict = req.url.startsWith('/__mock/auth/bind-phone');
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.end(JSON.stringify({ code: conflict ? 1192 : 0, msg: conflict ? '该资产已绑定手机号,如需更换请联系管理员' : 'success', data }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
server.printUrls();
|
||||||
729
tests/october-h5.test.mjs
Normal file
729
tests/october-h5.test.mjs
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const vue = require('vue');
|
||||||
|
const plain = (value) => JSON.parse(JSON.stringify(value));
|
||||||
|
const deferred = () => {
|
||||||
|
let resolvePromise;
|
||||||
|
const promise = new Promise(resolve => { resolvePromise = resolve; });
|
||||||
|
return { promise, resolve: resolvePromise };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute actual store/API modules and page setup scripts with real Vue
|
||||||
|
// reactivity. Only native uni APIs and lifecycle scheduling are substituted.
|
||||||
|
function runtime(initial = {}) {
|
||||||
|
const storage = new Map(Object.entries({ token: 'session-A', identifier: 'A', ...initial }));
|
||||||
|
const calls = [];
|
||||||
|
const requests = [];
|
||||||
|
const mounted = [];
|
||||||
|
const shown = [];
|
||||||
|
const hidden = [];
|
||||||
|
const unmounted = [];
|
||||||
|
const timers = [];
|
||||||
|
const sessionStorage = new Map();
|
||||||
|
const api = {};
|
||||||
|
let inWechat = false;
|
||||||
|
let sdkInit = async () => {};
|
||||||
|
let options = {};
|
||||||
|
const uni = {
|
||||||
|
getStorageSync: key => storage.get(key) ?? '',
|
||||||
|
setStorageSync: (key, value) => storage.set(key, plain(value)),
|
||||||
|
removeStorageSync: key => storage.delete(key),
|
||||||
|
clearStorageSync: () => storage.clear(),
|
||||||
|
request: request => { requests.push(request); },
|
||||||
|
showToast: value => calls.push({ kind: 'toast', ...value }),
|
||||||
|
showModal: value => calls.push({ kind: 'modal', ...value }),
|
||||||
|
reLaunch: value => calls.push({ kind: 'reLaunch', ...value }),
|
||||||
|
redirectTo: value => calls.push({ kind: 'redirectTo', ...value }),
|
||||||
|
navigateTo: value => calls.push({ kind: 'navigateTo', ...value }),
|
||||||
|
navigateBack: () => calls.push({ kind: 'navigateBack' })
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
uni, Intl, Date, URLSearchParams, URL,
|
||||||
|
console: { log() {}, error() {}, warn() {} },
|
||||||
|
setTimeout: callback => { timers.push(callback); return timers.length; },
|
||||||
|
setInterval: () => 1, clearInterval() {},
|
||||||
|
getCurrentPages: () => [{ options }],
|
||||||
|
sessionStorage: { getItem: key => sessionStorage.get(key) || '', removeItem: key => sessionStorage.delete(key), setItem: (key, value) => sessionStorage.set(key, value) },
|
||||||
|
window: { location: { search: '', pathname: '/', hash: '', origin: 'https://example.invalid' }, history: { replaceState() {} } },
|
||||||
|
defineProps: () => ({}), defineEmits: () => () => {}
|
||||||
|
});
|
||||||
|
const cache = new Map();
|
||||||
|
const synthetic = (key, values) => {
|
||||||
|
if (!cache.has(key)) {
|
||||||
|
cache.set(key, new vm.SyntheticModule(Object.keys(values), function () {
|
||||||
|
for (const [name, value] of Object.entries(values)) this.setExport(name, value);
|
||||||
|
}, { context, identifier: key }));
|
||||||
|
}
|
||||||
|
return cache.get(key);
|
||||||
|
};
|
||||||
|
function moduleFor(path, expose = []) {
|
||||||
|
if (cache.has(path)) return cache.get(path);
|
||||||
|
let source = readFileSync(path, 'utf8');
|
||||||
|
if (path.endsWith('.vue')) {
|
||||||
|
source = source.match(/<script setup>([\s\S]*?)<\/script>/)[1];
|
||||||
|
if (expose.length) source += `\nexport { ${expose.join(', ')} };`;
|
||||||
|
}
|
||||||
|
const module = new vm.SourceTextModule(source, { context, identifier: path });
|
||||||
|
cache.set(path, module);
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
async function linker(specifier, parent) {
|
||||||
|
if (specifier === 'vue') return synthetic('vue', {
|
||||||
|
...vue,
|
||||||
|
onMounted: cb => mounted.push(cb), onUnmounted: cb => unmounted.push(cb)
|
||||||
|
});
|
||||||
|
if (specifier === '@dcloudio/uni-app') return synthetic('uni-app', {
|
||||||
|
onShow: cb => shown.push(cb), onHide: cb => hidden.push(cb), onUnload: cb => unmounted.push(cb)
|
||||||
|
});
|
||||||
|
if (specifier === '@/api/index.js') return synthetic('api-index', {
|
||||||
|
...api, deviceApi: {}, hasActiveOrPendingPackage: async () => true
|
||||||
|
});
|
||||||
|
if (specifier === '@/utils/payment.js') return synthetic('payment', {
|
||||||
|
consumePendingPaymentRefresh: () => false, PAYMENT_REFRESH_TARGETS: { HOME: 'home' }
|
||||||
|
});
|
||||||
|
if (specifier === '@/utils/wxsdk.js') return synthetic('wxsdk', {
|
||||||
|
initWxConfig: () => sdkInit(), wxScan: async () => {}, isInWechat: () => inWechat
|
||||||
|
});
|
||||||
|
if (specifier === './env.js') return synthetic('env', { BASE_URL: 'https://example.invalid' });
|
||||||
|
if (specifier.endsWith('.vue')) return synthetic(specifier, { default: {} });
|
||||||
|
const path = specifier.startsWith('@/') ? resolve(root, specifier.slice(2)) : resolve(dirname(parent.identifier), specifier);
|
||||||
|
return moduleFor(path);
|
||||||
|
}
|
||||||
|
async function load(path, expose) {
|
||||||
|
const module = moduleFor(resolve(root, path), expose);
|
||||||
|
if (module.status === 'unlinked') await module.link(linker);
|
||||||
|
if (module.status === 'linked') await module.evaluate();
|
||||||
|
return module.namespace;
|
||||||
|
}
|
||||||
|
const reply = (request, data = {}, code = 0, statusCode = 200, msg = 'success') => {
|
||||||
|
request.success({ statusCode, data: { code, msg, data } });
|
||||||
|
};
|
||||||
|
return { storage, sessionStorage, context, calls, requests, mounted, shown, hidden, unmounted, timers, api, load, reply,
|
||||||
|
setOptions: value => { options = value; }, tick: () => vue.nextTick(),
|
||||||
|
setWechatSdk: init => { inWechat = true; sdkInit = init; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('phone binding modes and success updates distinguish all four cases', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const { getPhoneBindingMode: mode, getBindingSuccessState: success, optionalBoolean } = await r.load('utils/phone-bind.js');
|
||||||
|
assert.equal(mode('', false), 'customer-asset');
|
||||||
|
assert.equal(mode('13800000001', false), 'asset');
|
||||||
|
assert.equal(mode('', true), 'customer');
|
||||||
|
assert.equal(mode('13800000001', true), 'none');
|
||||||
|
assert.equal(mode(null, false), 'unknown');
|
||||||
|
assert.equal(optionalBoolean(undefined), null);
|
||||||
|
assert.equal(optionalBoolean('false'), null);
|
||||||
|
assert.equal(success({ customerPhone: '13800000001' }, '13900000002', 'asset').customerPhone, undefined);
|
||||||
|
assert.equal(success({ customerPhone: '13800000001' }, '13900000002', 'asset').assetPhoneIsOwn, false);
|
||||||
|
assert.equal(success({}, '13900000002', 'customer').assetPhoneBound, undefined);
|
||||||
|
assert.equal(success({}, '13900000002', 'customer-asset').assetPhoneBound, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package history hides numeric/string status 4 recursively without changing pagination', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const { normalizeAssetPackageHistory } = await r.load('api/modules/asset.js');
|
||||||
|
const input = {
|
||||||
|
page: 1, size: 10, total: 30,
|
||||||
|
items: [
|
||||||
|
{ package_usage_id: 1, status: 4 },
|
||||||
|
{ package_usage_id: 2, status: '4' },
|
||||||
|
{ package_usage_id: 3, status: 1, expand_by_default: 1, children: [
|
||||||
|
{ package_usage_id: 4, status: 4 },
|
||||||
|
{ package_usage_id: 5, status: 2, children: [
|
||||||
|
{ package_usage_id: 6, status: '4' }, { package_usage_id: 7, status: 0 }
|
||||||
|
] }
|
||||||
|
] },
|
||||||
|
{ package_usage_id: 8, status: 3 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = normalizeAssetPackageHistory(input);
|
||||||
|
assert.deepEqual(plain(result.items).map(item => item.package_usage_id), [3, 8]);
|
||||||
|
assert.deepEqual(plain(result.items[0].children).map(item => item.package_usage_id), [5]);
|
||||||
|
assert.deepEqual(plain(result.items[0].children[0].children).map(item => item.package_usage_id), [7]);
|
||||||
|
assert.equal(result.items[0].expand_by_default, true);
|
||||||
|
assert.equal(result.total, 30);
|
||||||
|
assert.equal(result.page, 1);
|
||||||
|
assert.equal(input.items.length, 4);
|
||||||
|
assert.equal(input.items[2].children.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package-history page skips all-invalid pages and keeps subsequent pages reachable', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const { normalizeAssetPackageHistory } = await r.load('api/modules/asset.js');
|
||||||
|
const requestedPages = [];
|
||||||
|
r.api.assetApi = { getPackageHistory: async (identifier, page) => {
|
||||||
|
assert.equal(identifier, 'A'); requestedPages.push(page);
|
||||||
|
return normalizeAssetPackageHistory({ page, size: 10, total: 40,
|
||||||
|
items: [{ package_usage_id: page, status: page === 2 || page === 4 ? 1 : 4 }] });
|
||||||
|
} };
|
||||||
|
const page = await r.load('pages/asset-package-history/asset-package-history.vue', ['loadPackageList', 'packageList', 'noMore']);
|
||||||
|
await page.loadPackageList();
|
||||||
|
assert.deepEqual(requestedPages, [1, 2]);
|
||||||
|
assert.deepEqual(plain(page.packageList.value).map(item => item.package_usage_id), [2]);
|
||||||
|
assert.equal(page.noMore.value, false);
|
||||||
|
await page.loadPackageList(true);
|
||||||
|
assert.deepEqual(requestedPages, [1, 2, 3, 4]);
|
||||||
|
assert.deepEqual(plain(page.packageList.value).map(item => item.package_usage_id), [2, 4]);
|
||||||
|
assert.equal(page.noMore.value, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package history with only invalidated records ends with an empty state', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const { normalizeAssetPackageHistory } = await r.load('api/modules/asset.js');
|
||||||
|
const requestedPages = [];
|
||||||
|
r.api.assetApi = { getPackageHistory: async (identifier, page) => {
|
||||||
|
requestedPages.push(page);
|
||||||
|
return normalizeAssetPackageHistory({ page, size: 10, total: 20, items: [{ status: 4 }] });
|
||||||
|
} };
|
||||||
|
const page = await r.load('pages/asset-package-history/asset-package-history.vue', ['loadPackageList', 'packageList', 'noMore', 'historyLoaded']);
|
||||||
|
await page.loadPackageList();
|
||||||
|
assert.deepEqual(requestedPages, [1, 2]);
|
||||||
|
assert.equal(page.packageList.value.length, 0);
|
||||||
|
assert.equal(page.noMore.value, true);
|
||||||
|
assert.equal(page.historyLoaded.value, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login facts are persisted per token/asset; customer phone changes never migrate assets', async () => {
|
||||||
|
const r = runtime({ phoneBindStatus: { A: { bound: true, phone: '13900000002' } } });
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
assert.equal(user.state.assetPhoneBound, null);
|
||||||
|
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: true });
|
||||||
|
user.setCustomerPhone('13800000001');
|
||||||
|
user.completePhoneBinding('13900000002', 'asset');
|
||||||
|
assert.equal(user.state.customerPhone, '13800000001');
|
||||||
|
assert.equal(user.state.assetPhoneBound, true);
|
||||||
|
assert.equal(user.state.assetPhoneIsOwn, false);
|
||||||
|
user.completeCustomerPhoneChange('13700000003');
|
||||||
|
assert.equal(user.state.customerPhone, '13700000003');
|
||||||
|
assert.equal(user.state.assetPhoneBound, true);
|
||||||
|
assert.equal(user.state.assetPhoneIsOwn, null);
|
||||||
|
const reloaded = runtime(Object.fromEntries(r.storage));
|
||||||
|
assert.equal((await reloaded.load('store/user.js')).useUserStore().state.assetPhoneBound, true);
|
||||||
|
user.setIdentifier('B');
|
||||||
|
assert.equal(user.state.assetPhoneBound, null);
|
||||||
|
assert.equal(user.state.customerPhone, null);
|
||||||
|
assert.equal(r.storage.has('phoneBindStatus'), false);
|
||||||
|
user.setToken('session-B');
|
||||||
|
assert.equal(user.state.needBindPhone, null);
|
||||||
|
user.clearUser();
|
||||||
|
assert.equal(r.storage.has('customerAssetPhoneState'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login routing honors server guidance even with a phone bound to someone else', async () => {
|
||||||
|
for (const required of [false, true]) {
|
||||||
|
const r = runtime({ token: '' });
|
||||||
|
r.sessionStorage.set('assetToken', 'mock-asset-token');
|
||||||
|
r.context.window.location.search = '?code=mock-code';
|
||||||
|
r.api.wechatApi = {};
|
||||||
|
r.api.authApi = { wechatLogin: async () => ({ token: 'new-session', asset_phone_bound: true, asset_phone_is_own: false, need_bind_phone: required }) };
|
||||||
|
const page = await r.load('pages/login/login.vue', ['handleWechatCallback']);
|
||||||
|
await page.handleWechatCallback();
|
||||||
|
assert.equal(r.calls.find(call => call.kind === 'redirectTo').url, required ? '/pages/bind/bind?fromLogin=true' : '/pages/index/index');
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
assert.equal(user.state.assetPhoneBound, true);
|
||||||
|
assert.equal(user.state.assetPhoneIsOwn, false);
|
||||||
|
assert.equal(user.state.needBindPhone, required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth round trip carries the verified identifier without relying on localStorage', async () => {
|
||||||
|
const r = runtime({ token: '', identifier: '' });
|
||||||
|
r.api.authApi = { verifyAsset: async id => { assert.equal(id, 'B'); return { asset_token: 'asset-token-B' }; } };
|
||||||
|
r.api.wechatApi = { getAppId: async () => ({ app_id: 'mock-app-id' }) };
|
||||||
|
const page = await r.load('pages/login/login.vue', ['identifier', 'doLogin']);
|
||||||
|
page.identifier.value = ' B ';
|
||||||
|
await page.doLogin();
|
||||||
|
assert.equal(r.storage.get('identifier'), 'B');
|
||||||
|
assert.deepEqual(JSON.parse(r.sessionStorage.get('wechatLoginContext')), { assetToken: 'asset-token-B', identifier: 'B' });
|
||||||
|
const authUrl = new URL(r.context.window.location.href);
|
||||||
|
assert.equal(new URL(authUrl.searchParams.get('redirect_uri')).searchParams.get('identifier'), 'B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth callback restores its own asset identifier before entering home', async () => {
|
||||||
|
const r = runtime({ token: 'old-token', identifier: 'old-asset' });
|
||||||
|
r.sessionStorage.set('assetToken', 'asset-token-B');
|
||||||
|
r.sessionStorage.set('wechatLoginContext', JSON.stringify({ assetToken: 'asset-token-B', identifier: 'B' }));
|
||||||
|
r.context.window.location.search = '?code=mock-code&identifier=B';
|
||||||
|
r.api.authApi = { wechatLogin: async () => ({ token: 'new-token', need_bind_phone: false }) };
|
||||||
|
r.api.wechatApi = {};
|
||||||
|
await r.load('pages/login/login.vue');
|
||||||
|
await r.mounted[0]();
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
assert.equal(user.state.identifier, 'B');
|
||||||
|
assert.equal(r.storage.get('identifier'), 'B');
|
||||||
|
assert.equal(user.state.token, 'new-token');
|
||||||
|
assert.equal(r.calls.find(call => call.kind === 'redirectTo').url, '/pages/index/index');
|
||||||
|
assert.equal(r.sessionStorage.has('assetToken'), false);
|
||||||
|
const { assetApi } = await r.load('api/modules/asset.js');
|
||||||
|
const info = assetApi.getInfo(user.state.identifier);
|
||||||
|
assert.deepEqual(plain(r.requests[0].data), { identifier: 'B' });
|
||||||
|
r.reply(r.requests[0], { identifier: 'B' }); await info;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback can recover a verified identifier from session even if localStorage is empty', async () => {
|
||||||
|
const r = runtime({ token: '', identifier: '' });
|
||||||
|
r.sessionStorage.set('assetToken', 'asset-token-B');
|
||||||
|
r.sessionStorage.set('wechatLoginContext', JSON.stringify({ assetToken: 'asset-token-B', identifier: 'B' }));
|
||||||
|
r.context.window.location.search = '?code=mock-code';
|
||||||
|
r.api.authApi = { wechatLogin: async () => ({ token: 'new-token', need_bind_phone: true }) };
|
||||||
|
r.api.wechatApi = {};
|
||||||
|
const page = await r.load('pages/login/login.vue', ['handleWechatCallback']);
|
||||||
|
await page.handleWechatCallback();
|
||||||
|
assert.equal(r.storage.get('identifier'), 'B');
|
||||||
|
assert.equal(r.calls.find(call => call.kind === 'redirectTo').url, '/pages/bind/bind?fromLogin=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback falls back to URL/saved identifiers, but never enters home without one', async () => {
|
||||||
|
for (const source of ['url', 'storage', 'missing']) {
|
||||||
|
const r = runtime({ token: '', identifier: '' });
|
||||||
|
r.sessionStorage.set('assetToken', 'mock-asset-token');
|
||||||
|
r.context.window.location.search = source === 'url' ? '?code=mock-code&identifier=B' : '?code=mock-code';
|
||||||
|
if (source === 'storage') r.storage.set('identifier', 'B');
|
||||||
|
let logins = 0;
|
||||||
|
r.api.wechatApi = {};
|
||||||
|
r.api.authApi = { wechatLogin: async () => { logins++; return { token: 'new-token', need_bind_phone: false }; } };
|
||||||
|
const page = await r.load('pages/login/login.vue', ['handleWechatCallback']);
|
||||||
|
await page.handleWechatCallback();
|
||||||
|
if (source === 'missing') {
|
||||||
|
assert.equal(logins, 0);
|
||||||
|
assert.equal(r.calls.some(call => call.kind === 'redirectTo'), false);
|
||||||
|
assert.match(r.calls.find(call => call.kind === 'toast').title, /缺少资产标识/);
|
||||||
|
} else {
|
||||||
|
assert.equal(r.storage.get('identifier'), 'B');
|
||||||
|
assert.equal(logins, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asset-info API rejects empty identifiers without a network request', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const { assetApi } = await r.load('api/modules/asset.js');
|
||||||
|
for (const value of ['', ' ', undefined, null]) {
|
||||||
|
await assert.rejects(assetApi.getInfo(value));
|
||||||
|
}
|
||||||
|
assert.equal(r.requests.length, 0);
|
||||||
|
const request = assetApi.getInfo(' A ');
|
||||||
|
assert.deepEqual(plain(r.requests[0].data), { identifier: 'A' });
|
||||||
|
r.reply(r.requests[0], {}); await request;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asset info waits until both wechat-login and JSSDK readiness complete', async () => {
|
||||||
|
const r = runtime({ token: 'old-token', identifier: 'old-asset' });
|
||||||
|
r.sessionStorage.set('assetToken', 'asset-token-B');
|
||||||
|
r.sessionStorage.set('wechatLoginContext', JSON.stringify({ assetToken: 'asset-token-B', identifier: 'B' }));
|
||||||
|
r.context.window.location.search = '?code=mock-code';
|
||||||
|
const login = deferred();
|
||||||
|
const sdk = deferred();
|
||||||
|
const events = [];
|
||||||
|
r.api.authApi = { wechatLogin: () => { events.push('login'); return login.promise; } };
|
||||||
|
r.api.wechatApi = {};
|
||||||
|
r.setWechatSdk(() => { events.push('jssdk'); return sdk.promise; });
|
||||||
|
await r.load('pages/login/login.vue');
|
||||||
|
const { assetApi } = await r.load('api/modules/asset.js');
|
||||||
|
const pending = r.mounted[0]();
|
||||||
|
await assert.rejects(assetApi.getInfo('old-asset'));
|
||||||
|
assert.equal(r.requests.length, 0);
|
||||||
|
assert.deepEqual(events, ['login']);
|
||||||
|
login.resolve({ token: 'new-token', need_bind_phone: false });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.deepEqual(events, ['login', 'jssdk']);
|
||||||
|
assert.equal(r.calls.some(call => call.kind === 'redirectTo'), false);
|
||||||
|
await assert.rejects(assetApi.getInfo('B'));
|
||||||
|
assert.equal(r.requests.length, 0);
|
||||||
|
sdk.resolve(); await pending;
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
assert.equal(user.state.loginPending, false);
|
||||||
|
assert.equal(r.calls.find(call => call.kind === 'redirectTo').url, '/pages/index/index');
|
||||||
|
const info = assetApi.getInfo(user.state.identifier);
|
||||||
|
assert.deepEqual(plain(r.requests[0].data), { identifier: 'B' });
|
||||||
|
assert.equal(r.requests[0].header.Authorization, 'Bearer new-token');
|
||||||
|
r.reply(r.requests[0], {}); await info;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSSDK failure does not enter the homepage or release asset requests', async () => {
|
||||||
|
const r = runtime({ token: '', identifier: 'A' });
|
||||||
|
r.sessionStorage.set('assetToken', 'asset-token-A');
|
||||||
|
r.context.window.location.search = '?code=mock-code';
|
||||||
|
r.api.authApi = { wechatLogin: async () => ({ token: 'new-token', need_bind_phone: false }) };
|
||||||
|
r.api.wechatApi = {};
|
||||||
|
r.setWechatSdk(async () => { throw new Error('JSSDK failed'); });
|
||||||
|
const page = await r.load('pages/login/login.vue', ['handleWechatCallback']);
|
||||||
|
await page.handleWechatCallback();
|
||||||
|
assert.equal(r.calls.some(call => call.kind === 'redirectTo'), false);
|
||||||
|
const { assetApi } = await r.load('api/modules/asset.js');
|
||||||
|
await assert.rejects(assetApi.getInfo('A'));
|
||||||
|
assert.equal(r.requests.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returning login session initializes JSSDK before re-entering homepage', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
r.api.authApi = {}; r.api.wechatApi = {};
|
||||||
|
const sdk = deferred();
|
||||||
|
r.setWechatSdk(() => sdk.promise);
|
||||||
|
await r.load('pages/login/login.vue');
|
||||||
|
const pending = r.mounted[0]();
|
||||||
|
const { assetApi } = await r.load('api/modules/asset.js');
|
||||||
|
await assert.rejects(assetApi.getInfo('A'));
|
||||||
|
assert.equal(r.calls.some(call => call.kind === 'reLaunch'), false);
|
||||||
|
sdk.resolve(); await pending;
|
||||||
|
assert.equal(r.calls.find(call => call.kind === 'reLaunch').url, '/pages/index/index');
|
||||||
|
const info = assetApi.getInfo('A');
|
||||||
|
assert.equal(r.requests.length, 1);
|
||||||
|
r.reply(r.requests[0], {}); await info;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expiry boundaries and Shanghai rendering preserve server values, including zero', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const { normalizeExpiryReminder, getShanghaiDay, formatShanghaiExpiry } = await r.load('utils/expiry-reminder.js');
|
||||||
|
for (const days of [0, 3, 4, 7, 8, 15]) {
|
||||||
|
const level = days <= 3 ? 'red' : days <= 7 ? 'purple' : 'pink';
|
||||||
|
assert.equal(normalizeExpiryReminder({ remaining_days: days, level }).remaining_days, days);
|
||||||
|
}
|
||||||
|
for (const value of [undefined, null, {}, { remaining_days: -1, level: 'red' }, { remaining_days: 16, level: 'pink' }]) {
|
||||||
|
assert.equal(normalizeExpiryReminder(value), null);
|
||||||
|
}
|
||||||
|
assert.equal(getShanghaiDay(new Date('2026-10-12T15:59:59Z')), '2026-10-12');
|
||||||
|
assert.equal(getShanghaiDay(new Date('2026-10-12T16:00:00Z')), '2026-10-13');
|
||||||
|
assert.match(formatShanghaiExpiry('2026-10-12T16:00:00Z'), /2026.*10.*13.*00:00/);
|
||||||
|
assert.equal(formatShanghaiExpiry('2026-10-13T12:30:00'), '2026-10-13 12:30:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homepage expiry popup never displays backend level labels', () => {
|
||||||
|
const source = readFileSync(resolve(root, 'components/ExpiryReminderPopup.vue'), 'utf8');
|
||||||
|
assert.doesNotMatch(source, /level_name|reminder-level/);
|
||||||
|
assert.match(source, /reminder\.remaining_days/);
|
||||||
|
assert.match(source, /formatShanghaiExpiry\(reminder\.expires_at\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared asset-info receiver keeps a child-first reminder and rejects stale sessions/phones', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const { assetApi } = await r.load('api/modules/asset.js');
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
const asset = (await r.load('store/asset.js')).useAssetStore();
|
||||||
|
const first = assetApi.getInfo('A');
|
||||||
|
const second = assetApi.getInfo('A');
|
||||||
|
r.reply(r.requests[0], { bound_phone: '13800000001', expiry_reminder: { remaining_days: 0, level: 'red', expires_at: '2026-10-13T16:00:00Z' } });
|
||||||
|
await first;
|
||||||
|
r.reply(r.requests[1], { bound_phone: '13800000001' });
|
||||||
|
await second;
|
||||||
|
assert.equal(asset.state.expiryReminder.reminder.remaining_days, 0);
|
||||||
|
asset.markExpiryReminderShown();
|
||||||
|
asset.dismissExpiryReminder();
|
||||||
|
const repeated = assetApi.getInfo('A');
|
||||||
|
r.reply(r.requests[2], { expiry_reminder: { remaining_days: 0, level: 'red' } });
|
||||||
|
await repeated;
|
||||||
|
assert.equal(asset.state.expiryReminder, null);
|
||||||
|
const oldPhoneResponse = assetApi.getInfo('A');
|
||||||
|
user.completeCustomerPhoneChange('13700000003');
|
||||||
|
r.reply(r.requests[3], { bound_phone: '13800000001' });
|
||||||
|
await oldPhoneResponse;
|
||||||
|
assert.equal(user.state.customerPhone, '13700000003');
|
||||||
|
const late = assetApi.getInfo('A');
|
||||||
|
user.setIdentifier('B');
|
||||||
|
r.reply(r.requests[4], { bound_phone: '13800000001', expiry_reminder: { remaining_days: 3, level: 'red' } });
|
||||||
|
await late;
|
||||||
|
assert.equal(asset.state.expiryReminder, null);
|
||||||
|
assert.equal(user.state.customerPhone, null);
|
||||||
|
const b = assetApi.getInfo('B');
|
||||||
|
r.reply(r.requests[5], { expiry_reminder: { remaining_days: 8, level: 'pink' } });
|
||||||
|
await b;
|
||||||
|
assert.equal(asset.state.expiryReminder.identifier, 'B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notification APIs use PUT for read-all and single-read without new parameters', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const { notificationApi } = await r.load('api/modules/notification.js');
|
||||||
|
const all = notificationApi.markAllRead();
|
||||||
|
assert.equal(r.requests[0].method, 'PUT');
|
||||||
|
assert.match(r.requests[0].url, /\/notifications\/read-all$/);
|
||||||
|
r.reply(r.requests[0]); await all;
|
||||||
|
const one = notificationApi.markRead(12);
|
||||||
|
assert.equal(r.requests[1].method, 'PUT');
|
||||||
|
assert.deepEqual(plain(r.requests[1].data), { id: 12 });
|
||||||
|
r.reply(r.requests[1]); await one;
|
||||||
|
const list = notificationApi.getList(1, 20);
|
||||||
|
assert.deepEqual(plain(r.requests[2].data), { page: 1, page_size: 20 });
|
||||||
|
r.reply(r.requests[2]); await list;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('1192 disables repeated submission, while 1184 keeps the backend error', async () => {
|
||||||
|
for (const code of [1192, 1184]) {
|
||||||
|
const r = runtime();
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: false });
|
||||||
|
user.setCustomerPhone('13800000001');
|
||||||
|
let submits = 0;
|
||||||
|
r.api.authApi = { bindPhone: async () => { submits++; throw { code, msg: '手机号已被其他客户绑定' }; } };
|
||||||
|
r.api.assetApi = { getInfo: async () => ({ bound_phone: '13800000001' }) };
|
||||||
|
const page = await r.load('pages/bind/bind.vue', ['bind', 'bindPhone', 'assetConflict']);
|
||||||
|
await r.mounted[0]();
|
||||||
|
page.bind.phone = '13900000002'; page.bind.code = '123456';
|
||||||
|
await page.bindPhone();
|
||||||
|
if (code === 1192) {
|
||||||
|
await page.bindPhone();
|
||||||
|
assert.equal(submits, 1);
|
||||||
|
assert.equal(page.assetConflict.value, true);
|
||||||
|
assert.match(r.calls.find(call => call.kind === 'modal').content, /联系管理员/);
|
||||||
|
} else {
|
||||||
|
assert.equal(page.assetConflict.value, false);
|
||||||
|
assert.equal(r.calls.find(call => call.kind === 'toast').title, '手机号已被其他客户绑定');
|
||||||
|
}
|
||||||
|
assert.equal(user.state.assetPhoneBound, false);
|
||||||
|
assert.equal(user.state.customerPhone, '13800000001');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asset-only binding accepts a different phone; mandatory binding keeps the session and goes home', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: false });
|
||||||
|
user.setCustomerPhone('13800000001');
|
||||||
|
let submitted;
|
||||||
|
r.api.authApi = { bindPhone: async (phone, code) => { submitted = { phone, code }; } };
|
||||||
|
r.api.assetApi = { getInfo: async () => ({ bound_phone: '13800000001' }) };
|
||||||
|
const page = await r.load('pages/bind/bind.vue', ['bind', 'bindPhone', 'bindingMode']);
|
||||||
|
await r.mounted[0]();
|
||||||
|
assert.equal(page.bindingMode.value, 'asset');
|
||||||
|
page.bind.phone = '13900000002'; page.bind.code = '123456';
|
||||||
|
await page.bindPhone();
|
||||||
|
assert.deepEqual(submitted, { phone: '13900000002', code: '123456' });
|
||||||
|
assert.equal(user.state.customerPhone, '13800000001');
|
||||||
|
assert.equal(user.state.assetPhoneBound, true);
|
||||||
|
r.timers[0]();
|
||||||
|
assert.equal(r.calls.find(call => call.kind === 'navigateBack').kind, 'navigateBack');
|
||||||
|
for (const assetBound of [true, false]) {
|
||||||
|
const mandatory = runtime({ postBindReloginNotice: '1' });
|
||||||
|
mandatory.setOptions({ fromLogin: 'true' });
|
||||||
|
const mandatoryUser = (await mandatory.load('store/user.js')).useUserStore();
|
||||||
|
mandatoryUser.setLoginPhoneState({ asset_phone_bound: assetBound, asset_phone_is_own: false, need_bind_phone: true });
|
||||||
|
const mandatoryAsset = (await mandatory.load('store/asset.js')).useAssetStore();
|
||||||
|
mandatoryAsset.receiveExpiryReminder({ token: 'session-A', identifier: 'A' }, { remaining_days: 0, level: 'red' });
|
||||||
|
mandatoryUser.setUserInfo({ nickname: '测试客户' });
|
||||||
|
mandatory.api.authApi = { bindPhone: async () => {} };
|
||||||
|
mandatory.api.assetApi = { getInfo: async () => ({ bound_phone: '' }) };
|
||||||
|
const mandatoryPage = await mandatory.load('pages/bind/bind.vue', ['bind', 'bindPhone']);
|
||||||
|
await mandatory.mounted[0]();
|
||||||
|
mandatoryPage.bind.phone = '13900000002'; mandatoryPage.bind.code = '123456';
|
||||||
|
await mandatoryPage.bindPhone();
|
||||||
|
assert.equal(mandatory.calls.find(call => call.kind === 'reLaunch').url, '/pages/index/index');
|
||||||
|
assert.equal(mandatory.storage.get('identifier'), 'A');
|
||||||
|
assert.equal(mandatory.storage.get('token'), 'session-A');
|
||||||
|
assert.equal(mandatoryUser.state.token, 'session-A');
|
||||||
|
assert.equal(mandatoryUser.state.needBindPhone, false);
|
||||||
|
assert.equal(mandatoryUser.state.userInfo.nickname, '测试客户');
|
||||||
|
assert.equal(mandatoryAsset.state.expiryReminder.reminder.remaining_days, 0);
|
||||||
|
assert.equal(mandatory.storage.has('postBindReloginNotice'), false);
|
||||||
|
assert.equal(mandatory.calls.some(call => call.url === '/pages/login/login'), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binding still marks a previously unbound asset bound when prefill fails', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
r.setOptions({ fromLogin: 'true' });
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
user.setLoginPhoneState({ asset_phone_bound: false, asset_phone_is_own: false, need_bind_phone: true });
|
||||||
|
r.api.assetApi = { getInfo: async () => { throw new Error('offline'); } };
|
||||||
|
r.api.authApi = { bindPhone: async () => {} };
|
||||||
|
const page = await r.load('pages/bind/bind.vue', ['bind', 'bindPhone']);
|
||||||
|
await r.mounted[0]();
|
||||||
|
page.bind.phone = '13900000002'; page.bind.code = '123456';
|
||||||
|
await page.bindPhone();
|
||||||
|
assert.equal(user.state.assetPhoneBound, true);
|
||||||
|
assert.equal(user.state.assetPhoneIsOwn, null);
|
||||||
|
assert.equal(user.state.customerPhone, null);
|
||||||
|
assert.equal(user.state.needBindPhone, false);
|
||||||
|
assert.equal(r.storage.get('token'), 'session-A');
|
||||||
|
assert.equal(r.calls.find(call => call.kind === 'reLaunch').url, '/pages/index/index');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('change-phone page updates only the confirmed customer phone', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
user.setLoginPhoneState({ asset_phone_bound: true, asset_phone_is_own: true, need_bind_phone: false });
|
||||||
|
user.setCustomerPhone('13800000001');
|
||||||
|
r.api.assetApi = { getInfo: async () => ({ bound_phone: '13800000001' }) };
|
||||||
|
r.api.authApi = { changePhone: async () => ({ phone: '13900000002' }) };
|
||||||
|
const page = await r.load('pages/change-phone/change-phone.vue', ['form', 'submit']);
|
||||||
|
await r.mounted[0]();
|
||||||
|
page.form.oldCode = '123456'; page.form.newPhone = '13900000002'; page.form.newCode = '654321';
|
||||||
|
await page.submit();
|
||||||
|
assert.equal(user.state.customerPhone, '13900000002');
|
||||||
|
assert.equal(user.state.assetPhoneBound, true);
|
||||||
|
assert.equal(user.state.assetPhoneIsOwn, null);
|
||||||
|
assert.equal(page.form.oldPhone, '13900000002');
|
||||||
|
assert.equal(r.storage.has('phoneBindStatus'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('home shows shared reminder once and excludes only expiry from old notification popups', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
r.api.assetApi = { getInfo: async () => ({ identifier: 'A' }) };
|
||||||
|
r.api.notificationApi = {
|
||||||
|
getList: async () => ({ items: [{ id: 1, category: 'expiry', is_read: false }, { id: 2, category: 'approval', is_read: false }] }),
|
||||||
|
getUnreadCount: async () => ({ count: 2 }), markRead: async () => {}
|
||||||
|
};
|
||||||
|
const asset = (await r.load('store/asset.js')).useAssetStore();
|
||||||
|
asset.receiveExpiryReminder({ token: 'session-A', identifier: 'A' }, { remaining_days: 0, level: 'red' });
|
||||||
|
const page = await r.load('pages/index/index.vue', ['visibleExpiryReminder', 'loadUnreadNotifications', 'notificationItems', 'notificationPopupShow', 'closeExpiryReminder', 'onPopupCandidateClose']);
|
||||||
|
await r.shown[0]();
|
||||||
|
assert.equal(page.visibleExpiryReminder.value, null);
|
||||||
|
assert.equal(asset.state.expiryReminder.shown, false);
|
||||||
|
page.onPopupCandidateClose('empty'); await r.tick();
|
||||||
|
assert.equal(page.visibleExpiryReminder.value.remaining_days, 0);
|
||||||
|
await page.loadUnreadNotifications();
|
||||||
|
assert.deepEqual(plain(page.notificationItems.value).map(item => item.id), [2]);
|
||||||
|
assert.equal(page.notificationPopupShow.value, false);
|
||||||
|
page.closeExpiryReminder();
|
||||||
|
assert.equal(page.notificationPopupShow.value, true);
|
||||||
|
r.hidden[0](); await r.shown[0](); page.onPopupCandidateClose('empty'); await r.tick();
|
||||||
|
assert.equal(page.visibleExpiryReminder.value, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expiry popup waits for an open notification and closing it does not mark expiry read', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
let markReads = 0;
|
||||||
|
r.api.assetApi = { getInfo: async () => ({ identifier: 'A' }) };
|
||||||
|
r.api.notificationApi = {
|
||||||
|
getList: async () => ({ items: [{ id: 2, category: 'approval', is_read: false }] }),
|
||||||
|
getUnreadCount: async () => ({ count: 1 }), markRead: async () => { markReads++; }
|
||||||
|
};
|
||||||
|
const page = await r.load('pages/index/index.vue', ['visibleExpiryReminder', 'notificationPopupShow', 'onPopupCandidateClose', 'closeNotificationPopup', 'closeExpiryReminder']);
|
||||||
|
await r.shown[0](); page.onPopupCandidateClose('empty');
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.equal(page.notificationPopupShow.value, true);
|
||||||
|
const asset = (await r.load('store/asset.js')).useAssetStore();
|
||||||
|
asset.receiveExpiryReminder({ token: 'session-A', identifier: 'A' }, { remaining_days: 7, level: 'purple' });
|
||||||
|
await r.tick();
|
||||||
|
assert.equal(page.visibleExpiryReminder.value, null);
|
||||||
|
assert.equal(asset.state.expiryReminder.shown, false);
|
||||||
|
page.closeNotificationPopup();
|
||||||
|
assert.equal(page.visibleExpiryReminder.value.remaining_days, 7);
|
||||||
|
const previousReads = markReads;
|
||||||
|
page.closeExpiryReminder();
|
||||||
|
assert.equal(markReads, previousReads);
|
||||||
|
assert.equal(page.visibleExpiryReminder.value, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expiry renewal reuses homepage renewal and keeps queued notifications behind payment', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
r.api.assetApi = { getInfo: async () => ({ identifier: 'A', current_package_id: 12, current_package: '月套餐', renewal_price: 990 }) };
|
||||||
|
r.api.notificationApi = {
|
||||||
|
getList: async () => ({ items: [{ id: 2, category: 'approval', is_read: false }] }),
|
||||||
|
getUnreadCount: async () => ({ count: 1 }), markRead: async () => {}
|
||||||
|
};
|
||||||
|
const asset = (await r.load('store/asset.js')).useAssetStore();
|
||||||
|
asset.receiveExpiryReminder({ token: 'session-A', identifier: 'A' }, { remaining_days: 0, level: 'red' });
|
||||||
|
const page = await r.load('pages/index/index.vue', ['visibleExpiryReminder', 'notificationPopupShow', 'onPopupCandidateClose', 'loadUnreadNotifications', 'renewalPopupRef', 'onRenewalVisibilityChange', 'renewFromExpiryReminder', 'startHomepageRenewal']);
|
||||||
|
await r.shown[0](); page.onPopupCandidateClose('empty');
|
||||||
|
await page.loadUnreadNotifications();
|
||||||
|
assert.equal(page.visibleExpiryReminder.value.remaining_days, 0);
|
||||||
|
const opens = [];
|
||||||
|
page.renewalPopupRef.value = { open: async (...args) => {
|
||||||
|
assert.equal(page.visibleExpiryReminder.value, null);
|
||||||
|
assert.equal(page.notificationPopupShow.value, false);
|
||||||
|
opens.push(plain(args));
|
||||||
|
page.onRenewalVisibilityChange(true);
|
||||||
|
} };
|
||||||
|
await page.renewFromExpiryReminder();
|
||||||
|
assert.deepEqual(opens, [[[12], ['月套餐'], 990]]);
|
||||||
|
assert.equal(page.notificationPopupShow.value, false);
|
||||||
|
page.onRenewalVisibilityChange(false);
|
||||||
|
assert.equal(page.notificationPopupShow.value, true);
|
||||||
|
page.notificationPopupShow.value = false;
|
||||||
|
await page.startHomepageRenewal();
|
||||||
|
assert.deepEqual(opens[1], opens[0]);
|
||||||
|
const source = readFileSync(resolve(root, 'components/ExpiryReminderPopup.vue'), 'utf8');
|
||||||
|
assert.match(source, /@tap="\$emit\('renew'\)">立即续费/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notification center refreshes server scope after read-all; failures keep unread items', async () => {
|
||||||
|
for (const fail of [false, true]) {
|
||||||
|
const r = runtime();
|
||||||
|
let readAll = false;
|
||||||
|
r.api.notificationApi = {
|
||||||
|
getList: async () => ({ items: [{ id: 1, is_read: readAll, category: 'expiry' }], total: 1 }),
|
||||||
|
getUnreadCount: async () => ({ count: readAll ? 0 : 1 }),
|
||||||
|
markAllRead: async () => { if (fail) throw new Error('offline'); readAll = true; }
|
||||||
|
};
|
||||||
|
const page = await r.load('pages/notifications/notifications.vue', ['loadNotifications', 'loadUnreadCount', 'markAllRead', 'notifications', 'unreadCount']);
|
||||||
|
await page.loadNotifications(); await page.loadUnreadCount();
|
||||||
|
await page.markAllRead();
|
||||||
|
assert.equal(page.notifications[0].is_read, !fail);
|
||||||
|
assert.equal(page.unreadCount.value, fail ? 1 : 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a notification card updates its read badge only after successful mark-read', async () => {
|
||||||
|
for (const fail of [false, true]) {
|
||||||
|
const r = runtime();
|
||||||
|
let marked = false;
|
||||||
|
r.api.notificationApi = {
|
||||||
|
getUnreadCount: async () => ({ count: marked ? 0 : 1 }),
|
||||||
|
markRead: async id => { assert.equal(id, 42); if (fail) throw new Error('offline'); marked = true; }
|
||||||
|
};
|
||||||
|
const page = await r.load('pages/notifications/notifications.vue', ['notifications', 'markItemRead', 'unreadCount']);
|
||||||
|
page.notifications.push({ id: 42, is_read: false, title: '套餐提醒' });
|
||||||
|
page.unreadCount.value = 1;
|
||||||
|
await page.markItemRead(page.notifications[0]);
|
||||||
|
assert.equal(page.notifications[0].is_read, !fail);
|
||||||
|
assert.equal(page.unreadCount.value, fail ? 1 : 0);
|
||||||
|
}
|
||||||
|
const source = readFileSync(resolve(root, 'pages/notifications/notifications.vue'), 'utf8');
|
||||||
|
assert.match(source, /item\.is_read \? '已读' : '未读'/);
|
||||||
|
assert.doesNotMatch(source, /border-left|unread-dot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notification center rejects an old asset response after switching context', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const oldList = deferred();
|
||||||
|
r.api.notificationApi = { getList: () => oldList.promise, getUnreadCount: async () => ({ count: 0 }) };
|
||||||
|
const page = await r.load('pages/notifications/notifications.vue', ['loadNotifications', 'notifications']);
|
||||||
|
const pending = page.loadNotifications();
|
||||||
|
r.storage.set('identifier', 'B');
|
||||||
|
oldList.resolve({ items: [{ id: 1, category: 'expiry' }], total: 1 });
|
||||||
|
await pending;
|
||||||
|
assert.equal(page.notifications.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('asset A read-all does not locally mark asset B expiry notifications read', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const read = new Map([['A', false], ['B', false]]);
|
||||||
|
r.api.notificationApi = {
|
||||||
|
getList: async () => ({ items: [{ id: r.storage.get('identifier'), category: 'expiry', is_read: read.get(r.storage.get('identifier')) }], total: 1 }),
|
||||||
|
getUnreadCount: async () => ({ count: read.get(r.storage.get('identifier')) ? 0 : 1 }),
|
||||||
|
markAllRead: async () => read.set(r.storage.get('identifier'), true)
|
||||||
|
};
|
||||||
|
const page = await r.load('pages/notifications/notifications.vue', ['loadNotifications', 'loadUnreadCount', 'markAllRead', 'notifications', 'unreadCount']);
|
||||||
|
await page.loadNotifications(); await page.loadUnreadCount(); await page.markAllRead();
|
||||||
|
assert.equal(page.notifications[0].is_read, true);
|
||||||
|
r.storage.set('identifier', 'B');
|
||||||
|
await r.shown[0]();
|
||||||
|
assert.equal(page.notifications[0].id, 'B');
|
||||||
|
assert.equal(page.notifications[0].is_read, false);
|
||||||
|
assert.equal(page.unreadCount.value, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('401 clears phone/reminder state, but late 401 cannot log out a new session', async () => {
|
||||||
|
const r = runtime();
|
||||||
|
const request = (await r.load('utils/request.js')).default;
|
||||||
|
const user = (await r.load('store/user.js')).useUserStore();
|
||||||
|
const stale = request({ url: '/old' });
|
||||||
|
user.setToken('session-B');
|
||||||
|
r.reply(r.requests[0], {}, 1, 401);
|
||||||
|
await assert.rejects(stale);
|
||||||
|
assert.equal(user.state.token, 'session-B');
|
||||||
|
user.setLoginPhoneState({ asset_phone_bound: true, need_bind_phone: false });
|
||||||
|
const expired = request({ url: '/current' });
|
||||||
|
r.reply(r.requests[1], {}, 1, 401);
|
||||||
|
await assert.rejects(expired);
|
||||||
|
assert.equal(user.state.token, '');
|
||||||
|
assert.equal(user.state.assetPhoneBound, null);
|
||||||
|
assert.equal(r.storage.has('customerAssetPhoneState'), false);
|
||||||
|
});
|
||||||
29
utils/expiry-reminder.js
Normal file
29
utils/expiry-reminder.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export const getShanghaiDay = (date = new Date()) => {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
|
||||||
|
}).formatToParts(date);
|
||||||
|
const part = (type) => parts.find(item => item.type === type)?.value || '';
|
||||||
|
return `${part('year')}-${part('month')}-${part('day')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeExpiryReminder = (reminder) => {
|
||||||
|
if (!reminder || typeof reminder !== 'object') return null;
|
||||||
|
const days = reminder.remaining_days;
|
||||||
|
if (days === null || days === undefined || days === '' ||
|
||||||
|
!Number.isInteger(Number(days)) || Number(days) < 0 || Number(days) > 15 ||
|
||||||
|
!['red', 'purple', 'pink'].includes(reminder.level)) return null;
|
||||||
|
return { ...reminder, remaining_days: Number(days) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatShanghaiExpiry = (value) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
const text = String(value);
|
||||||
|
// An offset-free server date is already a Shanghai wall-clock value.
|
||||||
|
if (!/(Z|[+-]\d{2}:?\d{2})$/i.test(text)) return text.replace('T', ' ').slice(0, 19);
|
||||||
|
const date = new Date(text);
|
||||||
|
if (!Number.isFinite(date.getTime())) return '-';
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
9
utils/payment-attempt.js
Normal file
9
utils/payment-attempt.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const isUnknownPaymentResult = (error) => Boolean(error?.paymentResultUnknown);
|
||||||
|
|
||||||
|
export const getPaymentErrorMessage = (error, fallback = '支付失败,请稍后重试') => {
|
||||||
|
if (isUnknownPaymentResult(error)) {
|
||||||
|
return '支付结果未知,请稍后查看订单状态';
|
||||||
|
}
|
||||||
|
|
||||||
|
return error?.msg || error?.message || fallback;
|
||||||
|
};
|
||||||
31
utils/phone-bind.js
Normal file
31
utils/phone-bind.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// A customer phone or an old cache does not prove an asset association exists.
|
||||||
|
export const optionalBoolean = (value) => {
|
||||||
|
if (value === true || value === 1) return true;
|
||||||
|
if (value === false || value === 0) return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPhoneBindingMode = (customerPhone, assetPhoneBound) => {
|
||||||
|
if (customerPhone === null || assetPhoneBound === null) return 'unknown';
|
||||||
|
if (!customerPhone) return assetPhoneBound ? 'customer' : 'customer-asset';
|
||||||
|
return assetPhoneBound ? 'none' : 'asset';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBindingSuccessState = (state, phone, mode) => {
|
||||||
|
if (mode === 'customer-asset') {
|
||||||
|
return { customerPhone: phone, assetPhoneBound: true, assetPhoneIsOwn: true, needBindPhone: false };
|
||||||
|
}
|
||||||
|
if (mode === 'asset') {
|
||||||
|
return { assetPhoneBound: true, assetPhoneIsOwn: phone === state.customerPhone, needBindPhone: false };
|
||||||
|
}
|
||||||
|
if (mode === 'customer') {
|
||||||
|
return { customerPhone: phone, assetPhoneIsOwn: null, needBindPhone: false };
|
||||||
|
}
|
||||||
|
// Even if the customer phone was unavailable, successful binding of a
|
||||||
|
// previously unbound asset proves an association now exists, not its owner.
|
||||||
|
if (state.assetPhoneBound === false) {
|
||||||
|
return { assetPhoneBound: true, assetPhoneIsOwn: null, needBindPhone: false };
|
||||||
|
}
|
||||||
|
// An ambiguous success response does not identify which phone was changed.
|
||||||
|
return { needBindPhone: false };
|
||||||
|
};
|
||||||
3
utils/popup.js
Normal file
3
utils/popup.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// 会话级去重:后端同日重复请求返回同一 notification_id,
|
||||||
|
// 弹窗展示时登记,跨页面/组件实例共享,避免同一天重复弹出同一个弹窗。
|
||||||
|
export const handledPopupNotificationIds = new Set();
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BASE_URL } from './env.js';
|
import { BASE_URL } from './env.js';
|
||||||
|
import { useUserStore } from '@/store/user.js';
|
||||||
|
|
||||||
const showErrorToast = (message) => {
|
const showErrorToast = (message) => {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
@@ -26,12 +27,14 @@ const request = (options) => {
|
|||||||
const { code, msg, data } = responseData;
|
const { code, msg, data } = responseData;
|
||||||
|
|
||||||
if (res.statusCode === 401) {
|
if (res.statusCode === 401) {
|
||||||
uni.removeStorageSync('token');
|
// A late response from an old login must not clear a new session.
|
||||||
uni.removeStorageSync('identifier');
|
if (token === (uni.getStorageSync('token') || '')) {
|
||||||
showErrorToast(msg || '登录已过期,请重新登录');
|
useUserStore().clearUser();
|
||||||
setTimeout(() => {
|
showErrorToast(msg || responseData.message || '登录已过期,请重新登录');
|
||||||
uni.reLaunch({ url: '/pages/login/login' });
|
setTimeout(() => {
|
||||||
}, 2000);
|
if (!uni.getStorageSync('token')) uni.reLaunch({ url: '/pages/login/login' });
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
reject(responseData);
|
reject(responseData);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,13 +45,20 @@ const request = (options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.showError !== false) {
|
if (options.showError !== false) {
|
||||||
showErrorToast(msg);
|
showErrorToast(msg || responseData.message);
|
||||||
}
|
}
|
||||||
reject(responseData);
|
reject(responseData);
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
showErrorToast('网络请求失败');
|
if (options.showError !== false) {
|
||||||
reject(err);
|
showErrorToast('网络请求失败');
|
||||||
|
}
|
||||||
|
// A transport failure does not tell us whether the backend created a
|
||||||
|
// payment. Callers must reconcile status and must not retry creation.
|
||||||
|
reject({
|
||||||
|
...err,
|
||||||
|
paymentResultUnknown: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
9
utils/session-context.js
Normal file
9
utils/session-context.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const getSessionContext = () => ({
|
||||||
|
token: uni.getStorageSync('token') || '',
|
||||||
|
identifier: uni.getStorageSync('identifier') || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isCurrentSession = (context) => {
|
||||||
|
const current = getSessionContext();
|
||||||
|
return !!context.token && context.token === current.token && context.identifier === current.identifier;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user