Compare commits
8 Commits
develop
...
iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bd9de15cd | ||
|
|
6a1543f9f6 | ||
|
|
010b941d0e | ||
|
|
a25ea5954c | ||
|
|
8b6ce505b5 | ||
|
|
484a807dce | ||
|
|
6845e97b14 | ||
|
|
189d64c290 |
@@ -4,6 +4,7 @@ export { deviceApi } from './modules/device.js';
|
||||
export { exchangeApi } from './modules/exchange.js';
|
||||
export { orderApi } from './modules/order.js';
|
||||
export { notificationApi } from './modules/notification.js';
|
||||
export { popupApi } from './modules/popup.js';
|
||||
export { realnameApi } from './modules/realname.js';
|
||||
export { walletApi } from './modules/wallet.js';
|
||||
export { wechatApi } from './modules/wechat.js';
|
||||
|
||||
@@ -19,6 +19,24 @@ export const normalizeAssetInfo = (data = {}) => ({
|
||||
: data.is_expiring === true || data.is_expiring === 1
|
||||
});
|
||||
|
||||
const normalizePackageHistoryNode = (node = {}) => ({
|
||||
...node,
|
||||
children: Array.isArray(node.children)
|
||||
? node.children.map(normalizePackageHistoryNode)
|
||||
: [],
|
||||
expand_by_default: node.expand_by_default === true || node.expand_by_default === 1
|
||||
});
|
||||
|
||||
export const normalizeAssetPackageHistory = (data = {}) => ({
|
||||
...data,
|
||||
items: Array.isArray(data.items)
|
||||
? data.items.map(normalizePackageHistoryNode)
|
||||
: [],
|
||||
page: Number(data.page),
|
||||
size: Number(data.size),
|
||||
total: Number(data.total)
|
||||
});
|
||||
|
||||
export const isAssetRealNameCompleted = (assetInfo = {}) => {
|
||||
if (assetInfo.asset_type === 'device') {
|
||||
return (assetInfo.cards || []).some((card) => Number(card?.real_name_status) === 1);
|
||||
@@ -50,7 +68,7 @@ export const assetApi = {
|
||||
url: '/api/c/v1/asset/package-history',
|
||||
method: 'GET',
|
||||
data: { identifier, page, page_size, ...params }
|
||||
});
|
||||
}).then(normalizeAssetPackageHistory);
|
||||
},
|
||||
|
||||
getPackages(identifier, packageType = '') {
|
||||
|
||||
@@ -23,7 +23,8 @@ export const notificationApi = {
|
||||
markRead(id) {
|
||||
return request({
|
||||
url: `/api/c/v1/notifications/${id}/read`,
|
||||
method: 'PUT'
|
||||
method: 'PUT',
|
||||
data: { id }
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
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 }
|
||||
});
|
||||
}
|
||||
};
|
||||
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>
|
||||
296
components/PopupCandidate.vue
Normal file
296
components/PopupCandidate.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<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>
|
||||
<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='请输入收件人电话' border='surround' maxlength='20'/>
|
||||
</view>
|
||||
<view class='form-item'>
|
||||
<view class='form-label'>收货地址</view>
|
||||
<up-textarea v-model='addressForm.recipient_address' placeholder='请输入完整收货地址'/>
|
||||
</view>
|
||||
<view class='popup-actions'>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { popupApi, notificationApi } from '@/api/index.js';
|
||||
import { handledPopupNotificationIds } from '@/utils/popup.js';
|
||||
|
||||
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 addressForm = reactive({
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
recipient_address: ''
|
||||
});
|
||||
|
||||
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 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.recipient_name = '';
|
||||
addressForm.recipient_phone = '';
|
||||
addressForm.recipient_address = '';
|
||||
};
|
||||
|
||||
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;
|
||||
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 { recipient_name, recipient_phone, recipient_address } = addressForm;
|
||||
if (!recipient_name || !recipient_phone || !recipient_address) {
|
||||
uni.showToast({ title: '请填写完整收货信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
recipient_phone,
|
||||
recipient_address
|
||||
});
|
||||
await markPopupRead();
|
||||
step.value = 'done';
|
||||
} catch (e) {
|
||||
console.error('提交风险换卡收货地址失败', e);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
visible.value = false;
|
||||
emit('close', 'done');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.identifier) {
|
||||
emit('close', 'empty');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await popupApi.getCandidate(props.page, props.identifier);
|
||||
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) {
|
||||
// 资源不可见(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;
|
||||
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 {
|
||||
padding: 0 32rpx 20rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
margin-bottom: 12rpx;
|
||||
color: var(--text-primary);
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.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>
|
||||
97
docs/API.md
97
docs/API.md
@@ -376,7 +376,102 @@ data:
|
||||
- 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:
|
||||
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
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,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
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<!-- 空状态提示 -->
|
||||
<view v-if="!historyLoaded || (loading && packageList.length === 0)" class="loading-state">
|
||||
<up-loading-icon text="加载中" size="30"></up-loading-icon>
|
||||
</view>
|
||||
@@ -10,177 +9,88 @@
|
||||
<view class="empty-desc">当前账号下暂无套餐历史信息</view>
|
||||
</view>
|
||||
|
||||
<!-- 套餐列表 -->
|
||||
<view v-else class="card package-card" v-for="(item, index) in packageList" :key="index">
|
||||
<view class="package-header flex-row-sb">
|
||||
<view class="package-name">{{ item.package_name }}</view>
|
||||
<view class="tag-apple" :class="getStatusClass(item.status)">{{ item.status_name }}</view>
|
||||
<view v-else>
|
||||
<PackageHistoryNode
|
||||
v-for="(item, index) in packageList"
|
||||
:key="getGroupKey(item, index)"
|
||||
: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 class="load-more" v-if="packageList.length > 0">
|
||||
<view v-if="packageList.length > 0" class="load-more">
|
||||
<text v-if="loading">加载中...</text>
|
||||
<text v-else-if="noMore">没有更多了</text>
|
||||
<text v-else @tap="loadMore">点击加载更多</text>
|
||||
<text v-else-if="noMore">没有更多套餐</text>
|
||||
<text v-else role="button" tabindex="0" @tap="loadMore">点击加载更多</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { assetApi } from '@/api/index.js';
|
||||
import PackageHistoryNode from '@/components/PackageHistoryNode.vue';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
let packageList = reactive([]);
|
||||
let loading = ref(false);
|
||||
let historyLoaded = ref(false);
|
||||
let noMore = ref(false);
|
||||
let page = ref(1);
|
||||
const packageList = ref([]);
|
||||
const loading = ref(false);
|
||||
const historyLoaded = ref(false);
|
||||
const noMore = ref(false);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const pageSize = 10;
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const classMap = {
|
||||
'0': 'tag-warning',
|
||||
'1': 'tag-success',
|
||||
'2': 'tag-primary',
|
||||
'3': 'tag-secondary',
|
||||
'4': 'tag-danger'
|
||||
};
|
||||
return classMap[status] || '';
|
||||
};
|
||||
|
||||
const formatMB = (mb) => {
|
||||
if (!mb && mb !== 0) return '0 MB';
|
||||
if (mb >= 1024) {
|
||||
return (mb / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
return mb.toFixed(2) + ' MB';
|
||||
};
|
||||
|
||||
// 获取已使用流量
|
||||
const getUsedFlow = (item) => {
|
||||
if (item.enable_virtual_data) {
|
||||
return formatMB(item.virtual_used_mb || 0);
|
||||
} else {
|
||||
return formatMB(item.real_used_mb || 0);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取总流量
|
||||
const getTotalFlow = (item) => {
|
||||
return formatMB(item.real_total_mb || 0);
|
||||
};
|
||||
|
||||
// 获取剩余流量
|
||||
const getRemainFlow = (item) => {
|
||||
const total = item.real_total_mb || 0;
|
||||
const used = item.enable_virtual_data ? (item.virtual_used_mb || 0) : (item.real_used_mb || 0);
|
||||
const remain = Math.max(total - used, 0);
|
||||
return formatMB(remain);
|
||||
};
|
||||
|
||||
// 获取使用百分比
|
||||
const getUsagePercent = (item) => {
|
||||
const used = item.real_used_mb || 0;
|
||||
const total = item.real_total_mb || 0;
|
||||
|
||||
if (!total) return 0;
|
||||
return Math.min((used / total) * 100, 100).toFixed(2);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T').join(' ').slice(0, 19);
|
||||
};
|
||||
const getGroupKey = (item, index) => item.package_usage_id ?? `group-${index}`;
|
||||
|
||||
const loadPackageList = async (append = false) => {
|
||||
if (loading.value || noMore.value) return;
|
||||
if (loading.value || (append && noMore.value)) return;
|
||||
|
||||
loading.value = true;
|
||||
const requestedPage = page.value;
|
||||
|
||||
try {
|
||||
const data = await assetApi.getPackageHistory(
|
||||
userStore.state.identifier,
|
||||
page.value,
|
||||
requestedPage,
|
||||
pageSize
|
||||
);
|
||||
|
||||
const newData = (data.items || []).map(item => ({
|
||||
...item,
|
||||
activated_at: item.activated_at ? formatDate(item.activated_at) : '',
|
||||
created_at: formatDate(item.created_at),
|
||||
expires_at: item.expires_at ? formatDate(item.expires_at) : ''
|
||||
}));
|
||||
const groups = data.items;
|
||||
const responsePage = Number.isInteger(data.page) && data.page > 0
|
||||
? data.page
|
||||
: requestedPage;
|
||||
const responseSize = Number.isInteger(data.size) && data.size > 0
|
||||
? data.size
|
||||
: pageSize;
|
||||
const responseTotal = Number.isFinite(data.total) && data.total >= 0
|
||||
? data.total
|
||||
: 0;
|
||||
|
||||
if (append) {
|
||||
packageList.push(...newData);
|
||||
packageList.value.push(...groups);
|
||||
} else {
|
||||
packageList.splice(0, packageList.length, ...newData);
|
||||
packageList.value = groups;
|
||||
}
|
||||
|
||||
if (newData.length < pageSize) {
|
||||
noMore.value = true;
|
||||
} else {
|
||||
page.value++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载套餐历史失败', e);
|
||||
}
|
||||
total.value = responseTotal;
|
||||
noMore.value = responsePage * responseSize >= total.value;
|
||||
page.value = responsePage + 1;
|
||||
} catch (error) {
|
||||
// The shared request layer handles the API's unified error response and authentication errors.
|
||||
console.error('加载套餐历史失败', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
historyLoaded.value = true;
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (!noMore.value) {
|
||||
loadPackageList(true);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = () => loadPackageList(true);
|
||||
|
||||
onMounted(() => {
|
||||
loadPackageList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -193,70 +103,34 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
min-height: 400rpx;
|
||||
.empty-icon { width: 120rpx; height: 120rpx; margin-bottom: 30rpx; opacity: 0.6; }
|
||||
.empty-title { font-size: 32rpx; font-weight: 600; color: var(--text-primary); margin-bottom: 16rpx; }
|
||||
.empty-desc { font-size: 26rpx; color: var(--text-tertiary); text-align: center; }
|
||||
padding: 120rpx 40rpx;
|
||||
|
||||
.empty-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
margin-bottom: 30rpx;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.package-card {
|
||||
margin-bottom: var(--space-md);
|
||||
.package-header {
|
||||
margin-bottom: var(--space-md);
|
||||
.package-name { font-size: 32rpx; font-weight: 600; color: var(--text-primary); }
|
||||
.empty-title {
|
||||
margin-bottom: 16rpx;
|
||||
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 {
|
||||
.empty-desc {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
.flow-label { font-size: 20rpx; color: var(--text-tertiary); margin-bottom: 4rpx; }
|
||||
.flow-value { font-size: 26rpx; font-weight: 600; color: var(--text-primary); }
|
||||
}
|
||||
}
|
||||
.progress-section {
|
||||
.progress-apple {
|
||||
width: 100%;
|
||||
height: 8rpx;
|
||||
background: var(--gray-200);
|
||||
border-radius: var(--radius-small);
|
||||
overflow: hidden;
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
||||
border-radius: var(--radius-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: var(--space-lg);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 24rpx;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-secondary { background: var(--gray-200) !important; color: var(--gray-600) !important; }
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { authApi } from '@/api/index.js';
|
||||
import { authApi, assetApi } from '@/api/index.js';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
|
||||
const POST_BIND_RELOGIN_NOTICE_KEY = 'postBindReloginNotice';
|
||||
@@ -57,12 +57,29 @@
|
||||
let timer = null;
|
||||
let fromLogin = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// 获取页面参数,判断是否从登录页面跳转过来
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const options = currentPage.options;
|
||||
fromLogin.value = options.fromLogin === 'true';
|
||||
|
||||
// 契约变化:账号已有主手机号时,主号 + 验证码有效 → 幂等建联。
|
||||
// 预填账号已有主手机号,避免存量用户「提示要验证却提交任何号码都被拒」的死锁。
|
||||
const token = uni.getStorageSync('token');
|
||||
const identifier = userStore.state.identifier || uni.getStorageSync('identifier') || '';
|
||||
if (!token || !identifier) return;
|
||||
|
||||
try {
|
||||
const data = await assetApi.getInfo(identifier);
|
||||
const mainPhone = (data && data.bound_phone) || '';
|
||||
if (mainPhone) {
|
||||
bind.phone = mainPhone;
|
||||
}
|
||||
} catch (e) {
|
||||
// 预填失败不阻塞绑定流程,用户仍可手动输入手机号
|
||||
console.error('预填主手机号失败', e);
|
||||
}
|
||||
});
|
||||
|
||||
const prepareManualRelogin = () => {
|
||||
|
||||
@@ -104,6 +104,9 @@
|
||||
<RenewalPaymentPopup ref="renewalPopupRef" :identifier="userStore.state.identifier"
|
||||
paymentRefreshTarget="home" @completed="loadAssetInfo" />
|
||||
|
||||
<PopupCandidate v-if='popupCandidateShow' page='home' :identifier='userStore.state.identifier'
|
||||
@close='onPopupCandidateClose' />
|
||||
|
||||
<!-- 日期选择器弹窗 -->
|
||||
<up-datetime-picker v-if="!userInfo.isDevice" :show="showStartDatePicker" v-model="startDateTimestamp"
|
||||
mode="date" @confirm="onStartDateConfirm" @cancel="showStartDatePicker=false"
|
||||
@@ -134,6 +137,7 @@
|
||||
import FloatingButton from '@/components/FloatingButton.vue';
|
||||
import NotificationPopup from '@/components/NotificationPopup.vue';
|
||||
import RenewalPaymentPopup from '@/components/RenewalPaymentPopup.vue';
|
||||
import PopupCandidate from '@/components/PopupCandidate.vue';
|
||||
import {
|
||||
assetApi,
|
||||
deviceApi,
|
||||
@@ -238,6 +242,7 @@
|
||||
let notificationPopupCurrent = ref(0);
|
||||
let renewalPopupRef = ref(null);
|
||||
const notificationReadPending = new Set();
|
||||
let popupCandidateShow = ref(false);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
@@ -864,8 +869,7 @@
|
||||
try {
|
||||
const data = await notificationApi.getList(1, 50, false);
|
||||
const items = (data?.items || [])
|
||||
.filter(item => !item.is_read)
|
||||
.sort((a, b) => getNotificationPriority(b) - getNotificationPriority(a));
|
||||
.filter(item => !item.is_read);
|
||||
if (!items.length) return;
|
||||
|
||||
notificationItems.value = items;
|
||||
@@ -885,17 +889,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 index = Number(event?.detail?.current ?? event?.current ?? 0);
|
||||
notificationPopupCurrent.value = index;
|
||||
@@ -906,6 +899,18 @@
|
||||
notificationPopupShow.value = false;
|
||||
};
|
||||
|
||||
const runPopupCandidate = () => {
|
||||
popupCandidateShow.value = true;
|
||||
};
|
||||
|
||||
const onPopupCandidateClose = (reason) => {
|
||||
popupCandidateShow.value = false;
|
||||
if (reason === 'empty' || reason === 'invisible') {
|
||||
loadUnreadNotifications();
|
||||
}
|
||||
loadNotificationUnreadCount();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initCurrentMonth();
|
||||
});
|
||||
@@ -916,7 +921,7 @@
|
||||
}
|
||||
handleIndexEntry();
|
||||
loadNotificationUnreadCount();
|
||||
loadUnreadNotifications();
|
||||
runPopupCandidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -86,7 +86,27 @@
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const urlIdentifier = getPathDeviceId();
|
||||
const token = uni.getStorageSync('token');
|
||||
const storedIdentifier = uni.getStorageSync('identifier') || '';
|
||||
|
||||
// 从外部链接进入时,链接中的资产标识优先于本地登录态。
|
||||
// 只有相同资产才能复用已登录的会话,避免将上一台设备的数据带到新链接中。
|
||||
if (urlIdentifier) {
|
||||
identifier.value = urlIdentifier;
|
||||
if (token && urlIdentifier === storedIdentifier) {
|
||||
uni.reLaunch({ url: '/pages/index/index' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (token || storedIdentifier) {
|
||||
userStore.clearUser();
|
||||
}
|
||||
|
||||
doLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
uni.reLaunch({ url: '/pages/index/index' });
|
||||
return;
|
||||
@@ -94,7 +114,6 @@
|
||||
|
||||
showPostBindReloginNotice();
|
||||
handleWechatCallback();
|
||||
getPathDeviceId();
|
||||
|
||||
// 初始化微信 SDK (仅在微信浏览器内执行)
|
||||
// #ifdef H5
|
||||
@@ -236,10 +255,50 @@
|
||||
|
||||
const getPathDeviceId = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const idf = params.get('identifier');
|
||||
if (idf) {
|
||||
identifier.value = idf;
|
||||
handleLogin();
|
||||
return (params.get('identifier') || '').trim();
|
||||
};
|
||||
|
||||
const isNineteenDigitIccidPrefix = (value) => /^89\d{17}$/.test(value);
|
||||
|
||||
const appendIccidLuhnCheckDigit = (prefix) => {
|
||||
let sum = 0;
|
||||
|
||||
for (let index = prefix.length - 1, offset = 0; index >= 0; index--, offset++) {
|
||||
let digit = Number(prefix[index]);
|
||||
if (offset % 2 === 0) {
|
||||
digit *= 2;
|
||||
if (digit > 9) digit -= 9;
|
||||
}
|
||||
sum += digit;
|
||||
}
|
||||
|
||||
return `${prefix}${(10 - (sum % 10)) % 10}`;
|
||||
};
|
||||
|
||||
const verifyAssetToken = async (assetIdentifier) => {
|
||||
const verifyData = await authApi.verifyAsset(assetIdentifier);
|
||||
if (!verifyData?.asset_token) {
|
||||
throw { msg: '资产校验未返回有效登录凭证' };
|
||||
}
|
||||
return verifyData;
|
||||
};
|
||||
|
||||
const verifyAssetWithIccidRetry = async (enteredIdentifier) => {
|
||||
try {
|
||||
return {
|
||||
verifyData: await verifyAssetToken(enteredIdentifier),
|
||||
verifiedIdentifier: enteredIdentifier
|
||||
};
|
||||
} catch (firstError) {
|
||||
if (!isNineteenDigitIccidPrefix(enteredIdentifier)) {
|
||||
throw firstError;
|
||||
}
|
||||
|
||||
const completedIccid = appendIccidLuhnCheckDigit(enteredIdentifier);
|
||||
return {
|
||||
verifyData: await verifyAssetToken(completedIccid),
|
||||
verifiedIdentifier: completedIccid
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -250,13 +309,11 @@
|
||||
sessionStorage.removeItem('assetToken');
|
||||
}
|
||||
try {
|
||||
const verifyData = await authApi.verifyAsset(identifier.value);
|
||||
if (!verifyData?.asset_token) {
|
||||
throw { msg: '资产校验未返回有效登录凭证' };
|
||||
}
|
||||
const enteredIdentifier = identifier.value;
|
||||
const { verifyData, verifiedIdentifier } = await verifyAssetWithIccidRetry(enteredIdentifier);
|
||||
|
||||
userStore.setAssetToken(verifyData.asset_token);
|
||||
userStore.setIdentifier(identifier.value);
|
||||
userStore.setIdentifier(verifiedIdentifier);
|
||||
|
||||
await redirectToWxAuth(verifyData.asset_token);
|
||||
} catch (e) {
|
||||
|
||||
@@ -221,6 +221,10 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<PopupCandidate v-if='popupCandidateShow' page='asset_wallet_recharge'
|
||||
:identifier='userStore.state.identifier' @close='onPopupCandidateClose' />
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -228,6 +232,7 @@
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { walletApi } from '@/api/index.js';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
import PopupCandidate from '@/components/PopupCandidate.vue';
|
||||
import {
|
||||
consumePendingPaymentRefresh,
|
||||
handlePaymentError,
|
||||
@@ -242,6 +247,11 @@
|
||||
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const popupCandidateShow = ref(false);
|
||||
|
||||
const onPopupCandidateClose = () => {
|
||||
popupCandidateShow.value = false;
|
||||
};
|
||||
|
||||
const currentTab = ref(0);
|
||||
const walletDetail = reactive({
|
||||
@@ -765,6 +775,7 @@
|
||||
|
||||
onMounted(() => {
|
||||
syncWalletStatus();
|
||||
popupCandidateShow.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
const getCategoryText = (category) => ({
|
||||
approval: '审批',
|
||||
expiry: '临期',
|
||||
exchange: '换货',
|
||||
sync: '同步',
|
||||
system: '系统'
|
||||
}[category] || '通知');
|
||||
@@ -73,17 +72,6 @@
|
||||
critical: '严重'
|
||||
}[severity] || '提示');
|
||||
|
||||
const getNotificationPriority = (item) => {
|
||||
const severityRank = { info: 10, warning: 20, error: 30, critical: 40 };
|
||||
const remainingDays = Number(item?.days_until_expiry ?? item?.days_remaining ?? item?.remaining_days);
|
||||
const expiryLevel = String(item?.expiry_level || '').toLowerCase();
|
||||
if (item?.category === 'expiry' && ((Number.isFinite(remainingDays) && remainingDays >= 0 && remainingDays <= 3) ||
|
||||
['0_3', '0-3', '0~3', 'critical'].includes(expiryLevel))) {
|
||||
return 100;
|
||||
}
|
||||
return severityRank[item?.severity] || 0;
|
||||
};
|
||||
|
||||
const loadUnreadCount = async () => {
|
||||
try {
|
||||
const data = await notificationApi.getUnreadCount();
|
||||
@@ -98,7 +86,7 @@
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await notificationApi.getList(page.value, pageSize);
|
||||
const items = (data?.items || []).sort((a, b) => getNotificationPriority(b) - getNotificationPriority(a));
|
||||
const items = (data?.items || []);
|
||||
if (append) {
|
||||
notifications.push(...items);
|
||||
} else {
|
||||
|
||||
@@ -95,6 +95,10 @@
|
||||
</view>
|
||||
</up-popup>
|
||||
</view>
|
||||
|
||||
<PopupCandidate v-if='popupCandidateShow' page='package_purchase'
|
||||
:identifier='userStore.state.identifier' @close='onPopupCandidateClose' />
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -102,6 +106,7 @@
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { assetApi, isAssetRealNameCompleted, orderApi, walletApi } from '@/api/index.js';
|
||||
import { useUserStore } from '@/store/index.js';
|
||||
import PopupCandidate from '@/components/PopupCandidate.vue';
|
||||
import {
|
||||
consumePendingPaymentRefresh,
|
||||
handlePaymentError,
|
||||
@@ -116,6 +121,11 @@
|
||||
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const popupCandidateShow = ref(false);
|
||||
|
||||
const onPopupCandidateClose = () => {
|
||||
popupCandidateShow.value = false;
|
||||
};
|
||||
|
||||
const showModal = ref(false);
|
||||
const currentPackage = ref(null);
|
||||
@@ -515,6 +525,7 @@
|
||||
} catch (error) {
|
||||
renewalPackageNames.value = [];
|
||||
}
|
||||
popupCandidateShow.value = true;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
2
qt2.md
Normal file
2
qt2.md
Normal file
@@ -0,0 +1,2 @@
|
||||
<view class="bottom-spacer"></view>
|
||||
<view class="plain"></view>
|
||||
@@ -43,6 +43,9 @@ export const useUserStore = () => {
|
||||
const clearUser = () => {
|
||||
state.token = '';
|
||||
state.assetToken = '';
|
||||
state.identifier = '';
|
||||
state.isDevice = false;
|
||||
state.realNameStatus = 0;
|
||||
state.userInfo = { avatar: '', nickname: '' };
|
||||
uni.removeStorageSync('token');
|
||||
uni.removeStorageSync('identifier');
|
||||
|
||||
3
utils/popup.js
Normal file
3
utils/popup.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// 会话级去重:后端同日重复请求返回同一 notification_id,
|
||||
// 弹窗展示时登记,跨页面/组件实例共享,避免同一天重复弹出同一个弹窗。
|
||||
export const handledPopupNotificationIds = new Set();
|
||||
Reference in New Issue
Block a user