From 0136615502ba0fb947964448c5e95ceca04b1c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Sat, 7 Nov 2026 18:51:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A6=96=E9=A1=B5=E5=BC=B9=E5=87=BA?= =?UTF-8?q?=E5=A5=97=E9=A4=90=E6=B2=89=E9=BB=98=E6=9C=9F=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资产有沉默期已过期且可延期的套餐时弹窗,带去延期入口;待实名 7 天内到截止日时提醒尽快实名 - 排在临期提醒之后、站内通知之前,同一资产同一笔套餐每天最多弹一次 - 套餐历史在后台加载,不阻塞首页其他弹窗 --- components/SilenceReminderPopup.vue | 113 ++++++++++++++++++++++++++++ pages/index/index.vue | 64 +++++++++++++++- tests/october-h5.test.mjs | 38 +++++++++- tests/silence.test.mjs | 29 +++++++ utils/silence.js | 40 ++++++++++ 5 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 components/SilenceReminderPopup.vue 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 @@ + + + + + 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}`;