feat: 首页弹出套餐沉默期提醒
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 14m26s

- 资产有沉默期已过期且可延期的套餐时弹窗,带去延期入口;待实名 7 天内到截止日时提醒尽快实名
- 排在临期提醒之后、站内通知之前,同一资产同一笔套餐每天最多弹一次
- 套餐历史在后台加载,不阻塞首页其他弹窗
This commit is contained in:
邹进
2026-11-07 18:51:54 +09:00
parent 31ff18d66a
commit 0136615502
5 changed files with 278 additions and 6 deletions

View File

@@ -0,0 +1,113 @@
<template>
<view class="silence-overlay" @tap.stop>
<view class="silence-dialog" :class="`silence-${reminder.kind}`" role="dialog" aria-modal="true" aria-label="套餐沉默期提醒">
<button class="silence-close" aria-label="关闭沉默期提醒" @tap="$emit('close')">×</button>
<view class="silence-title">{{ reminder.kind === 'expired' ? '套餐沉默期已过期' : '请尽快完成实名' }}</view>
<view v-if="reminder.packageName" class="silence-package">{{ reminder.packageName }}</view>
<view class="silence-body">
<template v-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')">{{ reminder.kind === 'expired' ? '去延期' : '查看套餐' }}</button>
<button class="silence-later" @tap="$emit('close')">我知道了</button>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
import { formatSilenceDate } from '@/utils/silence.js';
const props = defineProps({ reminder: { type: Object, required: true } });
defineEmits(['close', 'action']);
const deadline = computed(() => formatSilenceDate(props.reminder.silence?.silence_deadline_at));
</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);
}
.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

@@ -100,6 +100,9 @@
<ExpiryReminderPopup v-if="visibleExpiryReminder" :reminder="visibleExpiryReminder"
@close="closeExpiryReminder" @renew="renewFromExpiryReminder" />
<SilenceReminderPopup v-if="silenceReminderShow && silenceReminder" :reminder="silenceReminder"
@close="closeSilenceReminder" @action="openSilenceReminderAction" />
<NotificationPopup :show="notificationPopupShow" :items="notificationItems"
:current="notificationPopupCurrent" @change="onNotificationChange" @close="closeNotificationPopup"
@renew="startHomepageRenewal" />
@@ -141,6 +144,7 @@
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 {
@@ -156,6 +160,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 { pickSilenceReminder, silenceReminderKey } from '@/utils/silence.js';
const userStore = useUserStore();
const assetStore = useAssetStore();
@@ -175,7 +180,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 +189,62 @@
assetStore.dismissExpiryReminder();
if (resumeQueue) advanceHomePopupQueue();
};
// 沉默期提醒:首页按"临期提醒 → 沉默期提醒 → 站内通知"的顺序依次弹出,同一资产同一笔套餐每天最多一次。
const silenceReminder = ref(null);
const silenceReminderShow = ref(false);
let silenceReminderIdentifier = '';
const loadSilenceReminder = 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 && !uni.getStorageSync(silenceReminderKey(identifier, reminder, getShanghaiDay()))) {
silenceReminderIdentifier = identifier;
silenceReminder.value = reminder;
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;
}
uni.setStorageSync(silenceReminderKey(silenceReminderIdentifier, reminder, getShanghaiDay()), 1);
silenceReminderShow.value = true;
return true;
};
const closeSilenceReminder = () => {
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;
@@ -1027,6 +1083,8 @@
if (!homeActive.value || entryId !== homeEntryId) return;
awaitingHomeReminders.value = false;
advanceHomePopupQueue();
// 沉默期提醒在后台加载,不阻塞首页其他弹窗;加载完成后按队列顺序择机弹出。
loadSilenceReminder(entryId);
});
onHide(() => {
homeEntryId++;
@@ -1035,6 +1093,8 @@
shownExpiryReminder.value = null;
notificationPopupShow.value = false;
popupCandidateShow.value = false;
silenceReminderShow.value = false;
silenceReminder.value = null;
notificationQueued = false;
notificationFallbackPending = false;
});

View File

@@ -808,16 +808,18 @@ test('returning home reloads actual polling, network and expiry with one asset r
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(r.requests.length, 1);
r.reply(r.requests[0], { enable_polling: false, network_status: 0, estimated_final_expires_at: '2026-10-26T00:00:00Z' });
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(r.requests.length, 2);
r.reply(r.requests[1], { enable_polling: true, network_status: 1, estimated_final_expires_at: '2026-11-26T00:00:00Z' });
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);
@@ -931,3 +933,31 @@ test('order completion and payment return reread actual asset state, not a presu
await r.timers[1]();
assert.equal(reads, 4);
});
test('home shows the silence reminder after other popups, once per day, and opens the extension page', 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: []
}] })
};
r.api.notificationApi = { getUnreadCount: async () => ({ count: 0 }), getList: async () => ({ items: [] }) };
const page = await r.load('pages/index/index.vue', ['silenceReminder', 'silenceReminderShow', 'onPopupCandidateClose', 'openSilenceReminderAction', 'closeSilenceReminder']);
await r.shown[0](); await r.tick(); await r.tick();
// 运营弹窗仍在时不弹沉默期提醒
assert.equal(page.silenceReminderShow.value, false);
page.onPopupCandidateClose('empty'); await r.tick();
assert.equal(page.silenceReminderShow.value, true);
assert.equal(page.silenceReminder.value.kind, 'expired');
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 r.tick(); await r.tick();
page.onPopupCandidateClose('empty'); await r.tick();
assert.equal(page.silenceReminderShow.value, false);
});

View File

@@ -9,6 +9,8 @@ import {
isPaymentLink,
isSilenceFeatureDisabled,
isSilenceOrderFinal,
pickSilenceReminder,
silenceReminderKey,
validateExtensionDays
} from '../utils/silence.js';
@@ -63,3 +65,30 @@ test('资格原因、状态样式、订单终态与支付链接判断', () => {
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);
assert.equal(silenceReminderKey('A', { packageUsageId: 3, kind: 'expired' }, '2026-11-07'), 'silence_reminder:A:3:expired:2026-11-07');
});

View File

@@ -127,3 +127,43 @@ export const isPaymentLink = (content) => /^https?:\/\//i.test(String(content ||
/** 沉默期流水 / 订单日期:只取到日 */
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;
};
/** 每个资产、每笔套餐、每种提醒每天最多弹一次的存储键 */
export const silenceReminderKey = (identifier, reminder, day) =>
`silence_reminder:${identifier}:${reminder.packageUsageId}:${reminder.kind}:${day}`;