13 Commits

Author SHA1 Message Date
邹进
37b650d41e feat: 沉默期已过期待延期时全局拦截,除绑定手机号与延期支付页外都跳转延期页
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m3s
2026-11-08 16:41:53 +09:00
邹进
535f92b818 feat: 沉默期已过期强制弹窗,只能去延期,不能关闭
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m37s
2026-11-08 16:29:12 +09:00
邹进
b6e55d315c feat: 延期页显示套餐订购时间、到期时间与沉默期截止时间
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 13m41s
2026-11-07 22:50:48 +09:00
邹进
d13be3580f feat: 延期微信支付在微信内直接拉起
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 19m17s
- 微信支付下单带 app_type;拿到 pay_config 后与套餐购买一样拉起微信支付,继续支付同样处理
- 微信内打开时支付方式默认微信;取消支付保留同一请求号,再次确认不重复建单
2026-11-07 21:57:26 +09:00
邹进
b035105f80 fix: 沉默期已过期未延期时每次回首页都弹提醒
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
已过期(含有未支付延期订单)不再限频,直到支付或延期;待实名临近截止仍为每次登录一次
2026-11-07 21:21:57 +09:00
邹进
c11d1881ef fix: 沉默期提醒每次登录都弹,有未支付延期订单时提示去支付或作废
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
- 频率从每天一次改为每次登录一次:重新登录后会再次提醒,同一次登录内回首页不重复
- 已过期套餐有未支付延期订单时,弹窗提示继续支付或作废
2026-11-07 21:04:12 +09:00
邹进
06ca8af218 feat: H5 延期订单可作废,未完成订单直接进入详情继续支付或作废
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
- 延期订单详情待支付时显示作废订单,作废后可重新办理延期
- 延期页有未完成订单时直接打开该订单详情;作废返回后重新查资格
2026-11-07 20:33:42 +09:00
邹进
0136615502 feat: 首页弹出套餐沉默期提醒
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 14m26s
- 资产有沉默期已过期且可延期的套餐时弹窗,带去延期入口;待实名 7 天内到截止日时提醒尽快实名
- 排在临期提醒之后、站内通知之前,同一资产同一笔套餐每天最多弹一次
- 套餐历史在后台加载,不阻塞首页其他弹窗
2026-11-07 18:51:54 +09:00
邹进
31ff18d66a feat: C 端套餐沉默期展示与自助延期
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 23m46s
- 套餐历史展示沉默期状态、截止日期、累计延期与调整审批中,可延期时显示延期入口
- 新增延期页:资格、档位与自定义天数试算、支付方式、下单(request_id 幂等)
- 支付宝走现有支付页;微信为链接时在微信外直接跳转,否则展示二维码与复制链接
- 新增我的延期订单列表与详情、沉默期记录,沉默期错误码转客户提示
2026-11-07 13:19:33 +09:00
邹进
b2f802e6f8 docs: 新增 C 端套餐沉默期展示与自助延期提案 add-package-silence-extension
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 25m59s
2026-11-07 11:29:33 +09:00
邹进
1baea2b24f ci: 测试环境改由 iteration/8-11 分支部署,对接后端 Iteration/8-11
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 24m13s
2026-11-06 22:18:29 +09:00
luo
e084cdeb90 fix: some
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 2m14s
2026-10-26 15:00:05 +08:00
luo
b5f89b744c ci: switch production deployment to iteration/october
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 2m3s
2026-10-26 14:29:18 +08:00
29 changed files with 2746 additions and 148 deletions

View File

@@ -1,8 +1,9 @@
name: 构建并部署前端到生产环境
name: 构建并部署前端到测试环境
on:
push:
branches:
- iteration/8-11
- dev
- test
@@ -27,7 +28,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/iteration/august" ]; then
if [ "${{ github.ref }}" = "refs/heads/iteration/8-11" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
@@ -51,8 +52,8 @@ jobs:
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: 部署到本地(仅 iteration/august 分支)
if: github.ref == 'refs/heads/iteration/august'
- name: 部署到测试环境(仅 iteration/8-11 分支)
if: github.ref == 'refs/heads/iteration/8-11'
run: |
mkdir -p ${{ env.DEPLOY_DIR }}

View File

@@ -8,3 +8,4 @@ export { popupApi } from './modules/popup.js';
export { realnameApi } from './modules/realname.js';
export { walletApi } from './modules/wallet.js';
export { wechatApi } from './modules/wechat.js';
export { packageSilenceApi } from './modules/packageSilence.js';

View File

@@ -3,27 +3,47 @@ import { useAssetStore } from '@/store/asset.js';
import { useUserStore } from '@/store/user.js';
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
export const normalizeAssetInfo = (data = {}) => ({
...data,
allowed_payment_methods: Array.isArray(data.allowed_payment_methods)
? data.allowed_payment_methods
: [],
real_name_status: data.real_name_status === undefined || data.real_name_status === null
? data.real_name_status
: Number(data.real_name_status),
realname_required: data.realname_required === undefined || data.realname_required === null
? data.realname_required
: data.realname_required === true || data.realname_required === 1,
days_until_final_expiry: data.days_until_final_expiry === null || data.days_until_final_expiry === undefined
? data.days_until_final_expiry
: Number(data.days_until_final_expiry),
is_expiring: data.is_expiring === undefined || data.is_expiring === null
? data.is_expiring
: data.is_expiring === true || data.is_expiring === 1
});
let assetInfoRequestId = 0;
export const normalizeAssetInfo = (data = {}) => {
// 保护期已下线:不向展示和业务判断透传废弃字段,兼容 none 或字段缺失。
const { device_protect_status, ...info } = data;
return {
...info,
allowed_payment_methods: Array.isArray(data.allowed_payment_methods)
? data.allowed_payment_methods
: [],
real_name_status: data.real_name_status === undefined || data.real_name_status === null
? data.real_name_status
: Number(data.real_name_status),
realname_required: data.realname_required === undefined || data.realname_required === null
? data.realname_required
: data.realname_required === true || data.realname_required === 1,
days_until_final_expiry: data.days_until_final_expiry === null || data.days_until_final_expiry === undefined
? data.days_until_final_expiry
: Number(data.days_until_final_expiry),
is_expiring: data.is_expiring === undefined || data.is_expiring === null
? data.is_expiring
: data.is_expiring === true || data.is_expiring === 1
};
};
const toBool = (value) => value === true || value === 1 || value === 'true';
// 沉默期对象:总开关关闭或不适用时后端不返回,保持为 null。
export const normalizePackageSilence = (silence) => {
if (!silence || typeof silence !== 'object') return null;
return {
...silence,
extendable: toBool(silence.extendable),
silence_adjust_pending: toBool(silence.silence_adjust_pending),
silence_extended_days: Number(silence.silence_extended_days) || 0
};
};
const normalizePackageHistoryNode = (node = {}) => ({
...node,
silence: normalizePackageSilence(node.silence),
children: Array.isArray(node.children)
? node.children.filter(child => Number(child?.status) !== 4).map(normalizePackageHistoryNode)
: [],
@@ -69,6 +89,7 @@ export const assetApi = {
return Promise.reject({ msg: '登录和微信配置尚未完成,请稍后重试' });
}
const phoneRevision = userStore.state.phoneRevision;
const requestId = identifier === context.identifier ? ++assetInfoRequestId : null;
return request({
url: '/api/c/v1/asset/info',
method: 'GET',
@@ -76,7 +97,10 @@ export const assetApi = {
}).then((response) => {
const data = normalizeAssetInfo(response);
if (identifier === context.identifier && isCurrentSession(context)) {
useAssetStore().receiveExpiryReminder(context, data.expiry_reminder);
const assetStore = useAssetStore();
// 替换整个快照,不合并旧轮询开关;迟到的旧请求不得覆盖新状态。
if (requestId === assetInfoRequestId) assetStore.setAssetInfo(data);
assetStore.receiveExpiryReminder(context, data.expiry_reminder);
if (phoneRevision === userStore.state.phoneRevision && typeof data.bound_phone === 'string') {
userStore.setCustomerPhone(data.bound_phone);
}

View File

@@ -0,0 +1,75 @@
import request from '@/utils/request.js';
import { APP_TYPE } from '@/utils/env.js';
// 实名即生效套餐沉默期 H5 接口(后端 Iteration/8-11,/api/c/v1/package-silence)。
// 资格、试算、下单关闭默认错误提示,由页面按沉默期错误码给出客户能看懂的文案。
const BASE = '/api/c/v1/package-silence';
export const packageSilenceApi = {
getEligibility(package_usage_id) {
return request({
url: `${BASE}/eligibility`,
method: 'GET',
data: { package_usage_id },
showError: false
});
},
quote(package_usage_id, days) {
return request({
url: `${BASE}/quote`,
method: 'GET',
data: { package_usage_id, days },
showError: false
});
},
createOrder({ package_usage_id, days, payment_method, request_id }) {
const data = { package_usage_id, days, payment_method, request_id };
// 微信支付带上应用类型:后端在微信内直接下 JSAPI 单,返回 pay_config 拉起支付
if (payment_method === 'wechat') data.app_type = APP_TYPE;
return request({
url: `${BASE}/orders`,
method: 'POST',
data,
showError: false
});
},
getOrders(params = {}) {
const data = {};
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') data[key] = value;
});
return request({
url: `${BASE}/orders`,
method: 'GET',
data
});
},
getOrder(id) {
return request({
url: `${BASE}/orders/${id}`,
method: 'GET'
});
},
// 作废自己的待支付延期订单(后端先查单,已支付则按支付成功办理并拒绝作废)
cancelOrder(id) {
return request({
url: `${BASE}/orders/${id}/cancel`,
method: 'POST',
data: {},
showError: false
});
},
getRecords(package_usage_id) {
return request({
url: `${BASE}/records`,
method: 'GET',
data: { package_usage_id }
});
}
};

View File

@@ -30,6 +30,43 @@
</view>
</view>
<view v-if="silence" class="silence-info">
<view class="silence-header flex-row-sb">
<view class="silence-title">实名沉默期</view>
<view class="silence-status" :class="getSilenceStatusClass(silence.silence_status)">
{{ silence.silence_status_name || '-' }}
</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">截止时间</view>
<view class="info-value">{{ formatSilenceDate(silence.silence_deadline_at) }}</view>
</view>
<view v-if="silence.silence_period_days" class="info-row flex-row-sb">
<view class="info-label">沉默期天数</view>
<view class="info-value">{{ silence.silence_period_days }} 天</view>
</view>
<view v-if="silence.silence_extended_days > 0" class="info-row flex-row-sb">
<view class="info-label">累计延期</view>
<view class="info-value">{{ silence.silence_extended_days }} 天</view>
</view>
<view v-if="silence.silence_adjust_pending" class="silence-pending">调整审批中</view>
<view class="silence-actions">
<view
class="silence-link"
role="button"
tabindex="0"
@tap.stop="openSilenceRecords"
>沉默期记录</view>
<view
v-if="silence.extendable"
class="silence-extend"
role="button"
tabindex="0"
@tap.stop="openSilenceExtension"
>延期</view>
</view>
</view>
<view class="divider"></view>
<view class="flow-info">
@@ -80,6 +117,7 @@
<script setup>
import { computed, ref } from 'vue';
import { formatSilenceDate, getSilenceStatusClass } from '@/utils/silence.js';
const props = defineProps({
node: {
@@ -92,6 +130,19 @@
}
});
const silence = computed(() => props.node.silence || null);
const openSilenceExtension = () => {
if (!silence.value?.extendable || !props.node.package_usage_id) return;
uni.navigateTo({
url: `/pages/silence-extension/silence-extension?package_usage_id=${props.node.package_usage_id}`
});
};
const openSilenceRecords = () => {
if (!props.node.package_usage_id) return;
uni.navigateTo({
url: `/pages/silence-records/silence-records?package_usage_id=${props.node.package_usage_id}`
});
};
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');
@@ -147,6 +198,73 @@
</script>
<style lang="scss" scoped>
.silence-info {
margin-top: var(--space-md);
padding: var(--space-md);
border-radius: 16rpx;
background: var(--bg-secondary);
}
.silence-header {
align-items: center;
margin-bottom: var(--space-sm);
}
.silence-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.silence-status {
padding: 4rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
color: var(--text-secondary);
background: var(--bg-primary);
&.silence-waiting {
color: #0a84ff;
background: rgba(10, 132, 255, 0.12);
}
&.silence-expired {
color: var(--danger);
background: var(--danger-light);
}
&.silence-ended {
color: var(--text-tertiary);
}
}
.silence-pending {
margin-top: var(--space-sm);
font-size: 24rpx;
color: #ff9f0a;
}
.silence-actions {
display: flex;
justify-content: flex-end;
gap: 24rpx;
margin-top: var(--space-sm);
}
.silence-link {
padding: 10rpx 0;
font-size: 24rpx;
color: var(--primary-dark);
}
.silence-extend {
padding: 10rpx 32rpx;
border-radius: 999rpx;
font-size: 24rpx;
color: #fff;
background: var(--primary);
}
.package-node {
margin-bottom: var(--space-md);

View File

@@ -0,0 +1,131 @@
<template>
<view class="silence-overlay" :class="{ 'silence-overlay-blocking': blocking }" @tap.stop @touchmove.stop.prevent>
<view class="silence-dialog" :class="`silence-${reminder.kind}`" role="dialog" aria-modal="true" aria-label="套餐沉默期提醒">
<button v-if="!blocking" class="silence-close" aria-label="关闭沉默期提醒" @tap="$emit('close')">×</button>
<view class="silence-title">{{ title }}</view>
<view v-if="reminder.packageName" class="silence-package">{{ reminder.packageName }}</view>
<view class="silence-body">
<template v-if="reminder.pendingOrderNo">
套餐沉默期已于 <text class="silence-strong">{{ deadline }}</text> 过期,您有一笔未支付的延期订单({{ reminder.pendingOrderNo }}),请继续支付;不需要了可以作废后重新办理。
</template>
<template v-else-if="reminder.kind === 'expired'">
套餐需在 <text class="silence-strong">{{ deadline }}</text> 前完成实名,现已过期。请先办理延期,延期后才能继续使用其他功能。
</template>
<template v-else>
套餐需在 <text class="silence-strong">{{ deadline }}</text> 前完成实名激活,还剩
<text class="silence-strong">{{ reminder.remainingDays }}</text> 天,过期后需办理延期。
</template>
</view>
<button class="silence-confirm" @tap="$emit('action')">{{ actionText }}</button>
<button v-if="!blocking" class="silence-later" @tap="$emit('close')">我知道了</button>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
import { formatSilenceDate, isSilenceReminderBlocking } from '@/utils/silence.js';
const props = defineProps({ reminder: { type: Object, required: true } });
defineEmits(['close', 'action']);
const blocking = computed(() => isSilenceReminderBlocking(props.reminder));
const deadline = computed(() => formatSilenceDate(props.reminder.silence?.silence_deadline_at));
const title = computed(() => {
if (props.reminder.pendingOrderNo) return '延期订单未支付';
return props.reminder.kind === 'expired' ? '套餐沉默期已过期' : '请尽快完成实名';
});
const actionText = computed(() => {
if (props.reminder.pendingOrderNo) return '去支付或作废';
return props.reminder.kind === 'expired' ? '去延期' : '查看套餐';
});
</script>
<style scoped lang="scss">
.silence-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);
}
/* 强制延期弹窗盖在首页所有弹层(含 up-popup)之上 */
.silence-overlay-blocking {
z-index: 10200;
background: rgba(20, 26, 36, 0.64);
}
.silence-dialog {
position: relative;
width: 100%;
max-width: 640rpx;
padding: 48rpx 36rpx 32rpx;
box-sizing: border-box;
border-radius: 32rpx;
text-align: center;
background: var(--bg-primary);
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.18);
}
.silence-close {
position: absolute;
top: 16rpx;
right: 20rpx;
padding: 0;
font-size: 44rpx;
line-height: 1;
color: var(--text-tertiary);
background: transparent;
&::after { border: none; }
}
.silence-title {
font-size: 36rpx;
font-weight: 700;
color: var(--text-primary);
}
.silence-expired .silence-title {
color: var(--danger);
}
.silence-package {
margin-top: 12rpx;
font-size: 26rpx;
color: var(--text-secondary);
}
.silence-body {
margin: 28rpx 0 36rpx;
font-size: 28rpx;
line-height: 1.7;
color: var(--text-secondary);
text-align: left;
}
.silence-strong {
font-weight: 600;
color: var(--text-primary);
}
.silence-confirm {
width: 100%;
border-radius: 999rpx;
font-size: 30rpx;
color: #fff;
background: var(--primary);
}
.silence-later {
width: 100%;
margin-top: 16rpx;
font-size: 28rpx;
color: var(--text-tertiary);
background: transparent;
&::after { border: none; }
}
</style>

View File

@@ -40,32 +40,23 @@
<script setup>
import {
ref,
computed,
watch
computed
} from 'vue';
import {
assetApi
} from '@/api/index.js';
import {
useUserStore
} from '@/store/index.js';
const props = defineProps({
identifier: {
type: String,
default: ''
assetInfo: {
type: Object,
default: null
}
});
const emit = defineEmits(['packageLoaded']);
const userStore = useUserStore();
// 流量数据
const usedMB = ref(0);
const totalMB = ref(0);
const remainMB = ref(0);
// 和首页共用最新资产快照,不保留只在首次进入时加载的套餐缓存。
const metrics = computed(() => props.assetInfo?.current_package_usage_id
? getTrafficMetrics(props.assetInfo)
: { used: 0, total: 0, remain: 0 });
const usedMB = computed(() => metrics.value.used);
const totalMB = computed(() => metrics.value.total);
const remainMB = computed(() => metrics.value.remain);
const toMB = (value) => Number(value) || 0;
@@ -125,40 +116,6 @@
};
};
// 加载套餐流量数据
const loadPackageData = async () => {
if (userStore.state.loginPending || !userStore.state.token) return;
const identifier = props.identifier || userStore.state.identifier;
if (!identifier) return;
try {
const data = await assetApi.getInfo(identifier);
if (data.current_package_usage_id) {
const metrics = getTrafficMetrics(data);
usedMB.value = metrics.used;
totalMB.value = metrics.total;
remainMB.value = metrics.remain;
emit('packageLoaded', data);
} else {
usedMB.value = 0;
totalMB.value = 0;
remainMB.value = 0;
emit('packageLoaded', null);
}
} catch (e) {
console.error('加载套餐流量数据失败', e);
}
};
// 监听 identifier 变化
watch(() => [props.identifier || userStore.state.identifier, userStore.state.token, userStore.state.loginPending], ([newVal, token, loginPending]) => {
if (newVal && token && !loginPending) {
loadPackageData();
}
}, {
immediate: true
});
</script>
<style scoped lang="scss">

View File

@@ -58,9 +58,17 @@ app.$mount()
// #ifdef VUE3
import { createSSRApp } from 'vue'
import { assetApi } from '@/api/modules/asset.js'
import { getSessionContext } from '@/utils/session-context.js'
import { createSilenceGuard, installSilenceGuard } from '@/utils/silence-guard.js'
export function createApp() {
patchSafeSetTabBarStyle()
const app = createSSRApp(App)
// 沉默期已过期待延期:除绑定手机号、延期支付等页面外全部拦截到延期页
installSilenceGuard(app, createSilenceGuard({
getSession: getSessionContext,
loadPackageHistory: (identifier) => assetApi.getPackageHistory(identifier, 1, 20, { status: 0 })
}))
// 使用 uview-plus (版本 3.4.0+)
app.use(uviewPlus, () => {
return {

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-07

View File

@@ -0,0 +1,87 @@
## Context
- 后端 8-11 提供的沉默期 C 端能力(接口说明见 `junhong_cmp_fiber/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md` 第 4、6、9 节):
- 套餐历史 `GET /api/c/v1/asset/package-history` 每笔套餐带 `silence` 对象,总开关关闭或不适用时不返回。
- `/api/c/v1/package-silence`:`GET /eligibility`、`GET /quote`、`POST /orders`、`GET /orders`、`GET /orders/:id`、`GET /records`。资产须属于当前客户。
- C 端现状(`iteration/8-11`):
- 套餐历史页 `pages/asset-package-history` 通过 `components/PackageHistoryNode.vue` 递归渲染主套餐与加油包,`api/modules/asset.js` 的 `normalizePackageHistoryNode` 用展开运算保留原字段,`silence` 会原样透传。
- 接口统一走 `utils/request.js`:`code !== 0` 时 reject 原始响应(含 `code`、`msg`、`data`),`showError: false` 可关闭默认提示。
- 支付:套餐购买走 `orderApi.create/pay`,微信用公众号 JSAPI(`wechatH5Pay`),支付宝走 `pages/alipay-payment`(`u-qrcode` 渲染二维码 + 复制链接),返回刷新用 `markPendingPaymentRefresh` / `consumePendingPaymentRefresh`。
- 支付方式标签与过滤在 `utils/payment-methods.js`,资产信息返回 `allowed_payment_methods`。
- 后端延期下单的支付:`POST /orders` 只返回 `qr_content`,按商户池路由:支付宝为 WAP 支付链接,微信为微信 H5 支付链接(`H5URL`),富友为扫码内容。没有返回 JSAPI 支付参数。
- 约束:基于 `iteration/8-11`,只部署测试环境;不改停复机逻辑;不改现有套餐购买流程。
## Goals / Non-Goals
**Goals:**
- 套餐历史可见沉默期状态与截止日,可延期的套餐有入口。
- 客户能自助完成付费延期并看到结果。
- 金额、可选天数、新截止日全部以后端为准。
**Non-Goals:**
- 特殊延期、线下登记、批量延期(后台功能)。
- 后端改动,包括为延期订单增加微信 JSAPI 支付。
- 生产发布。
## Decisions
### D1 沉默期展示放在 `PackageHistoryNode` 内
在套餐卡片"到期时间"下方加沉默期区域,`node.silence` 为空时整块不渲染;加油包节点复用同一组件,规则一致。不新开详情页,客户在原页面即可看到状态。状态名直接用后端 `silence_status_name`,前端只按 `silence_status` 选颜色(waiting 蓝、expired 红、ended 灰)。
备选:在套餐历史页顶部汇总沉默期。拒绝原因:一个资产可能有多笔套餐,按笔展示更清楚。
### D2 新增接口模块 `api/modules/packageSilence.js`
与现有模块风格一致,导出 `packageSilenceApi`:`getEligibility`、`quote`、`createOrder`、`getOrders`、`getOrder`、`getRecords`。下单和资格请求 `showError: false`,由页面按错误码映射提示(D6)。订单列表默认带当前资产 `asset_identifier`,只看当前资产的订单。
### D3 页面结构
新增三个页面并在 `pages.json` 注册:
| 页面 | 用途 |
|---|---|
| `pages/silence-extension/silence-extension` | 资格、选天数、试算、选支付方式、下单 |
| `pages/silence-orders/silence-orders` | 我的延期订单列表,点击进入详情(详情用同目录 `detail` 页) |
| `pages/silence-records/silence-records` | 某笔套餐的沉默期流水 |
延期页参数 `package_usage_id`,流水页参数 `package_usage_id`。入口:套餐卡片"延期""沉默期记录";"我的延期订单"放在套餐历史页右上角或延期页底部。
### D4 天数选择与试算
优先展示 `options` 档位(每档计费天数、金额、新截止日),默认选第一档;同时提供"自定义天数"输入,输入停止 500ms 后调 `quote`,以最后一次请求结果为准(丢弃过期响应)。前端只校验 `min_days ≤ days ≤ max_days` 和整数,金额一律显示后端值(分转元,两位小数)。
### D5 下单幂等与支付拉起
- `request_id` 在客户点击"确认支付"时生成(时间戳 + 随机串),存在页面状态中;同一次提交的网络重试复用,天数或支付方式变化后清空。
- 提交中按钮置为 loading,禁止重复提交。
- 支付宝:复用 `openAlipayPayment` 跳 `pages/alipay-payment`,二维码与复制链接都用 `qr_content`。
- 微信:后端返回的是微信 H5 支付链接或扫码内容,不是 JSAPI 参数。实现时先按"`qr_content` 是 http(s) 链接则 `window.location.href` 跳转,否则用 `u-qrcode` 渲染二维码并提示截图扫码"处理,见 Open Questions。
- 拉起前 `markPendingPaymentRefresh('silence-extension')`;延期页与订单详情 `onShow` 时 `consume` 到标记即查询订单详情。
### D6 错误码映射
在 `utils/silence-errors.js` 维护 1263~1274 的客户提示文案,1265、1266 用 `data` 中的范围拼接文案;资格接口的 `reason` 也映射为文案(not_expired、no_silence、extension_disabled、no_policy、exceeds_limit、refunded、order_pending、activated、adjust_pending)。未映射的错误码回退为后端 `msg`。
### D7 测试
沿用 `tests/october-h5.test.mjs` 的运行方式新增测试:套餐卡片沉默期显示与入口条件、天数校验、`request_id` 复用规则、错误码映射、支付返回刷新。
## Risks / Trade-offs
- [微信 H5 支付链接在微信内置浏览器中通常无法直接支付] → C 端主要在公众号内打开。上线测试前需要确认测试环境商户池给延期订单路由的微信渠道;若确实不能在微信内支付,短期只开放支付宝或二维码,长期需后端为延期订单提供 JSAPI 支付(另开后端变更)。
- [总开关默认关闭,测试环境看不到沉默期] → 验证前请超级管理员在后台打开总开关(打开前有 4 项检查)。
- [支付回跳不可靠] → 不依赖回跳,统一在 `onShow` 查订单详情;订单列表也可手动刷新。
- [延期下单不涉及停复机] → 延期只改套餐沉默期截止日;后端在延期生效后是否复机由后端规则决定,测试环境资产已全部在只读表中,不会真实复机。
## Migration Plan
1. 在 `iteration/8-11` 上实现并本地 `build:h5`(development 模式,接测试后端)。
2. 推送 `iteration/8-11` 部署测试环境(C 端 CI 目前推送未触发构建,需先查原因或手动部署)。
3. 测试环境打开沉默期总开关,用测试资产验证:展示、资格、试算、下单、支付、流水。
4. 回退:回滚 `iteration/8-11` 对应提交重新部署;后端关闭总开关时前端自动不显示沉默期。
## Open Questions
- 延期订单的微信支付方式:测试与生产商户池对延期订单走哪种微信渠道?是否需要后端提供 JSAPI 支付?决定前按 D5 的兜底方式实现。
- "我的延期订单"入口位置(套餐历史页右上角 / 首页功能卡 / 延期页底部),默认放套餐历史页右上角。

View File

@@ -0,0 +1,31 @@
## Why
后端 8-11 迭代已上线"实名即生效套餐沉默期":套餐历史返回 `silence` 对象,并提供 H5 自助延期接口(`/api/c/v1/package-silence/*`)。C 端目前没有用上任何沉默期字段和接口,客户看不到沉默期状态,沉默期过期后也无法自助付费延期,只能找运营在后台办理。依据 `junhong_cmp_fiber/docs/2026-11-06-测试环境8-11新功能前端对接与权限现状.md` 第 5.1 节补齐 C 端。
本变更基于 `iteration/8-11` 分支,只部署测试环境。
## What Changes
- 套餐历史每笔套餐展示沉默期:状态、截止时间、沉默期天数、累计延期天数、"调整审批中"标记;`silence` 为空时不显示,保持现有展示。
- `silence.extendable = true` 时显示"延期"入口。
- 新增自助延期流程:查询延期资格 → 选择或输入延期天数并试算金额与新截止日 → 选择支付方式下单 → 用返回的 `qr_content` 拉起支付或展示二维码 → 支付后刷新状态。
- 新增"我的延期订单"列表与详情。
- 新增套餐沉默期流水查看。
- 沉默期错误码 1263~1274 给出客户能看懂的提示。
- 不改现有套餐购买、钱包、实名流程;不改任何停复机相关逻辑(前端本来也不发起停复机)。
## Capabilities
### New Capabilities
- `package-silence-extension`:C 端套餐沉默期展示、自助付费延期、延期订单与沉默期流水查看、沉默期错误提示。
### Modified Capabilities
(无。本仓库 `openspec/specs/` 下没有已有规格。)
## Impact
- 页面:`pages/asset-package-history`、`components/PackageHistoryNode.vue`;新增延期页、延期订单列表与详情页、沉默期流水页,并在 `pages.json` 注册。
- 接口:新增 `api/modules/packageSilence.js`,在 `api/index.js` 导出;`api/modules/asset.js` 的套餐历史归一化保留 `silence` 字段。
- 支付:复用 `utils/payment.js` 的支付宝支付页和待刷新标记;微信支付方式取决于后端 `qr_content` 的类型,见 design 待确认事项。
- 后端接口(8-11 已提供,不改):`GET /api/c/v1/asset/package-history`,`GET /api/c/v1/package-silence/eligibility`、`/quote`、`/orders`、`/orders/:id`、`/records`,`POST /api/c/v1/package-silence/orders`。
- 依赖后端总开关 `package.silence.enabled`:关闭时不返回 `silence`、延期接口返回 1274;测试环境验证前需要打开。

View File

@@ -0,0 +1,97 @@
## ADDED Requirements
### Requirement: 套餐历史展示沉默期
套餐历史中每笔套餐返回 `silence` 对象时,系统 SHALL 在该套餐卡片中展示沉默期信息:状态名称 `silence_status_name`、截止时间 `silence_deadline_at`、沉默期天数 `silence_period_days`、累计延期天数 `silence_extended_days`(大于 0 时)。`silence_adjust_pending = true` 时 SHALL 显示"调整审批中"。`silence` 为空或缺失时 MUST NOT 显示任何沉默期内容,其余展示保持不变。关联加油包节点同样按此规则处理。
#### Scenario: 待实名套餐
- **WHEN** 套餐历史中一笔套餐的 `silence.silence_status = waiting`,截止时间为 2026-12-05 23:59:59
- **THEN** 该套餐卡片显示"待实名"(或后端返回的"延期待实名")和截止日期 2026-12-05
#### Scenario: 总开关关闭
- **WHEN** 套餐历史返回的套餐都没有 `silence` 字段
- **THEN** 页面与改动前一致,不显示沉默期区域和延期入口
#### Scenario: 调整审批中
- **WHEN** 一笔套餐的 `silence.silence_adjust_pending = true`
- **THEN** 沉默期区域显示"调整审批中"
### Requirement: 延期入口
系统 SHALL 只在 `silence.extendable = true` 时在该套餐卡片显示"延期"入口;点击后进入该套餐的延期页面,并带上 `package_usage_id`。`extendable` 为 false 或缺失时 MUST NOT 显示入口。
#### Scenario: 已过期可延期
- **WHEN** 一笔套餐 `silence.silence_status = expired` 且 `extendable = true`
- **THEN** 卡片显示"延期"入口,点击进入延期页面
#### Scenario: 不可延期
- **WHEN** 一笔套餐 `silence.extendable = false`
- **THEN** 卡片不显示"延期"入口
### Requirement: 延期资格与试算
进入延期页面时,系统 SHALL 调用 `GET /api/c/v1/package-silence/eligibility?package_usage_id=` 获取资格。`can_extend = false` 时 SHALL 按 `reason` 展示不能办理的原因,MUST NOT 允许下单;`pending_order_no` 非空时 SHALL 提示已有未完成的延期订单,并提供进入该订单详情的入口。可以办理时 SHALL 展示当前截止日期、已过期天数、可选天数范围(`min_days`~`max_days`),并列出 `options` 中每档的计费天数、金额与新截止日期供选择;客户输入自定义天数时 SHALL 调用 `GET /quote` 试算并展示计费天数、金额与新截止日期。金额 MUST 以后端返回为准,前端只传天数、不自行计算金额。输入天数超出 `min_days`~`max_days` 时 MUST 在提交前提示并禁止下单。
#### Scenario: 选择档位
- **WHEN** 资格返回 `options` 含 30 天 / 60 天两档,客户选择 60 天
- **THEN** 页面显示 60 天档的金额(分换算为元)与新截止日期,可以下单
#### Scenario: 输入天数试算
- **WHEN** 客户输入 31 天
- **THEN** 页面调用试算接口,显示计费天数 60 天、对应金额与新截止日期
#### Scenario: 已有未完成订单
- **WHEN** 资格返回 `pending_order_no` 非空
- **THEN** 页面提示已有未完成的延期订单,不能新建,并可进入该订单详情
#### Scenario: 不能办理
- **WHEN** 资格返回 `can_extend = false`、`reason = not_expired`
- **THEN** 页面显示"套餐沉默期尚未过期,暂不需要延期",下单按钮不可用
### Requirement: 自助下单与支付
客户确认天数与支付方式后,系统 SHALL 调用 `POST /api/c/v1/package-silence/orders`,参数为 `package_usage_id`、`days`、`payment_method`、`request_id`。支付方式 MUST 只在 `wechat`、`alipay` 中,并取当前资产 `allowed_payment_methods` 的交集;交集为空时 SHALL 提示暂不支持在线延期。同一次提交的网络重试 MUST 复用同一个 `request_id`,客户重新确认(天数或支付方式变化)时 SHALL 生成新的 `request_id`。提交期间 MUST 防止重复点击。下单成功后 SHALL 用返回的 `qr_content` 拉起支付:支付宝走现有支付宝支付页(二维码 + 复制链接);微信按 design 中确定的方式处理。拉起支付后 SHALL 记录待刷新标记,客户返回时刷新订单状态。
#### Scenario: 支付宝下单
- **WHEN** 客户选择 30 天、支付宝支付并确认
- **THEN** 系统下单后进入支付宝支付页,展示 `qr_content` 二维码与复制链接
#### Scenario: 重复点击
- **WHEN** 客户在下单请求返回前再次点击确认
- **THEN** 系统不发起第二次下单请求
#### Scenario: 无可用支付方式
- **WHEN** 资产 `allowed_payment_methods` 只有 `wallet`
- **THEN** 延期页面提示暂不支持在线延期,不显示下单按钮
### Requirement: 支付结果与订单查看
系统 SHALL 提供"我的延期订单"列表(`GET /api/c/v1/package-silence/orders`,可按支付时间 `paid_start`、`paid_end` 筛选,分页加载)与详情(`GET /orders/:id`)。详情 SHALL 展示订单号、套餐、天数(输入天数与计费天数)、金额、状态名称、支付方式、下单前截止时间、预计新截止时间、支付时间、延期生效时间;状态为 `pending_payment` 且返回 `qr_content` 时 SHALL 提供"继续支付"。从支付返回后 SHALL 重新查询订单详情;状态为 `completed` 时提示延期成功并刷新套餐历史。
#### Scenario: 支付成功返回
- **WHEN** 客户支付完成返回,订单状态为 `completed`
- **THEN** 页面提示延期成功,套餐历史中该套餐显示新的截止日期
#### Scenario: 继续支付
- **WHEN** 客户打开一笔 `pending_payment` 的延期订单,详情带 `qr_content`
- **THEN** 页面显示"继续支付",点击后按原支付方式拉起支付
### Requirement: 沉默期流水
系统 SHALL 在有 `silence` 的套餐上提供"沉默期记录"入口,调用 `GET /api/c/v1/package-silence/records?package_usage_id=` 按时间展示该套餐的沉默期变化记录;无记录时显示空状态。
#### Scenario: 查看流水
- **WHEN** 客户在一笔已延期过的套餐上打开沉默期记录
- **THEN** 页面按时间列出起算、延期等记录
### Requirement: 沉默期错误提示
沉默期接口返回错误码 1263~1274 时,系统 SHALL 显示面向客户的中文提示,MUST NOT 直接展示内部错误名称。至少包括:1263 套餐当前不需要延期、1264 该套餐不支持付费延期请联系客服、1265 超过可延期期限、1266 延期天数不在允许范围(带 `min_days`~`max_days`)、1267 免费延期次数已用完、1270 暂未开放延期请联系客服、1271 已有未完成的延期订单、1274 沉默期功能未开启。其他错误码沿用通用错误提示。
#### Scenario: 天数超范围
- **WHEN** 下单返回 1266,`data.min_days = 30`、`data.max_days = 180`
- **THEN** 页面提示"延期天数需在 30~180 天之间"
#### Scenario: 功能未开启
- **WHEN** 资格接口返回 1274
- **THEN** 延期页面提示"延期功能暂未开放",不显示下单按钮

View File

@@ -0,0 +1,38 @@
## 1. 接口与工具
- [x] 1.1 新增 `api/modules/packageSilence.js`(eligibility、quote、createOrder、getOrders、getOrder、getRecords),在 `api/index.js` 导出
- [x] 1.2 确认 `normalizeAssetPackageHistory` 保留 `silence` 对象(含加油包子节点),补充布尔字段 `extendable`、`silence_adjust_pending` 归一化
- [x] 1.3 新增 `utils/silence.js`(错误码与延期纯函数,代替原计划的 silence-errors.js):错误码 1263~1274 与资格 `reason` 的客户提示文案,1265/1266 拼接范围
- [x] 1.4 新增延期支付方式过滤:`allowed_payment_methods` 与 `wechat`、`alipay` 取交集
## 2. 套餐历史展示
- [x] 2.1 `PackageHistoryNode.vue` 增加沉默期区域:状态名、截止日期、沉默期天数、累计延期天数、"调整审批中";`silence` 为空不渲染
- [x] 2.2 `extendable = true` 时显示"延期"入口,跳转延期页并带 `package_usage_id`
- [x] 2.3 有 `silence` 的套餐显示"沉默期记录"入口
- [x] 2.4 套餐历史页增加"我的延期订单"入口;从延期成功返回时刷新列表
## 3. 延期页
- [x] 3.1 新建 `pages/silence-extension/silence-extension` 并注册 `pages.json`
- [x] 3.2 加载资格:不能办理时展示原因;有 `pending_order_no` 时提示并提供订单详情入口;1274 显示功能未开放
- [x] 3.3 展示当前截止日期、已过期天数、`options` 档位(计费天数、金额、新截止日),默认选第一档
- [x] 3.4 自定义天数输入:范围与整数校验,防抖调用试算,丢弃过期响应
- [x] 3.5 支付方式选择;无可用方式时提示暂不支持在线延期
- [x] 3.6 下单:生成并复用 `request_id`,提交中防重复,错误码按 1.3 提示
- [x] 3.7 拉起支付:支付宝走现有支付宝支付页;微信按 design D5(链接跳转或二维码);拉起前写待刷新标记(微信:微信外为链接直接跳转,其余进入延期支付页展示二维码与复制链接)
- [x] 3.8 `onShow` 消费待刷新标记后查询订单详情,`completed` 时提示延期成功
## 4. 延期订单与流水
- [ ] 4.1 新建延期订单列表页(分页、按支付时间筛选、默认当前资产)(已做分页与默认当前资产;按支付时间筛选未做)
- [x] 4.2 新建延期订单详情页:订单号、套餐、输入/计费天数、金额、状态、支付方式、截止时间变化、支付与生效时间;`pending_payment` 且有 `qr_content` 时"继续支付"
- [x] 4.3 新建沉默期流水页:按时间展示 `event_name`、截止时间变化、天数,空状态
## 5. 测试与验证
- [x] 5.1 在 `tests/` 增加用例:沉默期显示与入口条件、天数校验、`request_id` 复用、错误码映射、支付返回刷新(tests/silence.test.mjs 覆盖天数校验、request_id 复用、错误码映射、支付方式过滤;页面级的显示与入口条件、支付返回刷新未写自动化用例;既有 october-h5 38 项通过)
- [x] 5.2 本地 `build:h5`(development 模式)通过,确认接口指向测试后端
- [ ] 5.3 确认延期订单微信支付方式(design Open Questions),按结论调整 3.7(测试库微信商户池为 wechat_v2 直连,返回 H5 支付链接,公众号内需实测)
- [ ] 5.4 测试环境打开沉默期总开关后,用测试资产走通:展示 → 资格 → 试算 → 下单 → 支付 → 延期生效 → 流水
- [ ] 5.5 推送 `iteration/8-11` 部署测试环境(先排查 C 端 CI 未触发构建的原因)

View File

@@ -38,6 +38,36 @@
"navigationBarTitleText": "资产套餐历史"
}
},
{
"path": "pages/silence-extension/silence-extension",
"style": {
"navigationBarTitleText": "沉默期延期"
}
},
{
"path": "pages/silence-payment/silence-payment",
"style": {
"navigationBarTitleText": "延期支付"
}
},
{
"path": "pages/silence-orders/silence-orders",
"style": {
"navigationBarTitleText": "我的延期订单"
}
},
{
"path": "pages/silence-orders/detail",
"style": {
"navigationBarTitleText": "延期订单详情"
}
},
{
"path": "pages/silence-records/silence-records",
"style": {
"navigationBarTitleText": "沉默期记录"
}
},
{
"path": "pages/order-list/order-list",
"style": {

View File

@@ -1,5 +1,8 @@
<template>
<view class="container">
<view class="history-toolbar">
<text class="silence-orders-link" role="button" tabindex="0" @tap="openSilenceOrders">我的延期订单</text>
</view>
<view v-if="!historyLoaded || (loading && packageList.length === 0)" class="loading-state">
<up-loading-icon text="加载中" size="30"></up-loading-icon>
</view>
@@ -27,6 +30,7 @@
<script setup>
import { ref, onMounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { assetApi } from '@/api/index.js';
import PackageHistoryNode from '@/components/PackageHistoryNode.vue';
import { useUserStore } from '@/store/index.js';
@@ -86,12 +90,39 @@
const loadMore = () => loadPackageList(true);
const SILENCE_COMPLETED_KEY = 'silence_extension_completed';
const openSilenceOrders = () => {
uni.navigateTo({ url: '/pages/silence-orders/silence-orders' });
};
onMounted(() => {
uni.removeStorageSync(SILENCE_COMPLETED_KEY);
loadPackageList();
});
// 延期成功后返回本页:重新加载第一页,展示新的沉默期截止日期
onShow(() => {
if (!uni.getStorageSync(SILENCE_COMPLETED_KEY)) return;
uni.removeStorageSync(SILENCE_COMPLETED_KEY);
page.value = 1;
noMore.value = false;
loadPackageList();
});
</script>
<style lang="scss" scoped>
.history-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: var(--space-sm);
}
.silence-orders-link {
padding: 8rpx 0;
font-size: 26rpx;
color: var(--primary-dark);
}
.loading-state {
display: flex;
align-items: center;

View File

@@ -10,7 +10,7 @@
<!-- <VoiceCard v-if="!userInfo.isDevice" :voiceStats="voiceStats" :dateRangeText="dateRangeText"
@openDatePicker="openDateRangePicker" /> -->
<TrafficCard :identifier="userStore.state.identifier" />
<TrafficCard :assetInfo="assetStore.state.assetInfo" />
<!-- <WhitelistCard v-if="!userInfo.isDevice" :whitelistData="whitelistData" @refresh="refreshWhitelist"
@add="showAddWhitelistDialog" @showSms="showSmsCodeDialog" /> -->
@@ -100,6 +100,11 @@
<ExpiryReminderPopup v-if="visibleExpiryReminder" :reminder="visibleExpiryReminder"
@close="closeExpiryReminder" @renew="renewFromExpiryReminder" />
<SilenceReminderPopup v-if="silenceReminderShow && silenceReminder" :reminder="silenceReminder"
@close="closeSilenceReminder" @action="openSilenceReminderAction" />
<!-- 沉默期检查完成前挡住首页操作,避免强制延期弹窗弹出前还能点 -->
<view v-if="silenceChecking" class="silence-checking-mask" @tap.stop @touchmove.stop.prevent></view>
<NotificationPopup :show="notificationPopupShow" :items="notificationItems"
:current="notificationPopupCurrent" @change="onNotificationChange" @close="closeNotificationPopup"
@renew="startHomepageRenewal" />
@@ -141,13 +146,15 @@
import FloatingButton from '@/components/FloatingButton.vue';
import NotificationPopup from '@/components/NotificationPopup.vue';
import ExpiryReminderPopup from '@/components/ExpiryReminderPopup.vue';
import SilenceReminderPopup from '@/components/SilenceReminderPopup.vue';
import RenewalPaymentPopup from '@/components/RenewalPaymentPopup.vue';
import PopupCandidate from '@/components/PopupCandidate.vue';
import {
assetApi,
deviceApi,
hasActiveOrPendingPackage,
notificationApi
notificationApi,
packageSilenceApi
} from '@/api/index.js';
import {
useUserStore,
@@ -156,6 +163,7 @@
import { consumePendingPaymentRefresh, PAYMENT_REFRESH_TARGETS } from '@/utils/payment.js';
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
import { getShanghaiDay } from '@/utils/expiry-reminder.js';
import { isSilenceReminderBlocking, pickSilenceReminder, silenceReminderKey, silenceReminderRepeats } from '@/utils/silence.js';
const userStore = useUserStore();
const assetStore = useAssetStore();
@@ -175,7 +183,7 @@
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;
silenceReminderShow.value || visibleExpiryReminder.value || !entry || entry.shown || !isCurrentSession(entry) || entry.day !== getShanghaiDay()) return;
shownExpiryReminder.value = { ...entry };
assetStore.markExpiryReminderShown();
};
@@ -184,11 +192,90 @@
assetStore.dismissExpiryReminder();
if (resumeQueue) advanceHomePopupQueue();
};
// 沉默期提醒:已过期待延期的为强制弹窗(立即弹出、不可关闭、每次回首页都弹);临近截止的按"临期提醒 → 沉默期提醒 → 站内通知"顺序弹出,同一套餐每次登录最多一次。
const silenceReminder = ref(null);
const silenceReminderShow = ref(false);
let silenceReminderIdentifier = '';
const silenceChecking = ref(false);
const loadSilenceReminder = async (entryId) => {
silenceChecking.value = true;
try {
await loadSilenceReminderInner(entryId);
} finally {
if (entryId === homeEntryId) silenceChecking.value = false;
}
};
const loadSilenceReminderInner = async (entryId) => {
const identifier = String(userStore.state.identifier || '').trim();
silenceReminder.value = null;
if (!identifier) return;
try {
const data = await assetApi.getPackageHistory(identifier, 1, 20, { status: 0 });
if (entryId !== homeEntryId || !homeActive.value || identifier !== String(userStore.state.identifier || '').trim()) return;
const reminder = pickSilenceReminder(data?.items);
if (!reminder) return;
if (!silenceReminderRepeats(reminder) && uni.getStorageSync(silenceReminderKey(identifier, reminder, getSessionContext().token))) return;
// 已过期的套餐再查一次延期资格:有未支付的延期订单时,弹窗改为提醒去支付或作废
if (reminder.kind === 'expired') {
try {
const eligibility = await packageSilenceApi.getEligibility(reminder.packageUsageId);
if (eligibility?.pending_order_no) reminder.pendingOrderNo = eligibility.pending_order_no;
} catch (error) {
// 资格查询失败不影响提醒本身
}
}
if (entryId !== homeEntryId || !homeActive.value || identifier !== String(userStore.state.identifier || '').trim()) return;
silenceReminderIdentifier = identifier;
silenceReminder.value = reminder;
// 已过期待延期:不排队,立即强制弹出并盖住其他弹层,未延期前不能做其他操作
if (isSilenceReminderBlocking(reminder)) {
silenceReminderShow.value = true;
return;
}
advanceHomePopupQueue();
} catch (error) {
// 提醒失败不影响首页其他功能
console.error('加载沉默期提醒失败', error);
}
};
const showPendingSilenceReminder = () => {
const reminder = silenceReminder.value;
if (!reminder || silenceReminderShow.value) return false;
if (silenceReminderIdentifier !== String(userStore.state.identifier || '').trim()) {
silenceReminder.value = null;
return false;
}
if (!silenceReminderRepeats(reminder)) {
uni.setStorageSync(silenceReminderKey(silenceReminderIdentifier, reminder, getSessionContext().token), 1);
}
silenceReminderShow.value = true;
return true;
};
const closeSilenceReminder = () => {
// 强制延期弹窗不允许关闭
if (isSilenceReminderBlocking(silenceReminder.value)) return;
silenceReminderShow.value = false;
silenceReminder.value = null;
advanceHomePopupQueue();
};
const openSilenceReminderAction = () => {
const reminder = silenceReminder.value;
silenceReminderShow.value = false;
silenceReminder.value = null;
if (!reminder) return;
uni.navigateTo({
url: reminder.kind === 'expired'
? `/pages/silence-extension/silence-extension?package_usage_id=${reminder.packageUsageId}`
: '/pages/asset-package-history/asset-package-history'
});
};
const advanceHomePopupQueue = () => {
if (!homeActive.value || awaitingHomeReminders.value || renewalOpening.value || renewalVisible.value || popupCandidateShow.value ||
visibleExpiryReminder.value || notificationPopupShow.value) return;
visibleExpiryReminder.value || notificationPopupShow.value || silenceReminderShow.value) return;
showPendingExpiryReminder();
if (visibleExpiryReminder.value) return;
if (showPendingSilenceReminder()) return;
if (notificationQueued) {
notificationQueued = false;
notificationPopupShow.value = true;
@@ -205,6 +292,8 @@
let recoverShow = ref(false);
let logoutShow = ref(false);
let loading = ref(false);
let syncing = ref(false);
let assetRequestId = 0;
let userInfo = reactive({
isDevice: true,
@@ -284,7 +373,6 @@
let showSmsCode = ref(false);
let smsCodePhone = ref('');
let smsCode = ref('');
let indexEntryChecking = ref(false);
let notificationUnreadCount = ref(0);
let notificationPopupShow = ref(false);
let notificationItems = ref([]);
@@ -309,11 +397,12 @@
if (!identifier) return;
if (userStore.state.identifier !== identifier) userStore.setIdentifier(identifier);
const context = getSessionContext();
const requestId = ++assetRequestId;
loading.value = true;
try {
const data = await assetApi.getInfo(identifier);
if (!isCurrentSession(context)) return null;
if (!isCurrentSession(context) || requestId !== assetRequestId) return null;
// 基本信息
userInfo.isDevice = data.asset_type === 'device';
@@ -338,7 +427,7 @@
effectiveRealnamePolicy.value = data.effective_realname_policy || 'none';
userStore.setRealNameStatus(data.real_name_status);
// 流量信息已由 TrafficCard 组件独立获取,这里不再处理
// 流量卡片消费同一次 asset/info 快照,支付和有效期变化后一起更新。
// 当前卡信息
if (data.cards && data.cards.length > 0) {
@@ -397,7 +486,7 @@
console.error('加载资产信息失败', e);
return null;
} finally {
loading.value = false;
if (requestId === assetRequestId) loading.value = false;
}
};
@@ -415,14 +504,7 @@
}
if (userStore.state.identifier !== identifier) userStore.setIdentifier(identifier);
if (indexEntryChecking.value) return;
indexEntryChecking.value = true;
try {
await loadAssetInfo();
} finally {
indexEntryChecking.value = false;
}
await loadAssetInfo();
};
const onRenewalVisibilityChange = (visible) => {
@@ -738,6 +820,7 @@
};
const onSync = async () => {
if (syncing.value) return;
const identifier = userStore.state.identifier;
if (!identifier) {
uni.showToast({
@@ -747,11 +830,13 @@
return;
}
const context = getSessionContext();
uni.showModal({
title: '确认同步',
content: '确定要同步运营商数据吗?',
content: '确定要同步运营商数据吗?受理不代表同步或停复机已执行完成,结果以稍后读取的实际状态为准。',
success: (res) => {
if (!res.confirm) return;
if (!res.confirm || syncing.value || !isCurrentSession(context)) return;
syncing.value = true;
uni.showLoading({
title: '同步中...',
mask: true
@@ -759,17 +844,19 @@
assetApi.refresh(identifier).then((data) => {
uni.hideLoading();
if (!isCurrentSession(context)) return;
if (data.accepted) {
if (data?.accepted === true || data?.accepted === 1) {
const refreshTypeText = data.refresh_type === 'device' ? '设备' : '卡';
uni.showToast({
title: `${refreshTypeText}数据同步成功`,
icon: 'success',
duration: 2000
title: `${refreshTypeText}同步请求已受理,请稍后查看状态`,
icon: 'none',
duration: 3000
});
loadAssetInfo();
setTimeout(() => {
loadAssetInfo();
if (homeActive.value && isCurrentSession(context)) loadAssetInfo();
}, 2000);
} else {
uni.showToast({
@@ -779,6 +866,7 @@
}
}).catch((e) => {
uni.hideLoading();
if (!isCurrentSession(context)) return;
console.error('同步数据失败', e);
if (e.msg && e.msg.includes('冷却')) {
@@ -793,7 +881,7 @@
icon: 'none'
});
}
});
}).finally(() => { syncing.value = false; });
}
});
};
@@ -1018,22 +1106,27 @@
handleIndexEntry();
return;
}
if (consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.HOME)) {
loadAssetInfo();
}
// 每次返回首页都重新读取实际状态,支付返回不再重复发起两次请求。
consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.HOME);
loadNotificationUnreadCount();
runPopupCandidate();
await handleIndexEntry();
if (!homeActive.value || entryId !== homeEntryId) return;
awaitingHomeReminders.value = false;
advanceHomePopupQueue();
// 沉默期提醒在后台加载,不阻塞首页其他弹窗;加载完成后按队列顺序择机弹出。
loadSilenceReminder(entryId);
});
onHide(() => {
homeEntryId++;
assetRequestId++;
homeActive.value = false;
shownExpiryReminder.value = null;
notificationPopupShow.value = false;
popupCandidateShow.value = false;
silenceReminderShow.value = false;
silenceReminder.value = null;
silenceChecking.value = false;
notificationQueued = false;
notificationFallbackPending = false;
});
@@ -1068,4 +1161,11 @@
.bottom-spacer {
height: 120rpx;
}
.silence-checking-mask {
position: fixed;
inset: 0;
z-index: 10199;
background: transparent;
}
</style>

View File

@@ -67,14 +67,14 @@
</view>
<RenewalPaymentPopup ref="renewalPopupRef" :identifier="userStore.state.identifier"
paymentRefreshTarget="order-list" @completed="resetOrderListAndLoad" />
paymentRefreshTarget="order-list" @completed="refreshOrderState" />
</view>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { ref, reactive, computed } from 'vue';
import { onReachBottom, onShow } from '@dcloudio/uni-app';
import { orderApi } from '@/api/index.js';
import { assetApi, orderApi } from '@/api/index.js';
import RenewalPaymentPopup from '@/components/RenewalPaymentPopup.vue';
import { useUserStore } from '@/store/index.js';
import {
@@ -121,17 +121,25 @@
loadOrderList();
};
const refreshOrderState = () => {
resetOrderListAndLoad();
// 订单支付、退款及套餐生效后,读取后端实际轮询状态,不推测已恢复。
return assetApi.getInfo(userStore.state.identifier).catch(error => {
console.error('刷新订单关联资产状态失败', error);
});
};
const scheduleOrderStatusRefresh = () => {
uni.showToast({
title: '正在同步支付状态',
icon: 'none'
});
resetOrderListAndLoad();
refreshOrderState();
setTimeout(() => {
resetOrderListAndLoad();
}, 1500);
setTimeout(() => {
resetOrderListAndLoad();
refreshOrderState();
}, 3000);
};
@@ -232,16 +240,13 @@
onShow(() => {
if (!consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.ORDER_LIST)) {
refreshOrderState();
return;
}
scheduleOrderStatusRefresh();
});
onMounted(() => {
loadOrderList();
});
onReachBottom(() => {
loadMore();
});

View File

@@ -102,7 +102,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { ref, reactive, computed } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { assetApi, isAssetRealNameCompleted, orderApi, walletApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
@@ -121,6 +121,7 @@
import { getPaymentErrorMessage, isUnknownPaymentResult } from '@/utils/payment-attempt.js';
import { formatMoney } from '@/utils/display.js';
import { getDefaultPaymentMethod, getPaymentMethodOptions, normalizePaymentMethods } from '@/utils/payment-methods.js';
import { getSessionContext, isCurrentSession } from '@/utils/session-context.js';
const userStore = useUserStore();
const popupCandidateShow = ref(false);
@@ -147,7 +148,7 @@
{ label: '正式套餐', value: 'formal' },
{ label: '加油包', value: 'addon' }
];
let assetTypePromise = null;
let assetRequestId = 0;
let packageRequestId = 0;
const paymentMethodOptions = computed(() => getPaymentMethodOptions(allowedPaymentMethods.value));
@@ -177,22 +178,19 @@
const loadAssetType = async () => {
if (!userStore.state.identifier) return;
if (assetTypePromise) return assetTypePromise;
assetTypePromise = assetApi.getInfo(userStore.state.identifier)
.then((data) => {
assetInfo.value = data;
allowedPaymentMethods.value = normalizePaymentMethods(data.allowed_payment_methods);
normalizePaymentMethod();
})
.catch((error) => {
console.error('加载资产类型失败', error);
})
.finally(() => {
assetTypePromise = null;
});
return assetTypePromise;
const context = getSessionContext();
const requestId = ++assetRequestId;
try {
const data = await assetApi.getInfo(userStore.state.identifier);
if (!isCurrentSession(context) || requestId !== assetRequestId) return null;
assetInfo.value = data;
allowedPaymentMethods.value = normalizePaymentMethods(data.allowed_payment_methods);
normalizePaymentMethod();
return data;
} catch (error) {
console.error('加载资产类型失败', error);
return null;
}
};
const loadPackages = async (packageType = activePackageType.value) => {
@@ -261,12 +259,21 @@
};
const syncPackagePageState = async () => {
await loadAssetType();
if (!await loadAssetType()) return;
await Promise.all([loadPackages(), loadWalletBalance()]);
};
const refreshAfterPayment = async () => {
const context = getSessionContext();
await syncPackagePageState();
// 充值自动购包可能稍后生效,再确认一次,仍以实际响应为准。
setTimeout(() => {
if (isCurrentSession(context)) syncPackagePageState();
}, 1500);
};
const buyPackage = async (item) => {
await loadAssetType();
if (!await loadAssetType()) return;
if (assetInfo.value.effective_realname_policy === 'before_order' &&
assetInfo.value.realname_required && !isAssetRealNameCompleted(assetInfo.value)) {
uni.showModal({
@@ -309,9 +316,7 @@
} else {
showPaymentToast(true, isForceRecharge ? '充值成功,套餐将自动购买' : '支付成功');
}
setTimeout(() => {
loadWalletBalance();
}, 1500);
await refreshAfterPayment();
} catch (error) {
handlePaymentError(error);
}
@@ -464,9 +469,7 @@
} catch (error) {
showPaymentToast(false, '支付结果确认失败,请稍后查看订单');
}
setTimeout(() => {
loadWalletBalance();
}, 1500);
await refreshAfterPayment();
return;
}
@@ -505,11 +508,12 @@
};
onShow(() => {
// 后台也可能修改套餐有效期,返回页面时始终重读,不只处理支付宝返回。
syncPackagePageState();
if (!consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.PACKAGE_ORDER)) {
return;
}
syncPackagePageState();
uni.showModal({
title: '支付状态确认',
content: '已返回页面,支付结果以订单状态为准。是否前往我的订单查看最新状态?',
@@ -539,9 +543,6 @@
popupCandidateShow.value = true;
});
onMounted(() => {
syncPackagePageState();
});
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,520 @@
<template>
<view class="container silence-page">
<view v-if="loading" class="loading-state">
<up-loading-icon text="加载中" size="30"></up-loading-icon>
</view>
<view v-else-if="blockedMessage" class="card blocked-card">
<view class="blocked-title">{{ blockedMessage }}</view>
<view v-if="pendingOrderNo" class="blocked-desc">未完成订单:{{ pendingOrderNo }}</view>
<view class="blocked-actions">
<view v-if="pendingOrderNo" class="btn-apple btn-primary" @tap="openPendingOrder">去支付或作废该订单</view>
<view class="btn-apple btn-secondary" @tap="goBack">返回</view>
</view>
</view>
<template v-else-if="eligibility">
<view class="card">
<view class="section-title">当前沉默期</view>
<view v-if="eligibility.package_name" class="info-row flex-row-sb">
<view class="info-label">套餐</view>
<view class="info-value">{{ eligibility.package_name }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">订购时间</view>
<view class="info-value">{{ formatDateTime(eligibility.purchased_at) }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">到期时间</view>
<view class="info-value">{{ eligibility.expires_at ? formatDateTime(eligibility.expires_at) : '实名激活后开始计算' }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">沉默期截止</view>
<view class="info-value">{{ eligibility.silence_deadline_at ? formatDateTime(eligibility.silence_deadline_at) : (eligibility.original_deadline || '-') }}</view>
</view>
<view v-if="eligibility.overdue_days > 0" class="info-row flex-row-sb">
<view class="info-label">已过期</view>
<view class="info-value danger">{{ eligibility.overdue_days }} 天</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">可延期天数</view>
<view class="info-value">{{ eligibility.min_days }}~{{ eligibility.max_days }} 天</view>
</view>
<view v-if="eligibility.extended_days > 0" class="info-row flex-row-sb">
<view class="info-label">已累计延期</view>
<view class="info-value">{{ eligibility.extended_days }} 天</view>
</view>
</view>
<view class="card">
<view class="section-title">选择延期天数</view>
<view class="option-list">
<view
v-for="option in options"
:key="option.billed_days"
class="option-item"
:class="{ active: !customMode && selectedDays === option.billed_days }"
@tap="selectOption(option)"
>
<view class="option-days">{{ option.billed_days }} 天</view>
<view class="option-amount">¥{{ formatMoney(option.amount) }}</view>
<view class="option-deadline">延至 {{ option.new_deadline }}</view>
</view>
</view>
<view class="custom-row" :class="{ active: customMode }">
<view class="custom-label" @tap="enableCustom">自定义天数</view>
<input
class="custom-input"
type="number"
:value="customDays"
:placeholder="`${eligibility.min_days}~${eligibility.max_days}`"
@focus="enableCustom"
@input="onCustomInput"
/>
<text class="custom-unit">天</text>
</view>
<view v-if="customError" class="custom-error">{{ customError }}</view>
<view v-else-if="customMode && quoting" class="custom-hint">试算中...</view>
</view>
<view v-if="summary" class="card summary-card">
<view class="info-row flex-row-sb">
<view class="info-label">计费天数</view>
<view class="info-value">{{ summary.billed_days }} 天</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">新截止日期</view>
<view class="info-value">{{ summary.new_deadline }}</view>
</view>
<view class="info-row flex-row-sb">
<view class="info-label">应付金额</view>
<view class="info-value amount">¥{{ formatMoney(summary.amount) }}</view>
</view>
<view class="summary-tip">按每月 30 天计费,不足 30 天按 30 天计。</view>
</view>
<view class="card">
<view class="section-title">支付方式</view>
<view v-if="paymentOptions.length === 0" class="empty-desc">当前资产暂不支持在线延期,请联系客服办理</view>
<view
v-for="method in paymentOptions"
:key="method.value"
class="payment-item flex-row-sb"
:class="{ active: paymentMethod === method.value }"
@tap="paymentMethod = method.value"
>
<text>{{ method.label }}</text>
<text class="payment-check">{{ paymentMethod === method.value ? '✓' : '' }}</text>
</view>
</view>
<view class="footer-actions">
<view class="orders-link" @tap="openOrders">我的延期订单</view>
<view
class="btn-apple btn-primary submit-btn"
:class="{ disabled: !canSubmit }"
@tap="submit"
>{{ submitting ? '提交中...' : '确认支付' }}</view>
</view>
</template>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { assetApi, packageSilenceApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import { formatDateTime, formatMoney } from '@/utils/display.js';
import { consumePendingPaymentRefresh } from '@/utils/payment.js';
import {
createRequestIdKeeper,
getSilenceErrorMessage,
getSilencePaymentOptions,
getSilenceReasonText,
validateExtensionDays
} from '@/utils/silence.js';
import { openSilencePayment, SILENCE_REFRESH_TARGET } from '@/utils/silence-payment.js';
const userStore = useUserStore();
const packageUsageId = ref(0);
const loading = ref(true);
const eligibility = ref(null);
const blockedMessage = ref('');
const pendingOrderNo = ref('');
const paymentOptions = ref([]);
const paymentMethod = ref('');
const selectedDays = ref(0);
const customMode = ref(false);
const customDays = ref('');
const customError = ref('');
const quoting = ref(false);
const quoteResult = ref(null);
const submitting = ref(false);
const lastOrderId = ref(0);
const requestIds = createRequestIdKeeper();
let quoteTimer = null;
let quoteSeq = 0;
const options = computed(() => (Array.isArray(eligibility.value?.options) ? eligibility.value.options : []));
const chosenDays = computed(() => (customMode.value ? Number(customDays.value) : selectedDays.value));
const summary = computed(() => {
if (customMode.value) return customError.value ? null : quoteResult.value;
return options.value.find((option) => option.billed_days === selectedDays.value) || null;
});
const canSubmit = computed(() => (
!submitting.value && !!summary.value && !!paymentMethod.value && !(customMode.value && quoting.value)
));
const showToast = (title) => uni.showToast({ title, icon: 'none', duration: 2500 });
const isInWechatBrowser = () => typeof navigator !== 'undefined' && /micromessenger/i.test(navigator.userAgent);
const loadEligibility = async () => {
loading.value = true;
blockedMessage.value = '';
pendingOrderNo.value = '';
try {
const [data, info] = await Promise.all([
packageSilenceApi.getEligibility(packageUsageId.value),
assetApi.getInfo(userStore.state.identifier).catch(() => null)
]);
eligibility.value = data;
paymentOptions.value = getSilencePaymentOptions(info?.allowed_payment_methods, isInWechatBrowser());
if (!paymentOptions.value.some((option) => option.value === paymentMethod.value)) {
paymentMethod.value = paymentOptions.value[0]?.value || '';
}
if (data?.pending_order_no) {
pendingOrderNo.value = data.pending_order_no;
blockedMessage.value = '已有未完成的延期订单,暂不能新建';
} else if (!data?.can_extend) {
blockedMessage.value = getSilenceReasonText(data?.reason);
} else if (!selectedDays.value) {
selectedDays.value = options.value[0]?.billed_days || 0;
if (!selectedDays.value) customMode.value = true;
}
} catch (error) {
blockedMessage.value = getSilenceErrorMessage(error, '查询延期资格失败,请稍后重试');
} finally {
loading.value = false;
}
};
const selectOption = (option) => {
customMode.value = false;
customError.value = '';
selectedDays.value = option.billed_days;
};
const enableCustom = () => {
customMode.value = true;
};
const runQuote = async (days) => {
const seq = ++quoteSeq;
quoting.value = true;
try {
const data = await packageSilenceApi.quote(packageUsageId.value, days);
if (seq !== quoteSeq) return;
quoteResult.value = data;
} catch (error) {
if (seq !== quoteSeq) return;
quoteResult.value = null;
customError.value = getSilenceErrorMessage(error, '试算失败,请稍后重试');
} finally {
if (seq === quoteSeq) quoting.value = false;
}
};
const onCustomInput = (event) => {
customMode.value = true;
customDays.value = String(event?.detail?.value ?? '').trim();
quoteResult.value = null;
clearTimeout(quoteTimer);
quoteSeq += 1;
quoting.value = false;
customError.value = customDays.value
? validateExtensionDays(customDays.value, eligibility.value?.min_days, eligibility.value?.max_days)
: '';
if (!customDays.value || customError.value) return;
const days = Number(customDays.value);
quoteTimer = setTimeout(() => runQuote(days), 500);
};
const submit = async () => {
if (!canSubmit.value) {
if (!paymentMethod.value) showToast('请选择支付方式');
else if (!summary.value) showToast('请选择延期天数');
return;
}
const days = chosenDays.value;
const message = validateExtensionDays(days, eligibility.value?.min_days, eligibility.value?.max_days);
if (message) {
showToast(message);
return;
}
submitting.value = true;
try {
const order = await packageSilenceApi.createOrder({
package_usage_id: packageUsageId.value,
days,
payment_method: paymentMethod.value,
request_id: requestIds.get(days, paymentMethod.value)
});
lastOrderId.value = order?.id || 0;
if (!order?.qr_content && !order?.pay_config) {
showToast('订单已创建,请在"我的延期订单"中继续支付');
openOrders();
return;
}
const result = await openSilencePayment(order, SILENCE_REFRESH_TARGET.EXTENSION);
// 微信内直接支付完成:立即查订单结果(不经过支付页回跳)
if (result?.paid) await checkLastOrder();
} catch (error) {
// 网络异常时后端可能已建单、取消微信支付时订单仍待支付:保留 request_id,客户再次点击会拿到同一笔订单。
if (!error?.paymentResultUnknown && !error?.cancelled) requestIds.reset();
showToast(getSilenceErrorMessage(error, '下单失败,请稍后重试'));
if (Number(error?.code) === 1271) await loadEligibility();
} finally {
submitting.value = false;
}
};
const checkLastOrder = async () => {
if (!lastOrderId.value) return;
try {
const order = await packageSilenceApi.getOrder(lastOrderId.value);
if (order?.status === 'completed') {
uni.showToast({ title: '延期成功', icon: 'success' });
requestIds.reset();
uni.setStorageSync('silence_extension_completed', Date.now());
setTimeout(() => uni.navigateBack(), 1200);
return;
}
if (order?.status === 'paid') {
showToast('支付成功,延期处理中');
return;
}
uni.navigateTo({ url: `/pages/silence-orders/detail?id=${lastOrderId.value}` });
} catch (error) {
showToast(getSilenceErrorMessage(error, '查询订单失败'));
}
};
// 有未完成订单时直接打开该订单详情:可继续支付,也可作废后重新办理
const openPendingOrder = async () => {
try {
const data = await packageSilenceApi.getOrders({ order_no: pendingOrderNo.value, page: 1, page_size: 1 });
const target = Array.isArray(data?.items) ? data.items[0] : null;
if (target?.id) {
uni.navigateTo({ url: `/pages/silence-orders/detail?id=${target.id}` });
return;
}
} catch (error) {
console.error('查询未完成延期订单失败', error);
}
openOrders();
};
const openOrders = () => {
uni.navigateTo({ url: '/pages/silence-orders/silence-orders' });
};
const goBack = () => uni.navigateBack();
onLoad((query) => {
packageUsageId.value = Number(query?.package_usage_id) || 0;
if (!packageUsageId.value) {
loading.value = false;
blockedMessage.value = '缺少套餐信息,请从套餐历史进入';
return;
}
loadEligibility();
});
onShow(() => {
// 从订单详情作废未完成订单后返回:重新查资格,允许重新下单
if (pendingOrderNo.value && packageUsageId.value) {
loadEligibility();
return;
}
if (consumePendingPaymentRefresh(SILENCE_REFRESH_TARGET.EXTENSION)) checkLastOrder();
});
</script>
<style lang="scss" scoped>
.silence-page {
padding-bottom: 180rpx;
}
.loading-state {
display: flex;
justify-content: center;
padding: 200rpx 0;
}
.section-title {
margin-bottom: var(--space-md);
font-size: 30rpx;
font-weight: 600;
color: var(--text-primary);
}
.info-row {
padding: 10rpx 0;
font-size: 26rpx;
}
.info-label {
color: var(--text-tertiary);
}
.info-value {
color: var(--text-primary);
&.danger {
color: var(--danger);
}
&.amount {
font-size: 34rpx;
font-weight: 700;
color: var(--danger);
}
}
.option-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.option-item {
padding: 24rpx;
border: 2rpx solid var(--border-color, #e5e5ea);
border-radius: 20rpx;
&.active {
border-color: var(--primary);
background: rgba(85, 171, 92, 0.08);
}
}
.option-days {
font-size: 30rpx;
font-weight: 600;
}
.option-amount {
margin-top: 8rpx;
font-size: 28rpx;
color: var(--danger);
}
.option-deadline {
margin-top: 8rpx;
font-size: 22rpx;
color: var(--text-tertiary);
}
.custom-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 24rpx;
padding: 20rpx 24rpx;
border: 2rpx solid var(--border-color, #e5e5ea);
border-radius: 20rpx;
&.active {
border-color: var(--primary);
}
}
.custom-label {
font-size: 26rpx;
white-space: nowrap;
}
.custom-input {
flex: 1;
font-size: 28rpx;
}
.custom-error {
margin-top: 12rpx;
font-size: 24rpx;
color: var(--danger);
}
.custom-hint,
.summary-tip,
.empty-desc {
margin-top: 12rpx;
font-size: 24rpx;
color: var(--text-tertiary);
}
.payment-item {
padding: 24rpx 0;
font-size: 28rpx;
border-bottom: 1rpx solid var(--border-color, #e5e5ea);
&.active {
color: var(--primary-dark);
}
}
.payment-check {
color: var(--primary);
}
.footer-actions {
position: fixed;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: var(--bg-primary);
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.orders-link {
font-size: 26rpx;
color: var(--primary-dark);
}
.submit-btn {
min-width: 280rpx;
text-align: center;
&.disabled {
opacity: 0.5;
}
}
.blocked-card {
margin-top: 40rpx;
text-align: center;
}
.blocked-title {
font-size: 30rpx;
color: var(--text-primary);
}
.blocked-desc {
margin-top: 16rpx;
font-size: 24rpx;
color: var(--text-tertiary);
}
.blocked-actions {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-top: 40rpx;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<view class="container">
<view v-if="loading && !order" class="loading-state">
<up-loading-icon text="加载中" size="30"></up-loading-icon>
</view>
<view v-else-if="!order" class="card empty-card">未找到延期订单</view>
<template v-else>
<view class="card">
<view class="detail-header flex-row-sb">
<view class="detail-title">{{ order.package_name || '套餐沉默期延期' }}</view>
<view class="detail-status">{{ order.status_name || order.status }}</view>
</view>
<view v-for="row in rows" :key="row.label" class="detail-row flex-row-sb">
<text class="label">{{ row.label }}</text>
<text class="value" :class="row.className">{{ row.value }}</text>
</view>
</view>
<view v-if="canContinuePay" class="btn-apple btn-primary pay-btn" @tap="continuePay">继续支付</view>
<view v-if="canCancel" class="btn-apple btn-secondary cancel-btn" @tap="cancelOrder">
{{ cancelling ? '作废中...' : '作废订单' }}
</view>
</template>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { packageSilenceApi } from '@/api/index.js';
import { formatDateTime, formatMoney } from '@/utils/display.js';
import { consumePendingPaymentRefresh } from '@/utils/payment.js';
import { formatSilenceDate, getSilenceErrorMessage, SILENCE_PAYMENT_LABELS } from '@/utils/silence.js';
import { openSilencePayment, SILENCE_REFRESH_TARGET } from '@/utils/silence-payment.js';
const orderId = ref(0);
const order = ref(null);
const loading = ref(false);
const rows = computed(() => {
const item = order.value || {};
return [
{ label: '订单号', value: item.order_no || '-' },
{ label: '输入天数', value: item.input_days ? `${item.input_days} 天` : '-' },
{ label: '计费 / 延长天数', value: item.extension_days ? `${item.extension_days} 天` : '-' },
{ label: '金额', value: `¥${formatMoney(item.amount)}`, className: 'amount' },
{ label: '支付方式', value: SILENCE_PAYMENT_LABELS[item.payment_method] || item.payment_method || '-' },
{ label: '原截止日期', value: formatSilenceDate(item.before_deadline_at) },
{ label: '延期后截止日期', value: formatSilenceDate(item.expected_deadline_at) },
{ label: '支付时间', value: formatDateTime(item.paid_at) },
{ label: '延期生效时间', value: formatDateTime(item.completed_at) },
{ label: '下单时间', value: formatDateTime(item.created_at) }
];
});
const canContinuePay = computed(() => order.value?.status === 'pending_payment' && (!!order.value?.qr_content || !!order.value?.pay_config));
const canCancel = computed(() => order.value?.status === 'pending_payment');
const cancelling = ref(false);
const cancelOrder = () => {
if (cancelling.value || !order.value) return;
uni.showModal({
title: '作废延期订单',
content: '作废后该订单不能再支付,可以重新办理延期。确定作废吗?',
success: async (res) => {
if (!res.confirm) return;
cancelling.value = true;
try {
order.value = await packageSilenceApi.cancelOrder(order.value.id);
uni.showToast({ title: '已作废', icon: 'success' });
} catch (error) {
uni.showToast({ title: getSilenceErrorMessage(error, '作废失败,请稍后重试'), icon: 'none', duration: 2500 });
await load();
} finally {
cancelling.value = false;
}
}
});
};
const load = async () => {
if (!orderId.value) return;
loading.value = true;
try {
order.value = await packageSilenceApi.getOrder(orderId.value);
} catch (error) {
uni.showToast({ title: getSilenceErrorMessage(error, '加载订单失败'), icon: 'none' });
} finally {
loading.value = false;
}
};
const continuePay = async () => {
try {
const result = await openSilencePayment(order.value, SILENCE_REFRESH_TARGET.ORDER_DETAIL);
if (result?.paid) await load();
} catch (error) {
uni.showToast({ title: error?.message || '拉起支付失败', icon: 'none' });
}
};
onLoad((query) => {
orderId.value = Number(query?.id) || 0;
});
onShow(async () => {
const fromPayment = consumePendingPaymentRefresh(SILENCE_REFRESH_TARGET.ORDER_DETAIL);
await load();
if (fromPayment && order.value?.status === 'completed') {
uni.showToast({ title: '延期成功', icon: 'success' });
uni.setStorageSync('silence_extension_completed', Date.now());
}
});
</script>
<style lang="scss" scoped>
.loading-state {
display: flex;
justify-content: center;
padding: 200rpx 0;
}
.empty-card {
margin-top: 40rpx;
text-align: center;
color: var(--text-tertiary);
}
.detail-header {
margin-bottom: var(--space-md);
}
.detail-title {
font-size: 30rpx;
font-weight: 600;
}
.detail-status {
font-size: 26rpx;
color: var(--primary-dark);
}
.detail-row {
padding: 10rpx 0;
font-size: 26rpx;
.label {
color: var(--text-tertiary);
}
.amount {
color: var(--danger);
}
}
.pay-btn,
.cancel-btn {
margin-top: 32rpx;
text-align: center;
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<view class="container">
<view v-if="!loaded || (loading && orders.length === 0)" class="loading-state">
<up-loading-icon text="加载中" size="30"></up-loading-icon>
</view>
<view v-else-if="orders.length === 0" class="empty-state">
<view class="empty-title">暂无延期订单</view>
<view class="empty-desc">沉默期过期后可在套餐历史中办理延期</view>
</view>
<view v-else>
<view
v-for="order in orders"
:key="order.id"
class="card order-card"
role="button"
tabindex="0"
@tap="openDetail(order)"
>
<view class="order-header flex-row-sb">
<view class="order-name">{{ order.package_name || '套餐沉默期延期' }}</view>
<view class="order-status" :class="`status-${order.status}`">{{ order.status_name || order.status }}</view>
</view>
<view class="order-row flex-row-sb">
<text class="label">延期天数</text>
<text>{{ order.extension_days || order.input_days }} 天</text>
</view>
<view class="order-row flex-row-sb">
<text class="label">金额</text>
<text class="amount">¥{{ formatMoney(order.amount) }}</text>
</view>
<view class="order-row flex-row-sb">
<text class="label">下单时间</text>
<text>{{ formatDateTime(order.created_at) }}</text>
</view>
</view>
<view class="load-more">
<text v-if="loading">加载中...</text>
<text v-else-if="noMore">没有更多订单</text>
<text v-else role="button" tabindex="0" @tap="loadMore">点击加载更多</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { packageSilenceApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import { formatDateTime, formatMoney } from '@/utils/display.js';
const userStore = useUserStore();
const orders = ref([]);
const loading = ref(false);
const loaded = ref(false);
const noMore = ref(false);
const page = ref(1);
const pageSize = 10;
const load = async (append = false) => {
if (loading.value) return;
loading.value = true;
try {
const requestPage = append ? page.value : 1;
const data = await packageSilenceApi.getOrders({
page: requestPage,
page_size: pageSize,
asset_identifier: userStore.state.identifier
});
const items = Array.isArray(data?.items) ? data.items : [];
orders.value = append ? [...orders.value, ...items] : items;
const total = Number(data?.total) || 0;
noMore.value = requestPage * pageSize >= total;
page.value = requestPage + 1;
} catch (error) {
console.error('加载延期订单失败', error);
} finally {
loading.value = false;
loaded.value = true;
}
};
const loadMore = () => {
if (!noMore.value) load(true);
};
const openDetail = (order) => {
uni.navigateTo({ url: `/pages/silence-orders/detail?id=${order.id}` });
};
onShow(() => load(false));
</script>
<style lang="scss" scoped>
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400rpx;
}
.empty-title {
font-size: 30rpx;
font-weight: 600;
}
.empty-desc {
margin-top: 12rpx;
font-size: 24rpx;
color: var(--text-tertiary);
}
.order-card {
margin-bottom: var(--space-md);
}
.order-header {
margin-bottom: var(--space-sm);
}
.order-name {
font-size: 28rpx;
font-weight: 600;
}
.order-status {
font-size: 24rpx;
color: var(--text-secondary);
&.status-completed {
color: var(--primary-dark);
}
&.status-pending_payment {
color: #ff9f0a;
}
&.status-closed,
&.status-rejected,
&.status-refunded {
color: var(--text-tertiary);
}
&.status-exception {
color: var(--danger);
}
}
.order-row {
padding: 6rpx 0;
font-size: 24rpx;
.label {
color: var(--text-tertiary);
}
.amount {
color: var(--danger);
}
}
.load-more {
padding: var(--space-lg);
font-size: 24rpx;
color: var(--text-tertiary);
text-align: center;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<view class="container silence-pay-page">
<view v-if="!payment" class="card empty-card">未找到待支付的延期订单,请在"我的延期订单"中查看</view>
<template v-else>
<view class="card">
<view class="pay-title">微信支付</view>
<view class="pay-amount">¥{{ formatMoney(payment.amount) }}</view>
<view class="pay-order">订单号:{{ payment.orderNo || '-' }}</view>
</view>
<view class="card">
<view class="method-title">扫码支付</view>
<view class="qr-wrap">
<u-qrcode :val="payment.content" :size="220" unit="px" :show-loading="false"></u-qrcode>
</view>
<view class="qr-desc">长按识别二维码,或截图后用微信"扫一扫"支付</view>
</view>
<view v-if="isLink" class="card">
<view class="method-title">浏览器支付</view>
<view class="qr-desc">复制链接后在手机浏览器中打开完成支付</view>
<view class="btn-apple btn-secondary copy-btn" @tap="copyLink">复制支付链接</view>
</view>
<view class="btn-apple btn-primary done-btn" @tap="finish">我已完成支付</view>
</template>
</view>
</template>
<script setup>
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { formatMoney } from '@/utils/display.js';
import { isPaymentLink } from '@/utils/silence.js';
import { getPendingSilencePayment } from '@/utils/silence-payment.js';
const payment = ref(null);
const isLink = computed(() => isPaymentLink(payment.value?.content));
const copyLink = () => {
uni.setClipboardData({
data: payment.value.content,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
});
};
// 返回上一页(延期页或订单详情),由上一页 onShow 查询订单状态
const finish = () => uni.navigateBack();
onShow(() => {
payment.value = getPendingSilencePayment();
});
</script>
<style lang="scss" scoped>
.silence-pay-page {
min-height: 100vh;
box-sizing: border-box;
}
.pay-title,
.method-title {
font-size: 30rpx;
font-weight: 600;
color: var(--text-primary);
}
.pay-amount {
margin-top: 16rpx;
font-size: 48rpx;
font-weight: 700;
color: var(--danger);
}
.pay-order {
margin-top: 8rpx;
font-size: 24rpx;
color: var(--text-tertiary);
}
.qr-wrap {
display: flex;
justify-content: center;
padding: 32rpx 0;
}
.qr-desc {
font-size: 24rpx;
color: var(--text-tertiary);
text-align: center;
}
.copy-btn,
.done-btn {
margin-top: 24rpx;
text-align: center;
}
.empty-card {
margin-top: 40rpx;
text-align: center;
color: var(--text-tertiary);
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<view class="container">
<view v-if="loading" class="loading-state">
<up-loading-icon text="加载中" size="30"></up-loading-icon>
</view>
<view v-else-if="records.length === 0" class="empty-state">
<view class="empty-title">暂无沉默期记录</view>
</view>
<view v-else class="timeline">
<view v-for="record in records" :key="record.id" class="card record-card">
<view class="record-header flex-row-sb">
<view class="record-name">{{ record.event_name || record.event_type }}</view>
<view class="record-time">{{ formatDateTime(record.created_at) }}</view>
</view>
<view v-if="record.before_deadline_at || record.after_deadline_at" class="record-row">
截止日期:{{ formatSilenceDate(record.before_deadline_at) }} → {{ formatSilenceDate(record.after_deadline_at) }}
</view>
<view v-if="record.period_days" class="record-row">沉默期天数:{{ record.period_days }} 天</view>
<view v-if="record.change_days" class="record-row">变化天数:{{ record.change_days > 0 ? "+" : "" }}{{ record.change_days }} 天</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { packageSilenceApi } from '@/api/index.js';
import { formatDateTime } from '@/utils/display.js';
import { formatSilenceDate } from '@/utils/silence.js';
const records = ref([]);
const loading = ref(true);
onLoad(async (query) => {
const packageUsageId = Number(query?.package_usage_id) || 0;
if (!packageUsageId) {
loading.value = false;
return;
}
try {
const data = await packageSilenceApi.getRecords(packageUsageId);
records.value = Array.isArray(data) ? data : [];
} catch (error) {
console.error('加载沉默期记录失败', error);
} finally {
loading.value = false;
}
});
</script>
<style lang="scss" scoped>
.loading-state,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 400rpx;
}
.empty-title {
font-size: 28rpx;
color: var(--text-tertiary);
}
.record-card {
margin-bottom: var(--space-md);
}
.record-header {
margin-bottom: var(--space-sm);
}
.record-name {
font-size: 28rpx;
font-weight: 600;
}
.record-time,
.record-row {
font-size: 24rpx;
color: var(--text-tertiary);
}
.record-row {
padding: 4rpx 0;
}
</style>

View File

@@ -29,6 +29,13 @@ function runtime(initial = {}) {
const timers = [];
const sessionStorage = new Map();
const api = {};
const props = vue.reactive({});
const payment = {
consumePendingPaymentRefresh: () => false,
PAYMENT_REFRESH_TARGETS: { HOME: 'home', PACKAGE_ORDER: 'package-order', ORDER_LIST: 'order-list', MY_WALLET: 'my-wallet' },
handlePaymentError() {}, isValidAlipayPaymentLink: () => false, isValidWechatPayConfig: () => false,
markPendingPaymentRefresh() {}, openAlipayPayment: async () => {}, showPaymentToast() {}, wechatH5Pay: async () => {}
};
let inWechat = false;
let sdkInit = async () => {};
let options = {};
@@ -40,6 +47,8 @@ function runtime(initial = {}) {
request: request => { requests.push(request); },
showToast: value => calls.push({ kind: 'toast', ...value }),
showModal: value => calls.push({ kind: 'modal', ...value }),
showLoading: value => calls.push({ kind: 'loading', ...value }),
hideLoading: () => calls.push({ kind: 'hideLoading' }),
reLaunch: value => calls.push({ kind: 'reLaunch', ...value }),
redirectTo: value => calls.push({ kind: 'redirectTo', ...value }),
navigateTo: value => calls.push({ kind: 'navigateTo', ...value }),
@@ -53,7 +62,7 @@ function runtime(initial = {}) {
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: () => () => {}
defineProps: () => props, defineEmits: () => () => {}, defineExpose() {}
});
const cache = new Map();
const synthetic = (key, values) => {
@@ -81,13 +90,14 @@ function runtime(initial = {}) {
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)
onShow: cb => shown.push(cb), onHide: cb => hidden.push(cb), onUnload: cb => unmounted.push(cb),
onLoad() {}, onReachBottom() {}
});
if (specifier === '@/api/index.js') return synthetic('api-index', {
...api, deviceApi: {}, hasActiveOrPendingPackage: async () => true
packageSilenceApi: {}, ...api, deviceApi: {}, hasActiveOrPendingPackage: async () => true
});
if (specifier === '@/utils/payment.js') return synthetic('payment', {
consumePendingPaymentRefresh: () => false, PAYMENT_REFRESH_TARGETS: { HOME: 'home' }
...payment, consumePendingPaymentRefresh: (...args) => payment.consumePendingPaymentRefresh(...args)
});
if (specifier === '@/utils/wxsdk.js') return synthetic('wxsdk', {
initWxConfig: () => sdkInit(), wxScan: async () => {}, isInWechat: () => inWechat
@@ -106,7 +116,7 @@ function runtime(initial = {}) {
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,
return { storage, sessionStorage, context, calls, requests, mounted, shown, hidden, unmounted, timers, api, props, payment, load, reply,
setOptions: value => { options = value; }, tick: () => vue.nextTick(),
setWechatSdk: init => { inWechat = true; sdkInit = init; } };
}
@@ -727,3 +737,239 @@ test('401 clears phone/reminder state, but late 401 cannot log out a new session
assert.equal(user.state.assetPhoneBound, null);
assert.equal(r.storage.has('customerAssetPhoneState'), false);
});
test('asset info ignores deprecated protection values and preserves actual polling states', async () => {
const r = runtime();
const { normalizeAssetInfo } = await r.load('api/modules/asset.js');
for (const protection of [undefined, 'none', 'protecting']) {
const input = { enable_polling: false, network_status: 0, cards: [{ enable_polling: false }] };
if (protection !== undefined) input.device_protect_status = protection;
const result = normalizeAssetInfo(input);
assert.equal(Object.hasOwn(result, 'device_protect_status'), false);
assert.equal(result.enable_polling, false);
assert.equal(result.network_status, 0);
assert.equal(result.cards[0].enable_polling, false);
}
assert.equal(normalizeAssetInfo({}).enable_polling, undefined);
for (const path of ['pages/index/index.vue', 'components/UserInfoCard.vue', 'components/DeviceStatusCard.vue', 'components/TrafficCard.vue']) {
assert.doesNotMatch(readFileSync(resolve(root, path), 'utf8'), /device_protect_status|保护期/);
}
});
test('fresh asset snapshots replace polling cache and late responses cannot roll it back', async () => {
const r = runtime();
const { assetApi } = await r.load('api/modules/asset.js');
const asset = (await r.load('store/asset.js')).useAssetStore();
asset.setAssetInfo({ enable_polling: false, current_package_id: 1 });
const old = assetApi.getInfo('A');
const fresh = assetApi.getInfo('A');
r.reply(r.requests[1], { enable_polling: true, current_package_id: 2, cards: [{ enable_polling: true }] });
await fresh;
r.reply(r.requests[0], { enable_polling: false, current_package_id: 1 });
await old;
assert.equal(asset.state.assetInfo.enable_polling, true);
assert.equal(asset.state.assetInfo.current_package_id, 2);
assert.equal(asset.state.assetInfo.cards[0].enable_polling, true);
const missing = assetApi.getInfo('A');
r.reply(r.requests[2], { current_package_id: 3 }); await missing;
assert.equal(asset.state.assetInfo.enable_polling, undefined);
assert.equal(asset.state.assetInfo.cards, undefined);
const late = assetApi.getInfo('A');
(await r.load('store/user.js')).useUserStore().setIdentifier('B');
r.reply(r.requests[3], { enable_polling: true }); await late;
assert.equal(asset.state.assetInfo, null);
const b = assetApi.getInfo('B');
r.reply(r.requests[4], { enable_polling: false }); await b;
assert.equal(asset.state.assetInfo.enable_polling, false);
});
test('traffic uses the same fresh asset snapshot and clears expired or missing package data', async () => {
const r = runtime();
const card = await r.load('components/TrafficCard.vue', ['usedMB', 'totalMB', 'remainMB']);
assert.equal(card.totalMB.value, 0);
r.props.assetInfo = { current_package_usage_id: 1, real_used_mb: 100, real_total_mb: 1000 };
assert.equal(card.usedMB.value, 100);
assert.equal(card.remainMB.value, 900);
r.props.assetInfo = { current_package_usage_id: 2, real_used_mb: 200, virtual_used_mb: 150, enable_virtual_data: true, real_total_mb: 2000 };
assert.equal(card.usedMB.value, 150);
assert.equal(card.totalMB.value, 2000);
assert.equal(card.remainMB.value, 1850);
r.props.assetInfo = { real_used_mb: 200, real_total_mb: 2000 };
assert.equal(card.usedMB.value, 0);
assert.equal(card.totalMB.value, 0);
r.props.assetInfo = null;
assert.equal(card.remainMB.value, 0);
assert.equal(r.requests.length, 0);
});
test('returning home reloads actual polling, network and expiry with one asset request per entry', async () => {
const r = runtime();
r.api.assetApi = (await r.load('api/modules/asset.js')).assetApi;
r.api.notificationApi = { getUnreadCount: async () => ({ count: 0 }) };
r.payment.consumePendingPaymentRefresh = () => true;
const page = await r.load('pages/index/index.vue', ['deviceInfo']);
// 首页还会在后台查套餐历史做沉默期提醒;这里只统计资产信息请求。
const infoRequests = () => r.requests.filter(request => !String(request.url).includes('package-history'));
const first = r.shown[0]();
assert.equal(infoRequests().length, 1);
r.reply(infoRequests()[0], { enable_polling: false, network_status: 0, estimated_final_expires_at: '2026-10-26T00:00:00Z' });
await first;
const asset = (await r.load('store/asset.js')).useAssetStore();
assert.equal(asset.state.assetInfo.enable_polling, false);
r.hidden[0]();
const next = r.shown[0]();
assert.equal(infoRequests().length, 2);
r.reply(infoRequests()[1], { enable_polling: true, network_status: 1, estimated_final_expires_at: '2026-11-26T00:00:00Z' });
await next;
assert.equal(asset.state.assetInfo.enable_polling, true);
assert.equal(page.deviceInfo.network_status, 1);
assert.equal(page.deviceInfo.expireDate, '2026-11-26');
});
test('home rejects an old asset request after a new refresh or hide-and-return', async () => {
const r = runtime();
r.api.notificationApi = {};
const old = deferred();
const fresh = deferred();
let reads = 0;
r.api.assetApi = { getInfo: () => (++reads === 1 ? old.promise : fresh.promise) };
const page = await r.load('pages/index/index.vue', ['loadAssetInfo', 'deviceInfo']);
const a = page.loadAssetInfo();
r.hidden[0]();
const b = page.loadAssetInfo();
fresh.resolve({ network_status: 0, current_package: '新套餐' }); await b;
old.resolve({ network_status: 1, current_package: '旧套餐' }); await a;
assert.equal(page.deviceInfo.network_status, 0);
assert.equal(page.deviceInfo.packageName, '新套餐');
});
test('sync acceptance is not completion and never optimistically resumes the network', async () => {
for (const accepted of [true, false, undefined, 'false']) {
const r = runtime();
r.api.notificationApi = {};
let reads = 0;
r.api.assetApi = {
refresh: async () => ({ accepted, refresh_type: 'device' }),
getInfo: async () => { reads++; return { network_status: 0 }; }
};
const page = await r.load('pages/index/index.vue', ['onSync', 'deviceInfo', 'homeActive']);
page.homeActive.value = true;
await page.onSync();
const modal = r.calls.find(call => call.kind === 'modal');
assert.match(modal.content, /受理不代表.*停复机已执行完成/);
modal.success({ confirm: true });
await new Promise(resolve => setImmediate(resolve));
const toast = r.calls.find(call => call.kind === 'toast');
assert.equal(toast.icon, 'none');
assert.doesNotMatch(toast.title, /同步成功|复机成功|停机成功/);
assert.equal(reads, accepted === true ? 1 : 0);
if (accepted === true) {
assert.match(toast.title, /已受理/);
assert.equal(page.deviceInfo.network_status, 0);
await r.timers[0]();
assert.equal(reads, 2);
(await r.load('store/user.js')).useUserStore().setIdentifier('B');
await r.timers[0]();
assert.equal(reads, 2);
}
}
});
test('package page reloads asset state on every entry, after wallet payment and after wechat payment', async () => {
for (const method of ['wallet', 'wechat']) {
const r = runtime();
let polling = false;
let reads = 0;
r.api.assetApi = {
getInfo: async () => { reads++; return { enable_polling: polling, allowed_payment_methods: [method] }; },
getPackages: async () => ({ packages: [] })
};
r.api.isAssetRealNameCompleted = () => true;
r.api.walletApi = { getDetail: async () => ({ balance: 0 }) };
r.api.orderApi = {
create: async () => ({ order: { order_id: 12, payment_status: 2 } }),
pay: async () => ({}), getDetail: async () => ({ payment_status: 2 })
};
const page = await r.load('pages/package-order/package-order.vue', ['syncPackagePageState', 'confirmPay', 'currentPackage', 'paymentMethod', 'assetInfo', 'handleWechatPay']);
await page.syncPackagePageState();
assert.equal(page.assetInfo.value.enable_polling, false);
polling = true;
if (method === 'wallet') {
page.currentPackage.value = { package_id: 1 };
page.paymentMethod.value = 'wallet';
await page.confirmPay();
} else {
await page.handleWechatPay({}, false, 12);
}
assert.equal(page.assetInfo.value.enable_polling, true);
assert.equal(reads, 2);
polling = false;
await r.timers[0]();
await new Promise(resolve => setImmediate(resolve));
assert.equal(page.assetInfo.value.enable_polling, false);
polling = true;
r.shown[0]();
await new Promise(resolve => setImmediate(resolve));
assert.equal(page.assetInfo.value.enable_polling, true);
assert.equal(reads, 4);
}
});
test('order completion and payment return reread actual asset state, not a presumed polling value', async () => {
const r = runtime();
let reads = 0;
r.api.assetApi = { getInfo: async () => { reads++; return { enable_polling: false }; } };
r.api.orderApi = { getList: async () => ({ items: [] }) };
const page = await r.load('pages/order-list/order-list.vue', ['refreshOrderState']);
await page.refreshOrderState();
assert.equal(reads, 1);
r.shown[0]();
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 2);
r.payment.consumePendingPaymentRefresh = () => true;
r.shown[0]();
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 3);
await r.timers[1]();
assert.equal(reads, 4);
});
test('home forces the expired silence reminder on every return until extended, above other popups, and it cannot be closed', async () => {
const r = runtime();
r.api.assetApi = {
getInfo: async () => ({ identifier: 'A' }),
getPackageHistory: async () => ({ items: [{
package_usage_id: 44961, package_name: '测试1111',
silence: { silence_status: 'expired', extendable: true, silence_deadline_at: '2026-11-06T23:59:59+08:00' },
children: []
}] })
};
let pendingOrderNo = '';
r.api.packageSilenceApi = { getEligibility: async () => ({ can_extend: !pendingOrderNo, pending_order_no: pendingOrderNo }) };
r.api.notificationApi = { getUnreadCount: async () => ({ count: 0 }), getList: async () => ({ items: [] }) };
const page = await r.load('pages/index/index.vue', ['silenceReminder', 'silenceReminderShow', 'onPopupCandidateClose', 'openSilenceReminderAction', 'closeSilenceReminder']);
const settle = async () => { for (let i = 0; i < 6; i++) await r.tick(); };
await r.shown[0](); await settle();
// 已过期待延期为强制弹窗:不等运营弹窗关闭,立即弹出
assert.equal(page.silenceReminderShow.value, true);
assert.equal(page.silenceReminder.value.kind, 'expired');
// 不能关闭
page.closeSilenceReminder();
assert.equal(page.silenceReminderShow.value, true);
assert.equal(page.silenceReminder.value.pendingOrderNo, undefined);
page.openSilenceReminderAction();
assert.equal(page.silenceReminderShow.value, false);
assert.equal(r.calls.at(-1).kind, 'navigateTo');
assert.equal(r.calls.at(-1).url, '/pages/silence-extension/silence-extension?package_usage_id=44961');
// 已过期未支付:同一次登录内每次回到首页都要再弹
r.hidden[0](); await r.shown[0](); await settle();
assert.equal(page.silenceReminderShow.value, true);
page.closeSilenceReminder();
assert.equal(page.silenceReminderShow.value, true);
// 重新登录(凭证变化)后再次提醒;有未支付延期订单时提示去支付或作废
pendingOrderNo = 'SE20261107200004975908';
r.storage.set('token', 'session-B');
r.hidden[0](); await r.shown[0](); await settle();
assert.equal(page.silenceReminderShow.value, true);
assert.equal(page.silenceReminder.value.pendingOrderNo, 'SE20261107200004975908');
});

View File

@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSilenceGuard, isSilenceGuardAllowed, pagePathOf, silenceExtensionUrl } from '../utils/silence-guard.js';
const expiredItems = [{ package_usage_id: 45016, package_name: '套餐', silence: { silence_status: 'expired', extendable: true } }];
const okItems = [{ package_usage_id: 1, silence: { silence_status: 'ended' } }];
const setup = (items) => {
let clock = 0;
const calls = [];
const state = { items, session: { token: 't', identifier: 'A' } };
const guard = createSilenceGuard({
getSession: () => state.session,
loadPackageHistory: async (identifier) => { calls.push(identifier); return { items: state.items }; },
now: () => clock
});
return { guard, calls, state, advance: (ms) => { clock += ms; } };
};
test('只有绑定手机号、首页、登录授权与延期支付相关页面不拦截', () => {
for (const url of ['/pages/bind/bind?fromLogin=true', 'pages/index/index', '/pages/login/login', '/pages/silence-extension/silence-extension?package_usage_id=1',
'/pages/silence-payment/silence-payment', '/pages/silence-orders/detail?id=3', '/pages/alipay-payment/alipay-payment']) {
assert.equal(isSilenceGuardAllowed(url), true, url);
}
for (const url of ['/pages/change-phone/change-phone', '/pages/package-order/package-order', '/pages/switch/switch', '/pages/order-list/order-list',
'/pages/my-wallet/my-wallet', '/pages/device-exchange/device-exchange', '/pages/asset-package-history/asset-package-history', '/pages/notifications/notifications']) {
assert.equal(isSilenceGuardAllowed(url), false, url);
}
assert.equal(pagePathOf('/pages/a/b?x=1'), 'pages/a/b');
assert.equal(silenceExtensionUrl({ packageUsageId: 45016 }), '/pages/silence-extension/silence-extension?package_usage_id=45016');
});
test('沉默期已过期待延期时拦截,且每次都重新查询,延期完成后立即放行', async () => {
const { guard, calls, state } = setup(expiredItems);
assert.equal(guard.isKnownPass(), false);
assert.equal((await guard.resolveBlock()).packageUsageId, 45016);
assert.equal((await guard.resolveBlock()).packageUsageId, 45016);
assert.equal(calls.length, 2);
state.items = okItems;
assert.equal(await guard.resolveBlock(), null);
assert.equal(guard.isKnownPass(), true);
});
test('不需要延期时缓存放行结果 5 分钟,换资产或换登录重新检查', async () => {
const { guard, calls, state, advance } = setup(okItems);
assert.equal(await guard.resolveBlock(), null);
assert.equal(guard.isKnownPass(), true);
await guard.resolveBlock();
assert.equal(calls.length, 1);
advance(5 * 60 * 1000);
assert.equal(guard.isKnownPass(), false);
state.session = { token: 't', identifier: 'B' };
assert.equal(guard.isKnownPass(), false);
state.items = expiredItems;
assert.equal((await guard.resolveBlock()).packageUsageId, 45016);
});
test('未登录不拦截;查询失败放行但不缓存', async () => {
const anon = createSilenceGuard({ getSession: () => ({ token: '', identifier: '' }), loadPackageHistory: async () => { throw new Error('x'); } });
assert.equal(anon.isKnownPass(), true);
assert.equal(await anon.resolveBlock(), null);
const origError = console.error; console.error = () => {};
const failing = createSilenceGuard({ getSession: () => ({ token: 't', identifier: 'A' }), loadPackageHistory: async () => { throw new Error('x'); } });
assert.equal(await failing.resolveBlock(), null);
assert.equal(failing.isKnownPass(), false);
console.error = origError;
});
test('不可延期的过期套餐不拦截(无法办理延期)', async () => {
const { guard } = setup([{ package_usage_id: 2, silence: { silence_status: 'expired', extendable: false } }]);
assert.equal(await guard.resolveBlock(), null);
});

115
tests/silence.test.mjs Normal file
View File

@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createRequestIdKeeper,
getSilenceErrorMessage,
getSilencePaymentOptions,
getSilenceReasonText,
getSilenceStatusClass,
isPaymentLink,
isSilenceFeatureDisabled,
isSilenceOrderFinal,
pickSilenceReminder,
silenceReminderKey,
silenceReminderRepeats,
isSilenceReminderBlocking,
validateExtensionDays
} from '../utils/silence.js';
test('延期支付方式只取 wechat、alipay 与资产允许方式的交集,支付宝优先', () => {
assert.deepEqual(getSilencePaymentOptions(['wechat', 'wallet', 'alipay']).map((item) => item.value), ['alipay', 'wechat']);
assert.deepEqual(getSilencePaymentOptions(['wallet']), []);
assert.deepEqual(getSilencePaymentOptions(undefined), []);
assert.deepEqual(getSilencePaymentOptions(['alipay', 'wechat'], true).map((item) => item.value), ['wechat', 'alipay']);
});
test('延期天数校验:整数且在 min~max 内', () => {
assert.equal(validateExtensionDays('30', 30, 180), '');
assert.equal(validateExtensionDays(180, 30, 180), '');
assert.equal(validateExtensionDays('29', 30, 180), '延期天数需在 30~180 天之间');
assert.equal(validateExtensionDays('181', 30, 180), '延期天数需在 30~180 天之间');
assert.equal(validateExtensionDays('12.5', 30, 180), '请输入整数天数');
assert.equal(validateExtensionDays('', 30, 180), '请输入整数天数');
});
test('request_id:同一天数与支付方式复用,任一变化或重置后生成新的', () => {
let seq = 0;
const keeper = createRequestIdKeeper(() => `id-${++seq}`);
assert.equal(keeper.get(30, 'alipay'), 'id-1');
assert.equal(keeper.get(30, 'alipay'), 'id-1');
assert.equal(keeper.get(60, 'alipay'), 'id-2');
assert.equal(keeper.get(60, 'wechat'), 'id-3');
keeper.reset();
assert.equal(keeper.get(60, 'wechat'), 'id-4');
});
test('沉默期错误码转客户提示', () => {
assert.equal(getSilenceErrorMessage({ code: 1266, data: { min_days: 30, max_days: 180 } }), '延期天数需在 30~180 天之间');
assert.equal(getSilenceErrorMessage({ code: 1265, data: { min_months: 1, max_months: 6 } }), '超过可延期期限,可延期 1~6 个月');
assert.equal(getSilenceErrorMessage({ code: 1263, data: { reason: 'not_expired' } }), '套餐沉默期尚未过期,暂不需要延期');
assert.equal(getSilenceErrorMessage({ code: 1267 }), '免费延期次数已用完');
assert.equal(getSilenceErrorMessage({ code: 1271 }), '已有未完成的延期订单,请先完成支付或等待其关闭');
assert.equal(getSilenceErrorMessage({ code: 1274 }), '延期功能暂未开放');
assert.equal(getSilenceErrorMessage({ code: 1001, msg: '参数错误' }), '参数错误');
assert.equal(getSilenceErrorMessage({ paymentResultUnknown: true }), '网络异常,请稍后在"我的延期订单"中查看结果');
assert.equal(getSilenceErrorMessage({}, '兜底'), '兜底');
assert.equal(isSilenceFeatureDisabled({ code: 1274 }), true);
assert.equal(isSilenceFeatureDisabled({ code: 1266 }), false);
});
test('资格原因、状态样式、订单终态与支付链接判断', () => {
assert.equal(getSilenceReasonText('extension_disabled'), '该套餐不支持付费延期,请联系客服');
assert.equal(getSilenceReasonText('unknown'), '当前暂不能办理延期,请联系客服');
assert.equal(getSilenceStatusClass('expired'), 'silence-expired');
assert.equal(getSilenceStatusClass('other'), '');
assert.equal(isSilenceOrderFinal('completed'), true);
assert.equal(isSilenceOrderFinal('pending_payment'), false);
assert.equal(isPaymentLink('https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?x=1'), true);
assert.equal(isPaymentLink('weixin://wxpay/bizpayurl?pr=abc'), false);
assert.equal(isPaymentLink(''), false);
});
test('首页沉默期提醒:优先已过期可延期,其次 7 天内到期的待实名,含加油包子节点', () => {
const now = Date.parse('2026-11-07T10:00:00+08:00');
const node = (id, silence, children = []) => ({ package_usage_id: id, package_name: `套餐${id}`, silence, children });
assert.equal(pickSilenceReminder([], now), null);
assert.equal(pickSilenceReminder([node(1, null)], now), null);
assert.equal(pickSilenceReminder([node(1, { silence_status: 'expired', extendable: false })], now), null);
const expired = pickSilenceReminder([
node(1, { silence_status: 'waiting', silence_deadline_at: '2026-11-09T23:59:59+08:00' }),
node(2, null, [node(3, { silence_status: 'expired', extendable: true, silence_deadline_at: '2026-11-06T23:59:59+08:00' })])
], now);
assert.equal(expired.kind, 'expired');
assert.equal(expired.packageUsageId, 3);
const waiting = pickSilenceReminder([
node(4, { silence_status: 'waiting', silence_deadline_at: '2026-11-20T23:59:59+08:00' }),
node(5, { silence_status: 'waiting', silence_deadline_at: '2026-11-12T23:59:59+08:00' }),
node(6, { silence_status: 'waiting', silence_deadline_at: '2026-11-09T23:59:59+08:00' })
], now);
assert.equal(waiting.kind, 'waiting');
assert.equal(waiting.packageUsageId, 6);
assert.equal(waiting.remainingDays, 3);
assert.equal(pickSilenceReminder([node(7, { silence_status: 'ended' })], now), null);
const keyA = silenceReminderKey('A', { packageUsageId: 3, kind: 'expired' }, 'token-1');
assert.match(keyA, /^silence_reminder:A:3:expired:/);
assert.equal(keyA, silenceReminderKey('A', { packageUsageId: 3, kind: 'expired' }, 'token-1'));
assert.notEqual(keyA, silenceReminderKey('A', { packageUsageId: 3, kind: 'expired' }, 'token-2'));
});
test('只有已过期未延期的提醒每次回首页都弹,待实名提醒每次登录一次', () => {
assert.equal(silenceReminderRepeats({ kind: 'expired' }), true);
assert.equal(silenceReminderRepeats({ kind: 'expired', pendingOrderNo: 'SE1' }), true);
assert.equal(silenceReminderRepeats({ kind: 'waiting' }), false);
assert.equal(silenceReminderRepeats(null), false);
});
test('已过期的沉默期提醒为强制弹窗,临近截止的可关闭', () => {
assert.equal(isSilenceReminderBlocking({ kind: 'expired' }), true);
assert.equal(isSilenceReminderBlocking({ kind: 'expired', pendingOrderNo: 'SE1' }), true);
assert.equal(isSilenceReminderBlocking({ kind: 'waiting' }), false);
assert.equal(isSilenceReminderBlocking(null), false);
});

144
utils/silence-guard.js Normal file
View File

@@ -0,0 +1,144 @@
/**
* 沉默期强制延期拦截:当前资产有"沉默期已过期待延期"的套餐时,
* 除首页(强制延期弹窗)、登录/授权、绑定手机号、延期与支付相关页面外,其他页面一律不能进入,
* 统一引导到延期页。
*/
import { isSilenceReminderBlocking, pickSilenceReminder } from './silence.js';
export const SILENCE_EXTENSION_PAGE = '/pages/silence-extension/silence-extension';
// 不拦截的页面:登录授权、绑定手机号、首页(自带强制弹窗)、延期办理与支付相关页面
export const SILENCE_GUARD_ALLOWED_PAGES = Object.freeze([
'pages/index/index',
'pages/login/login',
'pages/auth/auth',
'pages/error/error',
'pages/bind/bind',
'pages/silence-extension/silence-extension',
'pages/silence-payment/silence-payment',
'pages/silence-orders/silence-orders',
'pages/silence-orders/detail',
'pages/silence-records/silence-records',
'pages/alipay-payment/alipay-payment'
]);
// 未被拦截的结果缓存时长;被拦截时每次都重新查询,延期完成后可立即放行
const PASS_CACHE_MS = 5 * 60 * 1000;
const NAVIGATION_METHODS = ['navigateTo', 'redirectTo', 'reLaunch', 'switchTab'];
const CHECKED_FLAG = '__silenceGuardChecked';
export const pagePathOf = (url) => String(url || '').split('?')[0].replace(/^\/+/, '');
export const isSilenceGuardAllowed = (url) => SILENCE_GUARD_ALLOWED_PAGES.includes(pagePathOf(url));
export const silenceExtensionUrl = (reminder) =>
`${SILENCE_EXTENSION_PAGE}?package_usage_id=${reminder.packageUsageId}`;
/**
* 创建拦截器核心(依赖可注入,便于测试)。
* deps.getSession(): { token, identifier }
* deps.loadPackageHistory(identifier): Promise<{ items }>
* deps.now(): number
*/
export const createSilenceGuard = ({ getSession, loadPackageHistory, now = () => Date.now() }) => {
let passCache = null;
let pending = null;
const sessionKey = () => {
const { token, identifier } = getSession();
const id = String(identifier || '').trim();
return token && id ? { key: `${token}|${id}`, identifier: id } : null;
};
/** 命中"未拦截"缓存时同步返回 true,可直接放行 */
const isKnownPass = () => {
const session = sessionKey();
if (!session) return true;
return !!passCache && passCache.key === session.key && now() - passCache.at < PASS_CACHE_MS;
};
/** 查询当前资产是否需要强制延期;返回需要延期的提醒,或 null(放行) */
const resolveBlock = () => {
const session = sessionKey();
if (!session) return Promise.resolve(null);
if (isKnownPass()) return Promise.resolve(null);
if (pending?.key === session.key) return pending.promise;
const promise = loadPackageHistory(session.identifier)
.then((data) => {
const reminder = pickSilenceReminder(data?.items, now());
if (isSilenceReminderBlocking(reminder)) {
passCache = null;
return reminder;
}
passCache = { key: session.key, at: now() };
return null;
})
.catch((error) => {
// 查询失败不锁死用户,放行但不缓存,下次跳转再查
console.error('沉默期拦截检查失败', error);
return null;
})
.finally(() => {
if (pending?.promise === promise) pending = null;
});
pending = { key: session.key, promise };
return promise;
};
const reset = () => {
passCache = null;
pending = null;
};
return { isKnownPass, resolveBlock, reset };
};
const notifyBlocked = () => {
uni.showToast({ title: '套餐沉默期已过期,请先办理延期', icon: 'none', duration: 2000 });
};
/**
* 安装全局拦截:
* 1. 拦截 navigateTo/redirectTo/reLaunch/switchTab:目标页不在白名单时先检查,需要延期则改去延期页;
* 2. 页面显示时(直接打开链接、刷新、浏览器后退)再检查一次,需要延期则替换为延期页。
*/
export const installSilenceGuard = (app, guard) => {
NAVIGATION_METHODS.forEach((method) => {
uni.addInterceptor(method, {
invoke(args) {
if (args && args[CHECKED_FLAG]) {
delete args[CHECKED_FLAG];
return;
}
if (isSilenceGuardAllowed(args?.url) || guard.isKnownPass()) return;
guard.resolveBlock().then((reminder) => {
if (reminder) {
notifyBlocked();
uni.navigateTo({ url: silenceExtensionUrl(reminder) });
return;
}
uni[method]({ ...args, [CHECKED_FLAG]: true });
});
return false;
}
});
});
app.mixin({
onShow() {
const route = this.$page?.route || this.route;
if (!route || isSilenceGuardAllowed(route)) return;
const known = guard.isKnownPass();
if (known) return;
uni.showLoading({ title: '加载中', mask: true });
guard.resolveBlock().then((reminder) => {
uni.hideLoading();
const pages = getCurrentPages();
const current = pages[pages.length - 1];
if (!reminder || (current?.route && current.route !== route)) return;
notifyBlocked();
uni.redirectTo({ url: silenceExtensionUrl(reminder), [CHECKED_FLAG]: true });
});
}
});
};

54
utils/silence-payment.js Normal file
View File

@@ -0,0 +1,54 @@
import { isValidWechatPayConfig, markPendingPaymentRefresh, openAlipayPayment, wechatH5Pay } from '@/utils/payment.js';
import { isPaymentLink } from '@/utils/silence.js';
export const SILENCE_REFRESH_TARGET = {
EXTENSION: 'silence-extension',
ORDER_DETAIL: 'silence-order-detail'
};
const SILENCE_PAYMENT_DATA_KEY = 'pending_silence_payment_data';
export const getPendingSilencePayment = () => uni.getStorageSync(SILENCE_PAYMENT_DATA_KEY) || null;
const isInWechat = () => typeof navigator !== 'undefined' && /micromessenger/i.test(navigator.userAgent);
/**
* 用延期订单返回的 qr_content 拉起支付。
* - 支付宝:走现有支付宝支付页(浏览器打开链接 / 截图扫码)。
* - 微信:后端给延期订单返回的是微信 H5 支付链接或扫码内容,不是公众号支付参数。
* 在微信外且为链接时直接跳转;其余情况进入延期支付页展示二维码与链接。
*/
export const openSilencePayment = (order, refreshTarget) => {
// 微信内直接支付(JSAPI):与套餐购买一致,用 pay_config 拉起微信支付
if (order?.pay_config && isValidWechatPayConfig(order.pay_config)) {
return wechatH5Pay(order.pay_config).then(() => ({ paid: true }));
}
const content = String(order?.qr_content || '').trim();
if (!content) return Promise.reject(new Error('支付内容为空'));
if (order.payment_method === 'alipay') {
return openAlipayPayment(content, refreshTarget);
}
markPendingPaymentRefresh(refreshTarget);
uni.setStorageSync(SILENCE_PAYMENT_DATA_KEY, {
orderId: order.id,
orderNo: order.order_no,
amount: order.amount,
content,
timestamp: Date.now()
});
if (isPaymentLink(content) && !isInWechat() && typeof window !== 'undefined') {
window.location.href = content;
return Promise.resolve({ redirecting: true });
}
return new Promise((resolve, reject) => {
uni.navigateTo({
url: '/pages/silence-payment/silence-payment',
success: () => resolve({ redirecting: true }),
fail: () => reject(new Error('打开支付页失败'))
});
});
};

188
utils/silence.js Normal file
View File

@@ -0,0 +1,188 @@
// 沉默期展示、延期下单的纯函数工具(不依赖 uni,便于测试)。
export const SILENCE_PAYMENT_METHODS = ['alipay', 'wechat'];
export const SILENCE_PAYMENT_LABELS = {
alipay: '支付宝支付',
wechat: '微信支付'
};
// 资格接口 reason:付费延期不能办理的原因
const REASON_TEXT = {
not_expired: '套餐沉默期尚未过期,暂不需要延期',
no_silence: '该套餐没有沉默期,无需延期',
extension_disabled: '该套餐不支持付费延期,请联系客服',
no_policy: '暂未开放延期,请联系客服',
exceeds_limit: '已超过可延期期限,请联系客服',
refunded: '该套餐已退款,不能延期',
order_pending: '已有未完成的延期订单,请先完成或等待其关闭',
activated: '套餐已激活,无需延期',
adjust_pending: '沉默期调整审批中,请稍后再试'
};
export const getSilenceReasonText = (reason) => REASON_TEXT[reason] || '当前暂不能办理延期,请联系客服';
const rangeText = (data, minKey, maxKey, unit) => {
const min = Number(data?.[minKey]);
const max = Number(data?.[maxKey]);
if (Number.isFinite(min) && Number.isFinite(max) && max > 0) return `${min}~${max} ${unit}`;
return '';
};
/**
* 沉默期错误码转客户提示;不是沉默期错误码时返回后端 msg 或兜底文案。
* error 为 request.js reject 的原始响应(含 code、msg、data)。
*/
export const getSilenceErrorMessage = (error, fallback = '操作失败,请稍后重试') => {
const code = Number(error?.code);
const data = error?.data || {};
switch (code) {
case 1263:
return data.reason ? getSilenceReasonText(data.reason) : '套餐当前不需要延期';
case 1264:
return '该套餐不支持付费延期,请联系客服';
case 1265: {
const range = rangeText(data, 'min_months', 'max_months', '个月');
return range ? `超过可延期期限,可延期 ${range}` : '超过可延期期限,请联系客服';
}
case 1266: {
const range = rangeText(data, 'min_days', 'max_days', '天');
return range ? `延期天数需在 ${range}之间` : '延期天数不在允许范围';
}
case 1267:
return '免费延期次数已用完';
case 1268:
return '沉默期已过期的套餐不能退款';
case 1270:
return '暂未开放延期,请联系客服';
case 1271:
return '已有未完成的延期订单,请先完成支付或等待其关闭';
case 1274:
return '延期功能暂未开放';
default:
if (error?.paymentResultUnknown) return '网络异常,请稍后在"我的延期订单"中查看结果';
return error?.msg || error?.message || fallback;
}
};
export const isSilenceFeatureDisabled = (error) => Number(error?.code) === 1274;
/** 延期可用支付方式:资产允许的支付方式与 wechat、alipay 取交集;微信内打开时微信优先,否则支付宝优先 */
export const getSilencePaymentOptions = (allowedMethods, preferWechat = false) => {
const allowed = Array.isArray(allowedMethods) ? allowedMethods : [];
const order = preferWechat ? ['wechat', 'alipay'] : SILENCE_PAYMENT_METHODS;
return order
.filter((method) => allowed.includes(method))
.map((value) => ({ value, label: SILENCE_PAYMENT_LABELS[value] }));
};
/** 天数校验:整数且在 [min, max];返回错误文案,合法返回空串 */
export const validateExtensionDays = (days, min, max) => {
const value = Number(days);
if (days === '' || days === null || days === undefined || !Number.isInteger(value)) {
return '请输入整数天数';
}
const lower = Number(min) || 1;
const upper = Number(max) || 0;
if (value < lower || (upper > 0 && value > upper)) {
return upper > 0 ? `延期天数需在 ${lower}~${upper} 天之间` : `延期天数不能少于 ${lower} 天`;
}
return '';
};
/** 下单幂等键:时间戳 + 随机串,最长 64 */
export const createSilenceRequestId = () => {
const random = Math.random().toString(36).slice(2, 12);
return `h5-silence-${Date.now()}-${random}`.slice(0, 64);
};
/**
* 管理一次提交的 request_id:同一组 (天数, 支付方式) 的重试复用,任一变化后生成新的。
*/
export const createRequestIdKeeper = (generate = createSilenceRequestId) => {
let current = null;
return {
get(days, paymentMethod) {
const key = `${days}|${paymentMethod}`;
if (!current || current.key !== key) current = { key, id: generate() };
return current.id;
},
reset() {
current = null;
}
};
};
/** 沉默期状态颜色 */
export const getSilenceStatusClass = (status) => ({
waiting: 'silence-waiting',
expired: 'silence-expired',
ended: 'silence-ended'
}[status] || '');
/** 延期订单状态是否已结束(不会再变化) */
export const isSilenceOrderFinal = (status) => ['completed', 'closed', 'rejected', 'refunded'].includes(status);
/** qr_content 是否为可直接跳转的支付链接 */
export const isPaymentLink = (content) => /^https?:\/\//i.test(String(content || '').trim());
/** 沉默期流水 / 订单日期:只取到日 */
export const formatSilenceDate = (value) => (value ? String(value).replace('T', ' ').slice(0, 10) : '-');
const DAY_MS = 86400000;
/** 待实名时,截止日前多少天开始提醒 */
export const SILENCE_WAITING_REMIND_DAYS = 7;
const flattenNodes = (items = []) => items.flatMap((node) => [node, ...flattenNodes(node?.children || [])]);
/**
* 从套餐历史中挑出首页要弹的沉默期提醒:
* - 优先"沉默期已过期且可延期"(kind = expired,带延期入口);
* - 其次"待实名且 N 天内到截止日"(kind = waiting),截止越近越优先。
* 没有符合条件的返回 null。now 便于测试注入。
*/
export const pickSilenceReminder = (items, now = Date.now()) => {
const candidates = flattenNodes(Array.isArray(items) ? items : [])
.filter((node) => node?.silence && node.package_usage_id);
const expired = candidates.find((node) => node.silence.silence_status === 'expired' && node.silence.extendable);
if (expired) {
return { kind: 'expired', packageUsageId: expired.package_usage_id, packageName: expired.package_name || '', silence: expired.silence };
}
const waiting = candidates
.filter((node) => node.silence.silence_status === 'waiting' && node.silence.silence_deadline_at)
.map((node) => ({ node, deadline: new Date(node.silence.silence_deadline_at).getTime() }))
.filter(({ deadline }) => Number.isFinite(deadline) && deadline >= now && deadline - now <= SILENCE_WAITING_REMIND_DAYS * DAY_MS)
.sort((a, b) => a.deadline - b.deadline)[0];
if (waiting) {
return {
kind: 'waiting',
packageUsageId: waiting.node.package_usage_id,
packageName: waiting.node.package_name || '',
silence: waiting.node.silence,
remainingDays: Math.max(0, Math.ceil((waiting.deadline - now) / DAY_MS))
};
}
return null;
};
const shortHash = (value) => {
let hash = 0;
const text = String(value || '');
for (let index = 0; index < text.length; index++) {
hash = (hash * 31 + text.charCodeAt(index)) | 0;
}
return (hash >>> 0).toString(36);
};
/**
* 每次登录、每个资产、每笔套餐、每种提醒最多弹一次的存储键:
* 带上登录凭证摘要,重新登录后会再次提醒;同一次登录内反复回首页不重复弹。
*/
/** 已过期未延期(含有未支付延期订单)每次回首页都要提醒;只有"待实名临近截止"按每次登录一次 */
export const silenceReminderRepeats = (reminder) => reminder?.kind === 'expired';
/** 已过期待延期的提醒为强制弹窗:不能关闭,只能去延期(或处理未支付的延期订单)。 */
export const isSilenceReminderBlocking = (reminder) => reminder?.kind === 'expired';
export const silenceReminderKey = (identifier, reminder, sessionToken) =>
`silence_reminder:${identifier}:${reminder.packageUsageId}:${reminder.kind}:${shortHash(sessionToken)}`;