diff --git a/components/ExpiryReminderBanner.vue b/components/ExpiryReminderBanner.vue deleted file mode 100644 index a3caf72..0000000 --- a/components/ExpiryReminderBanner.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - - - diff --git a/components/ExpiryReminderPopup.vue b/components/ExpiryReminderPopup.vue new file mode 100644 index 0000000..bac7cc7 --- /dev/null +++ b/components/ExpiryReminderPopup.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/pages/index/index.vue b/pages/index/index.vue index c6a6836..b0badf1 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -3,8 +3,6 @@ - @@ -99,6 +97,8 @@ + { const entry = shownExpiryReminder.value; return homeActive.value && entry && isCurrentSession(entry) && entry.day === getShanghaiDay() @@ -168,15 +172,31 @@ }); const showPendingExpiryReminder = () => { const entry = assetStore.state.expiryReminder; - if (!homeActive.value || !entry || entry.shown || !isCurrentSession(entry) || entry.day !== getShanghaiDay()) return; + if (!homeActive.value || awaitingHomeReminders.value || popupCandidateShow.value || notificationPopupShow.value || + visibleExpiryReminder.value || !entry || entry.shown || !isCurrentSession(entry) || entry.day !== getShanghaiDay()) return; shownExpiryReminder.value = { ...entry }; assetStore.markExpiryReminderShown(); }; const closeExpiryReminder = () => { shownExpiryReminder.value = null; assetStore.dismissExpiryReminder(); + advanceHomePopupQueue(); }; - watch(() => assetStore.state.expiryReminder, showPendingExpiryReminder); + const advanceHomePopupQueue = () => { + if (!homeActive.value || awaitingHomeReminders.value || popupCandidateShow.value || + visibleExpiryReminder.value || notificationPopupShow.value) return; + showPendingExpiryReminder(); + if (visibleExpiryReminder.value) return; + if (notificationQueued) { + notificationQueued = false; + notificationPopupShow.value = true; + markNotificationRead(notificationItems.value[0]); + } else if (notificationFallbackPending) { + notificationFallbackPending = false; + loadUnreadNotifications(); + } + }; + watch(() => assetStore.state.expiryReminder, advanceHomePopupQueue); let showModifyWifi = ref(false); let restartShow = ref(false); @@ -899,18 +919,18 @@ const loadUnreadNotifications = async () => { const context = getSessionContext(); + const entryId = homeEntryId; try { const data = await notificationApi.getList(1, 50, false); - if (!homeActive.value || !isCurrentSession(context)) return; + if (!homeActive.value || entryId !== homeEntryId || !isCurrentSession(context)) return; const items = (data?.items || []) .filter(item => !item.is_read && item.category !== 'expiry'); if (!items.length) return; notificationItems.value = items; notificationPopupCurrent.value = 0; - notificationPopupShow.value = true; - // 弹窗打开即视为用户已看到第一条通知。 - markNotificationRead(items[0]); + notificationQueued = true; + advanceHomePopupQueue(); } catch (error) { console.error('加载首页未读通知失败', error); } @@ -931,6 +951,7 @@ const closeNotificationPopup = () => { notificationPopupShow.value = false; + advanceHomePopupQueue(); }; const runPopupCandidate = () => { @@ -938,10 +959,12 @@ }; const onPopupCandidateClose = (reason) => { + if (!homeActive.value) return; popupCandidateShow.value = false; if (reason === 'empty' || reason === 'invisible') { - loadUnreadNotifications(); + notificationFallbackPending = true; } + advanceHomePopupQueue(); loadNotificationUnreadCount(); }; @@ -949,31 +972,44 @@ initCurrentMonth(); }); - onShow(() => { + onShow(async () => { + const entryId = ++homeEntryId; homeActive.value = true; shownExpiryReminder.value = null; - showPendingExpiryReminder(); + awaitingHomeReminders.value = true; + notificationFallbackPending = false; + notificationQueued = false; if (!getSessionContext().token) { + awaitingHomeReminders.value = false; handleIndexEntry(); return; } if (consumePendingPaymentRefresh(PAYMENT_REFRESH_TARGETS.HOME)) { loadAssetInfo(); } - handleIndexEntry(); loadNotificationUnreadCount(); runPopupCandidate(); + await handleIndexEntry(); + if (!homeActive.value || entryId !== homeEntryId) return; + awaitingHomeReminders.value = false; + advanceHomePopupQueue(); }); onHide(() => { + homeEntryId++; homeActive.value = false; shownExpiryReminder.value = null; notificationPopupShow.value = false; + popupCandidateShow.value = false; + notificationQueued = false; + notificationFallbackPending = false; }); watch(() => [userStore.state.token, userStore.state.identifier], () => { shownExpiryReminder.value = null; notificationItems.value = []; notificationUnreadCount.value = 0; notificationPopupShow.value = false; + notificationQueued = false; + notificationFallbackPending = false; }); diff --git a/pages/notifications/notifications.vue b/pages/notifications/notifications.vue index 677ba1f..69b2a71 100644 --- a/pages/notifications/notifications.vue +++ b/pages/notifications/notifications.vue @@ -17,11 +17,11 @@ - + {{ item.title || '业务通知' }} - + {{ item.body || '-' }} @@ -171,11 +171,13 @@ .toolbar { margin-bottom: var(--space-md); } .summary { color: var(--text-tertiary); font-size: 24rpx; } .mark-all { margin: 0; padding: 0 24rpx; height: 64rpx; line-height: 64rpx; font-size: 24rpx; } - .notification-card { margin-bottom: var(--space-md); border-left: 6rpx solid transparent; } - .notification-card.unread { border-left-color: var(--primary); } - .notification-header { margin-bottom: var(--space-sm); } + .notification-card { margin-bottom: var(--space-md); } + .notification-header { margin-bottom: var(--space-sm); gap: 20rpx; align-items: flex-start; } .notification-title { color: var(--text-primary); font-size: 30rpx; font-weight: 600; flex: 1; } - .unread-dot { width: 14rpx; height: 14rpx; margin-left: 16rpx; border-radius: 50%; background: var(--primary); } + .read-status { flex-shrink: 0; margin: 0; padding: 6rpx 18rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 500; line-height: 1.5; } + .read-status::after { border: none; } + .status-unread { color: var(--primary-dark); background: var(--success-light); } + .status-read { color: var(--text-tertiary); background: var(--gray-100); } .notification-body { color: var(--text-secondary); font-size: 26rpx; line-height: 1.6; white-space: pre-wrap; } .notification-footer { margin-top: var(--space-md); } .notification-meta, .notification-time { color: var(--text-tertiary); font-size: 22rpx; } diff --git a/tests/mock-h5-preview.mjs b/tests/mock-h5-preview.mjs index ace010a..d3a9bfd 100644 --- a/tests/mock-h5-preview.mjs +++ b/tests/mock-h5-preview.mjs @@ -1,6 +1,8 @@ // Local-only visual QA: no request reaches the real backend. import { preview } from 'vite'; +let notificationRead = false; + const server = await preview({ configFile: false, build: { outDir: 'dist/build/h5' }, @@ -19,6 +21,14 @@ const server = await preview({ expiry_reminder: { remaining_days: 0, expires_at: '2026-10-13T16:00:00Z', level: 'red', level_name: '即将到期' } }; if (req.url.startsWith('/__mock/notifications')) data = { count: 0, items: [], total: 0 }; + if (process.env.H5_MOCK_NOTIFICATIONS === '1') { + if (req.url.startsWith('/__mock/notifications/unread-count')) data = { count: notificationRead ? 0 : 1 }; + else if (/^\/__mock\/notifications\/42\/read/.test(req.url)) { notificationRead = true; data = { success: true }; } + else if (req.url.startsWith('/__mock/notifications/read-all')) { notificationRead = true; data = { updated_count: 1 }; } + else if (req.url.startsWith('/__mock/notifications')) data = { + items: [{ id: 42, title: '套餐临期提醒', body: '您的套餐即将到期,请及时续费。', category: 'expiry', severity: 'warning', is_read: notificationRead, created_at: '2026-10-13T08:00:00+08:00' }], total: 1 + }; + } const conflict = req.url.startsWith('/__mock/auth/bind-phone'); res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ code: conflict ? 1192 : 0, msg: conflict ? '该资产已绑定手机号,如需更换请联系管理员' : 'success', data })); diff --git a/tests/october-h5.test.mjs b/tests/october-h5.test.mjs index 6a03a9d..be5d843 100644 --- a/tests/october-h5.test.mjs +++ b/tests/october-h5.test.mjs @@ -351,13 +351,43 @@ test('home shows shared reminder once and excludes only expiry from old notifica }; const asset = (await r.load('store/asset.js')).useAssetStore(); asset.receiveExpiryReminder({ token: 'session-A', identifier: 'A' }, { remaining_days: 0, level: 'red' }); - const page = await r.load('pages/index/index.vue', ['visibleExpiryReminder', 'loadUnreadNotifications', 'notificationItems', 'closeExpiryReminder']); - r.shown[0](); await r.tick(); + const page = await r.load('pages/index/index.vue', ['visibleExpiryReminder', 'loadUnreadNotifications', 'notificationItems', 'notificationPopupShow', 'closeExpiryReminder', 'onPopupCandidateClose']); + await r.shown[0](); + assert.equal(page.visibleExpiryReminder.value, null); + assert.equal(asset.state.expiryReminder.shown, false); + page.onPopupCandidateClose('empty'); await r.tick(); assert.equal(page.visibleExpiryReminder.value.remaining_days, 0); await page.loadUnreadNotifications(); assert.deepEqual(plain(page.notificationItems.value).map(item => item.id), [2]); + assert.equal(page.notificationPopupShow.value, false); page.closeExpiryReminder(); - r.hidden[0](); r.shown[0](); await r.tick(); + assert.equal(page.notificationPopupShow.value, true); + r.hidden[0](); await r.shown[0](); page.onPopupCandidateClose('empty'); await r.tick(); + assert.equal(page.visibleExpiryReminder.value, null); +}); + +test('expiry popup waits for an open notification and closing it does not mark expiry read', async () => { + const r = runtime(); + let markReads = 0; + r.api.assetApi = { getInfo: async () => ({ identifier: 'A' }) }; + r.api.notificationApi = { + getList: async () => ({ items: [{ id: 2, category: 'approval', is_read: false }] }), + getUnreadCount: async () => ({ count: 1 }), markRead: async () => { markReads++; } + }; + const page = await r.load('pages/index/index.vue', ['visibleExpiryReminder', 'notificationPopupShow', 'onPopupCandidateClose', 'closeNotificationPopup', 'closeExpiryReminder']); + await r.shown[0](); page.onPopupCandidateClose('empty'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(page.notificationPopupShow.value, true); + const asset = (await r.load('store/asset.js')).useAssetStore(); + asset.receiveExpiryReminder({ token: 'session-A', identifier: 'A' }, { remaining_days: 7, level: 'purple' }); + await r.tick(); + assert.equal(page.visibleExpiryReminder.value, null); + assert.equal(asset.state.expiryReminder.shown, false); + page.closeNotificationPopup(); + assert.equal(page.visibleExpiryReminder.value.remaining_days, 7); + const previousReads = markReads; + page.closeExpiryReminder(); + assert.equal(markReads, previousReads); assert.equal(page.visibleExpiryReminder.value, null); }); @@ -378,6 +408,26 @@ test('notification center refreshes server scope after read-all; failures keep u } }); +test('clicking a notification card updates its read badge only after successful mark-read', async () => { + for (const fail of [false, true]) { + const r = runtime(); + let marked = false; + r.api.notificationApi = { + getUnreadCount: async () => ({ count: marked ? 0 : 1 }), + markRead: async id => { assert.equal(id, 42); if (fail) throw new Error('offline'); marked = true; } + }; + const page = await r.load('pages/notifications/notifications.vue', ['notifications', 'markItemRead', 'unreadCount']); + page.notifications.push({ id: 42, is_read: false, title: '套餐提醒' }); + page.unreadCount.value = 1; + await page.markItemRead(page.notifications[0]); + assert.equal(page.notifications[0].is_read, !fail); + assert.equal(page.unreadCount.value, fail ? 1 : 0); + } + const source = readFileSync(resolve(root, 'pages/notifications/notifications.vue'), 'utf8'); + assert.match(source, /item\.is_read \? '已读' : '未读'/); + assert.doesNotMatch(source, /border-left|unread-dot/); +}); + test('notification center rejects an old asset response after switching context', async () => { const r = runtime(); const oldList = deferred();