fix: some
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 2m16s

This commit is contained in:
luo
2026-10-13 18:02:13 +08:00
parent 97dec4dc65
commit 697ff9c33c
6 changed files with 263 additions and 54 deletions

View File

@@ -1,31 +0,0 @@
<template>
<view class="card expiry-banner" :class="`expiry-${reminder.level}`" role="status">
<view class="banner-heading">
<view class="banner-title">{{ reminder.level_name || '套餐临期提醒' }}</view>
<button class="banner-close" aria-label="关闭临期提醒" @tap="$emit('close')">关闭</button>
</view>
<view class="banner-body">当前资产套餐剩余 {{ reminder.remaining_days }} 天,请及时续费。</view>
<view class="banner-expiry">预计到期:{{ formatShanghaiExpiry(reminder.expires_at) }}</view>
</view>
</template>
<script setup>
import { formatShanghaiExpiry } from '@/utils/expiry-reminder.js';
defineProps({ reminder: { type: Object, required: true } });
defineEmits(['close']);
</script>
<style scoped lang="scss">
.expiry-banner {
border-left: 8rpx solid var(--reminder-color);
.banner-heading { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.banner-title { color: var(--reminder-color); font-size: 30rpx; font-weight: 600; }
.banner-close { margin: 0; padding: 8rpx 16rpx; font-size: 24rpx; line-height: 1.5; color: var(--text-secondary); background: var(--gray-100); }
.banner-close::after { border: none; }
.banner-body { margin-top: 12rpx; font-size: 28rpx; line-height: 1.6; color: var(--text-primary); }
.banner-expiry { margin-top: 8rpx; font-size: 24rpx; color: var(--text-secondary); }
}
.expiry-red { --reminder-color: var(--danger); }
.expiry-purple { --reminder-color: var(--purple); }
.expiry-pink { --reminder-color: var(--pink); }
</style>

View File

@@ -0,0 +1,142 @@
<template>
<view class="expiry-overlay" @tap.stop>
<view class="expiry-dialog" :class="`expiry-${reminder.level}`" role="dialog"
aria-modal="true" aria-label="套餐临期提醒" @keydown.esc="$emit('close')">
<view class="reminder-header">
<view class="reminder-level">{{ reminder.level_name || '套餐临期提醒' }}</view>
<button class="reminder-close" aria-label="关闭临期提醒" @tap="$emit('close')">×</button>
<view class="clock-halo" aria-hidden="true"><view class="clock-face"></view></view>
<view class="reminder-title">套餐即将到期</view>
<view class="remaining-label">当前资产套餐剩余</view>
<view class="remaining-days"><text class="day-number">{{ reminder.remaining_days }}</text><text class="day-unit">天</text></view>
</view>
<view class="reminder-content">
<view class="expiry-details">
<text class="expiry-label">预计到期时间</text>
<text class="expiry-value">{{ formatShanghaiExpiry(reminder.expires_at) }}</text>
</view>
<view class="reminder-description">为避免套餐到期影响使用,请及时续费。</view>
<button class="reminder-confirm" @tap="$emit('close')">我知道了</button>
</view>
</view>
</view>
</template>
<script setup>
import { formatShanghaiExpiry } from '@/utils/expiry-reminder.js';
defineProps({ reminder: { type: Object, required: true } });
defineEmits(['close']);
</script>
<style scoped lang="scss">
.expiry-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);
backdrop-filter: blur(4px);
}
.expiry-dialog {
width: 100%;
max-width: 640rpx;
max-height: calc(100vh - 80rpx);
overflow-y: auto;
border-radius: 32rpx;
background: var(--bg-primary);
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.18);
}
.reminder-header {
position: relative;
padding: 40rpx 36rpx 28rpx;
text-align: center;
background: linear-gradient(180deg, var(--reminder-tint), var(--bg-primary));
}
.reminder-level {
display: inline-block;
padding: 8rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
color: var(--reminder-color);
background: var(--reminder-tint);
}
.reminder-close {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 56rpx;
height: 56rpx;
margin: 0;
padding: 0;
border-radius: 50%;
font-size: 40rpx;
line-height: 52rpx;
color: var(--text-tertiary);
background: transparent;
}
.reminder-close::after, .reminder-confirm::after { border: none; }
.clock-halo {
display: flex;
align-items: center;
justify-content: center;
width: 108rpx;
height: 108rpx;
margin: 28rpx auto 22rpx;
border-radius: 50%;
background: var(--reminder-tint);
}
.clock-face {
position: relative;
width: 50rpx;
height: 50rpx;
border: 4rpx solid var(--reminder-color);
border-radius: 50%;
}
.clock-face::before, .clock-face::after {
content: '';
position: absolute;
left: 23rpx;
top: 10rpx;
width: 4rpx;
height: 17rpx;
border-radius: 4rpx;
background: var(--reminder-color);
}
.clock-face::after { top: 23rpx; height: 14rpx; transform: rotate(-55deg); transform-origin: top center; }
.reminder-title { color: var(--text-primary); font-size: 38rpx; font-weight: 700; }
.remaining-label { margin-top: 20rpx; color: var(--text-secondary); font-size: 26rpx; }
.remaining-days { margin-top: 8rpx; color: var(--reminder-color); line-height: 1.2; }
.day-number { font-size: 88rpx; font-weight: 700; letter-spacing: -3rpx; font-variant-numeric: tabular-nums; }
.day-unit { margin-left: 12rpx; font-size: 28rpx; }
.reminder-content { padding: 0 36rpx 36rpx; }
.expiry-details {
display: flex;
flex-direction: column;
gap: 10rpx;
padding: 22rpx;
border-radius: 16rpx;
text-align: center;
background: var(--bg-secondary);
}
.expiry-label { font-size: 22rpx; color: var(--text-tertiary); }
.expiry-value { font-size: 26rpx; color: var(--text-primary); font-weight: 500; }
.reminder-description { margin: 22rpx 0 28rpx; color: var(--text-secondary); font-size: 24rpx; line-height: 1.6; text-align: center; }
.reminder-confirm {
margin: 0;
padding: 24rpx;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: 600;
line-height: 1;
color: #fff;
background: var(--reminder-color);
}
.expiry-red { --reminder-color: #d94040; --reminder-tint: rgba(217, 64, 64, 0.08); }
.expiry-purple { --reminder-color: #8850c4; --reminder-tint: rgba(136, 80, 196, 0.08); }
.expiry-pink { --reminder-color: #ce4878; --reminder-tint: rgba(206, 72, 120, 0.08); }
</style>

View File

@@ -3,8 +3,6 @@
<UserInfoCard :currentCardNo="currentCardNo" :deviceInfo="deviceInfo" :onlineStatus="onlineStatus"
:isDevice="userInfo.isDevice" :networkStatus="deviceInfo.network_status"
:isExpiring="deviceInfo.isExpiring" :expiryDays="deviceInfo.expiryDays" @renew="startHomepageRenewal" />
<ExpiryReminderBanner v-if="visibleExpiryReminder" :reminder="visibleExpiryReminder"
@close="closeExpiryReminder" />
<DeviceStatusCard v-if="userInfo.isDevice" :deviceInfo="deviceInfo" :isRealName="isRealName"
:isDevice="userInfo.isDevice" @authentication="enterDetail('authentication')" />
@@ -99,6 +97,8 @@
</FunctionCard>
<view class="bottom-spacer"></view>
<ExpiryReminderPopup v-if="visibleExpiryReminder" :reminder="visibleExpiryReminder"
@close="closeExpiryReminder" />
<NotificationPopup :show="notificationPopupShow" :items="notificationItems"
:current="notificationPopupCurrent" @change="onNotificationChange" @close="closeNotificationPopup"
@@ -140,7 +140,7 @@
import FunctionCard from '@/components/FunctionCard.vue';
import FloatingButton from '@/components/FloatingButton.vue';
import NotificationPopup from '@/components/NotificationPopup.vue';
import ExpiryReminderBanner from '@/components/ExpiryReminderBanner.vue';
import ExpiryReminderPopup from '@/components/ExpiryReminderPopup.vue';
import RenewalPaymentPopup from '@/components/RenewalPaymentPopup.vue';
import PopupCandidate from '@/components/PopupCandidate.vue';
import {
@@ -161,6 +161,10 @@
const assetStore = useAssetStore();
const homeActive = ref(false);
const shownExpiryReminder = ref(null);
const awaitingHomeReminders = ref(false);
let notificationFallbackPending = false;
let notificationQueued = false;
let homeEntryId = 0;
const visibleExpiryReminder = computed(() => {
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;
});
</script>

View File

@@ -17,11 +17,11 @@
</view>
<view v-else class="notification-list">
<view v-for="item in notifications" :key="item.id" class="card notification-card"
:class="{ unread: !item.is_read }" @tap="markItemRead(item)">
<view v-for="item in notifications" :key="item.id" class="card notification-card" @tap="markItemRead(item)">
<view class="notification-header flex-row-sb">
<view class="notification-title">{{ item.title || '业务通知' }}</view>
<view v-if="!item.is_read" class="unread-dot"></view>
<button class="read-status" :class="item.is_read ? 'status-read' : 'status-unread'"
@tap.stop="markItemRead(item)">{{ item.is_read ? '已读' : '未读' }}</button>
</view>
<view class="notification-body">{{ item.body || '-' }}</view>
<view class="notification-footer flex-row-sb">
@@ -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; }

View File

@@ -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 }));

View File

@@ -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();