diff --git a/components/SilenceReminderPopup.vue b/components/SilenceReminderPopup.vue
new file mode 100644
index 0000000..09b352f
--- /dev/null
+++ b/components/SilenceReminderPopup.vue
@@ -0,0 +1,113 @@
+
+
+
+
+ {{ reminder.kind === 'expired' ? '套餐沉默期已过期' : '请尽快完成实名' }}
+ {{ reminder.packageName }}
+
+
+ 套餐需在 {{ deadline }} 前完成实名,现已过期。办理延期后可继续实名激活使用。
+
+
+ 套餐需在 {{ deadline }} 前完成实名激活,还剩
+ {{ reminder.remainingDays }} 天,过期后需办理延期。
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/index/index.vue b/pages/index/index.vue
index 1c08f5a..5a6cc1f 100644
--- a/pages/index/index.vue
+++ b/pages/index/index.vue
@@ -100,6 +100,9 @@
+
+
@@ -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;
});
diff --git a/tests/october-h5.test.mjs b/tests/october-h5.test.mjs
index 6874a89..e4fd59b 100644
--- a/tests/october-h5.test.mjs
+++ b/tests/october-h5.test.mjs
@@ -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);
+});
diff --git a/tests/silence.test.mjs b/tests/silence.test.mjs
index 35becbd..2094bc7 100644
--- a/tests/silence.test.mjs
+++ b/tests/silence.test.mjs
@@ -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');
+});
diff --git a/utils/silence.js b/utils/silence.js
index 273db3a..a452fc9 100644
--- a/utils/silence.js
+++ b/utils/silence.js
@@ -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}`;