feat: add global system notices and Beijing order time display
Deploy olons-server production / deploy (push) Successful in 3m12s

This commit is contained in:
richjohnrambo
2026-10-22 21:56:07 +08:00
parent 9bb479b506
commit 8ce67648b1
22 changed files with 581 additions and 121 deletions
+10
View File
@@ -1,6 +1,7 @@
<template>
<main class="m-app" :class="{ 'm-app--with-nav': showNav }">
<router-view />
<SystemNoticeHost />
<nav v-if="showNav" class="m-bottom-nav" aria-label="主要导航">
<router-link v-for="item in nav" :key="item.path" :to="item.path" :aria-label="item.label">
<img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" />
@@ -13,12 +14,15 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { lockHtmlScrollRightCompensationRef } from 'naive-ui/es/_utils';
import { hasSession } from './api';
import { mobileAssets } from './assets';
import SystemNoticeHost from './components/SystemNoticeHost.vue';
const route = useRoute();
const router = useRouter();
const showNav = computed(() => Boolean(route.meta.mobileNav));
const scrollbarCompensation = lockHtmlScrollRightCompensationRef;
const nav = [
{
label: '首页',
@@ -71,3 +75,9 @@
watch(() => route.path, ensureAccess, { immediate: true });
</script>
<style scoped>
.m-bottom-nav {
right: v-bind(scrollbarCompensation);
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { dateTime } from './api';
describe('Beijing business time display', () => {
it.each(['UTC', 'America/New_York', 'Asia/Shanghai'])(
'displays the same Beijing time on a device using %s',
(deviceTimeZone) => {
const previous = process.env.TZ;
process.env.TZ = deviceTimeZone;
try {
expect(dateTime('2026-10-22T13:06:00Z')).toBe('2026-10-22 21:06');
expect(dateTime('2026-10-22T21:06:00+08:00')).toBe('2026-10-22 21:06');
expect(dateTime('2026-10-22T16:00:00Z')).toBe('2026-10-23 00:00');
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}
);
it('uses a placeholder for missing or invalid timestamps', () => {
expect(dateTime()).toBe('--');
expect(dateTime('invalid-time')).toBe('--');
});
});
+2 -1
View File
@@ -184,12 +184,13 @@ export function dateTime(value?: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
hourCycle: 'h23',
})
.format(date)
.replaceAll('/', '-');
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+36 -63
View File
@@ -1,34 +1,34 @@
<template>
<Teleport to="body">
<Transition name="announcement-fade">
<div v-if="show" class="m-announcement-mask" role="presentation">
<section
class="m-announcement-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="announcement-title"
aria-describedby="announcement-content"
>
<h2 id="announcement-title" class="m-announcement-sr-title">
{{ notice?.title || '公告通知' }}
</h2>
<img class="m-announcement-board" :src="announcementBoard" alt="" />
<div class="m-announcement-body">
<div id="announcement-content" class="m-announcement-content">
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
<template v-else>{{ notice?.content }}</template>
</div>
<button type="button" @click="$emit('read')">我已阅读</button>
</div>
</section>
<n-modal :show="show" :mask-closable="false" :close-on-esc="false" :z-index="4000">
<section
class="m-announcement-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="announcement-title"
aria-describedby="announcement-content"
tabindex="-1"
>
<h2 id="announcement-title" class="m-announcement-sr-title">系统通知</h2>
<img class="m-announcement-board" :src="announcementBoard" alt="" />
<div class="m-announcement-body">
<div id="announcement-content" class="m-announcement-content">
<h3
v-if="notice && notice.id > 0 && notice.title && notice.title !== '系统通知'"
class="m-announcement-subtitle"
>{{ notice.title }}</h3>
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
<template v-else>{{ notice?.content }}</template>
</div>
<button type="button" :disabled="reading" @click="$emit('read')">
{{ reading ? '正在确认…' : '我已阅读' }}
</button>
</div>
</Transition>
</Teleport>
</section>
</n-modal>
</template>
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue';
import announcementBoard from '../assets/announcement-popup@2x.png';
import announcementBoard from '../assets/system-notice-popup@2x.png';
export interface AnnouncementNotice {
id: number;
@@ -38,39 +38,22 @@
source_type?: string;
}
const props = defineProps<{
defineProps<{
show: boolean;
notice?: AnnouncementNotice | null;
reading?: boolean;
}>();
defineEmits<{ read: [] }>();
watch(
() => props.show,
(show) => {
document.body.classList.toggle('m-announcement-open', show);
},
{ immediate: true }
);
onBeforeUnmount(() => document.body.classList.remove('m-announcement-open'));
</script>
<style scoped>
.m-announcement-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
padding: 20px 16px;
background: rgba(0, 0, 0, 0.62);
}
.m-announcement-dialog {
position: relative;
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
aspect-ratio: 2 / 3;
background: transparent;
box-shadow: none;
}
.m-announcement-board {
@@ -107,6 +90,12 @@
display: none;
}
.m-announcement-subtitle {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
.m-announcement-body button {
flex: none;
width: 100%;
@@ -132,20 +121,4 @@
border: 0;
}
.announcement-fade-enter-active,
.announcement-fade-leave-active {
transition: opacity 0.2s ease;
}
.announcement-fade-enter-from,
.announcement-fade-leave-to {
opacity: 0;
}
</style>
<style>
body.m-announcement-open {
overflow: hidden;
}
</style>
@@ -0,0 +1,100 @@
<template>
<AnnouncementPopup
:show="Boolean(activeSystemNotice)"
:notice="activeSystemNotice"
:reading="reading"
@read="markRead"
/>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import AnnouncementPopup, { type AnnouncementNotice } from './AnnouncementPopup.vue';
import {
completeLoginNotice,
errorText,
hasSession,
MobileApiError,
request,
shouldShowLoginNotice,
} from '../api';
import { activeSystemNotice, closeSystemNotice, openSystemNotice } from '../systemNotices';
const route = useRoute();
const reading = ref(false);
let timer: number | undefined;
let refreshing = false;
let disposed = false;
const controller = new AbortController();
async function refresh() {
if (disposed || refreshing || activeSystemNotice.value || !hasSession() ||
document.visibilityState === 'hidden') return;
refreshing = true;
let loadingLoginNotice = false;
try {
const pending = await request<AnnouncementNotice | null>({
url: '/v1/notices/pending', signal: controller.signal,
});
if (disposed || activeSystemNotice.value) return;
if (pending) {
openSystemNotice(pending);
return;
}
if (shouldShowLoginNotice()) {
loadingLoginNotice = true;
const loginNotice = await request<AnnouncementNotice | null>({
url: '/v1/notices/popup', signal: controller.signal,
});
if (disposed || activeSystemNotice.value) return;
if (loginNotice) openSystemNotice(loginNotice);
else completeLoginNotice();
}
} catch (error) {
if (loadingLoginNotice && error instanceof MobileApiError && error.status === 404) completeLoginNotice();
// Leave the page usable and retry on the next poll or when the page becomes visible.
} finally {
refreshing = false;
}
}
async function markRead() {
const notice = activeSystemNotice.value;
if (!notice || reading.value) return;
reading.value = true;
try {
if (notice.id > 0) {
await request({
url: `/v1/notices/${notice.id}/read`, method: 'POST', signal: controller.signal,
});
} else {
completeLoginNotice();
}
closeSystemNotice();
} catch (error) {
if (error instanceof MobileApiError && error.status === 404) closeSystemNotice();
else if (!disposed) window.$message.error(errorText(error));
} finally {
reading.value = false;
}
if (!disposed && !activeSystemNotice.value) void refresh();
}
const resume = () => void refresh();
watch(() => route.fullPath, resume);
onMounted(() => {
void refresh();
timer = window.setInterval(resume, 15000);
document.addEventListener('visibilitychange', resume);
window.addEventListener('online', resume);
});
onBeforeUnmount(() => {
disposed = true;
controller.abort();
if (timer) window.clearInterval(timer);
document.removeEventListener('visibilitychange', resume);
window.removeEventListener('online', resume);
closeSystemNotice();
});
</script>
+55 -3
View File
@@ -94,11 +94,12 @@ body {
}
.m-bottom-nav {
position: fixed;
left: 50%;
left: 0;
right: 0;
bottom: 0;
transform: translateX(-50%);
margin-inline: auto;
z-index: 20;
width: min(100%, 750px);
max-width: 750px;
height: calc(76px + env(safe-area-inset-bottom));
padding: 8px 18px env(safe-area-inset-bottom);
background: rgba(255, 255, 255, 0.98);
@@ -540,10 +541,23 @@ body {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
white-space: nowrap;
}
.m-notice-track {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.m-notice-track.is-marquee {
display: inline-flex;
gap: 28px;
padding-right: 28px;
width: max-content;
overflow: visible;
text-overflow: clip;
animation: m-notice-marquee 18s linear infinite;
}
.m-notice-tag {
flex: none;
padding: 2px 6px;
@@ -575,6 +589,19 @@ body {
transform: translateY(0);
}
}
@keyframes m-notice-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.m-notice-track.is-marquee {
animation: none;
}
}
.m-section {
margin-top: 12px;
padding: 17px 16px;
@@ -1059,6 +1086,31 @@ body {
color: #ce8d3d;
font-weight: 500;
}
.m-order-balance-warning {
display: grid;
gap: 5px;
margin-top: 12px;
padding: 12px 14px;
border: 1px solid #f2a5a5;
border-radius: 8px;
background: #fff0f0;
color: #b42318;
font-size: 13px;
line-height: 1.55;
}
.m-order-balance-warning strong {
font-size: 15px;
}
.m-order-balance-warning--detail {
padding: 16px;
font-size: 14px;
}
.m-order-balance-warning--detail p {
margin: 5px 0 0;
}
.m-order-balance-warning--detail .m-order-recharge-link {
margin: 10px 0 0;
}
.m-detail {
display: grid;
gap: 12px;
+13
View File
@@ -0,0 +1,13 @@
import { shallowRef } from 'vue';
import type { AnnouncementNotice } from './components/AnnouncementPopup.vue';
export const activeSystemNotice = shallowRef<AnnouncementNotice | null>(null);
export function openSystemNotice(notice: AnnouncementNotice) {
if (activeSystemNotice.value) return;
activeSystemNotice.value = notice;
}
export function closeSystemNotice() {
activeSystemNotice.value = null;
}
+88 -33
View File
@@ -1,10 +1,5 @@
<template>
<section class="m-home">
<AnnouncementPopup
:show="popupVisible"
:notice="loginPopupNotice"
@read="markPopupRead"
/>
<div class="m-home-hero" :style="bannerStyle">
<button
v-if="currentBanner && currentBanner.target_type !== 'none'"
@@ -64,8 +59,11 @@
<span :class="['m-notice-tag', { 'is-activity': displayNotice.source_type === 'operational_simulation' }]">
{{ displayNotice.source_type === 'operational_simulation' ? '活动' : '公告' }}
</span>
<p :key="displayNotice.id" class="m-notice-copy">
{{ displayNotice.title || displayNotice.content || '查看最新通知' }}
<p ref="noticeCopyRef" :key="displayNotice.id" class="m-notice-copy">
<span :class="['m-notice-track', { 'is-marquee': noticeNeedsMarquee }]">
<span ref="noticeTextRef">{{ noticeBarText }}</span>
<span v-if="noticeNeedsMarquee" aria-hidden="true">{{ noticeBarText }}</span>
</span>
</p>
<n-icon class="m-notice-arrow" :component="ChevronForwardOutline" />
</button>
@@ -111,20 +109,17 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { ChevronForwardOutline, MegaphoneOutline, SearchOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue';
import AnnouncementPopup, {
type AnnouncementNotice,
} from '../components/AnnouncementPopup.vue';
import type { AnnouncementNotice } from '../components/AnnouncementPopup.vue';
import { openSystemNotice } from '../systemNotices';
import {
completeLoginNotice,
errorText,
money,
request,
safeImage,
shouldShowLoginNotice,
} from '../api';
import { mobileAssets } from '../assets';
@@ -153,6 +148,9 @@
content?: string;
source_type?: string;
}
interface NoticePageData {
items: NoticeData[];
}
interface LeaderboardEntry {
rank: number;
username: string;
@@ -165,20 +163,30 @@
const loading = ref(true);
const error = ref('');
const leaderboard = ref<LeaderboardEntry[]>([]);
const loginPopupNotice = ref<AnnouncementNotice | null>(null);
const popupVisible = ref(false);
const homeAnnouncement = ref<AnnouncementNotice | null>(null);
const activeBannerIndex = ref(0);
const activeNoticeIndex = ref(0);
let bannerTimer: number | undefined;
let noticeTimer: number | undefined;
let noticeRefreshTimer: number | undefined;
let refreshingNotices = false;
const formatPoints = (value: number) =>
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
(value || 0) / 100
);
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
const currentNotice = computed(() => home.value?.notices?.[activeNoticeIndex.value]);
const displayNotice = computed(() => homeAnnouncement.value || currentNotice.value);
const displayNotice = computed(() => currentNotice.value || homeAnnouncement.value);
const noticeBarText = computed(() => {
const title = displayNotice.value?.title?.trim() || '';
const content = displayNotice.value?.content?.trim() || '';
if (title && content && title !== content) return `${title}:${content}`;
return title || content || '查看最新通知';
});
const noticeCopyRef = ref<HTMLElement>();
const noticeTextRef = ref<HTMLElement>();
const noticeNeedsMarquee = ref(false);
let noticeResizeObserver: ResizeObserver | undefined;
const bannerStyle = computed(() => {
const url = safeImage(currentBanner.value?.image_url);
return {
@@ -266,10 +274,16 @@
if (count > 1) {
noticeTimer = window.setInterval(() => {
activeNoticeIndex.value = (activeNoticeIndex.value + 1) % count;
}, 4000);
}, 12000);
}
}
function measureNoticeMarquee() {
const element = noticeCopyRef.value;
const text = noticeTextRef.value;
noticeNeedsMarquee.value = Boolean(element && text && text.getBoundingClientRect().width > element.clientWidth + 1);
}
async function load() {
loading.value = true;
error.value = '';
@@ -283,8 +297,6 @@
home.value = homeData;
leaderboard.value = rankData || [];
homeAnnouncement.value = noticeData;
loginPopupNotice.value = shouldShowLoginNotice() ? noticeData : null;
popupVisible.value = Boolean(loginPopupNotice.value);
} catch (err) {
error.value = errorText(err);
} finally {
@@ -292,25 +304,55 @@
}
}
function markPopupRead() {
if (!loginPopupNotice.value) return;
const noticeId = loginPopupNotice.value.id;
popupVisible.value = false;
loginPopupNotice.value = null;
completeLoginNotice();
if (noticeId > 0) {
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
}
}
function openNotice() {
if (currentNotice.value) {
openSystemNotice({
id: currentNotice.value.id,
title: currentNotice.value.title || '会员公告',
content: currentNotice.value.content || '',
source_type: currentNotice.value.source_type,
});
return;
}
if (homeAnnouncement.value) {
loginPopupNotice.value = homeAnnouncement.value;
popupVisible.value = true;
openSystemNotice(homeAnnouncement.value);
return;
}
void router.push('/app/content/notices');
}
async function refreshNotices() {
if (document.visibilityState === 'hidden' || refreshingNotices) return;
refreshingNotices = true;
try {
const page = await request<NoticePageData>({
url: '/v1/notices',
params: { page: 1, page_size: 5 },
});
if (home.value) {
const nextNotices = page.items || [];
const previousIds = home.value.notices.map((notice) => notice.id).join(',');
const nextIds = nextNotices.map((notice) => notice.id).join(',');
home.value.notices = nextNotices;
if (previousIds !== nextIds) {
activeNoticeIndex.value = 0;
restartNoticeTimer();
}
}
} catch {
// Keep the latest successfully loaded announcement visible during transient failures.
} finally {
refreshingNotices = false;
}
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') void refreshNotices();
}
watch(noticeBarText, () => void nextTick(measureNoticeMarquee), { flush: 'post' });
watch(noticeCopyRef, (element, previous) => {
if (previous) noticeResizeObserver?.unobserve(previous);
if (element) noticeResizeObserver?.observe(element);
void nextTick(measureNoticeMarquee);
}, { flush: 'post' });
watch(() => home.value?.banners?.length, () => {
activeBannerIndex.value = 0;
restartBannerTimer();
@@ -319,9 +361,22 @@
activeNoticeIndex.value = 0;
restartNoticeTimer();
});
onMounted(load);
onMounted(async () => {
await load();
await nextTick();
measureNoticeMarquee();
if (noticeCopyRef.value && typeof ResizeObserver !== 'undefined') {
noticeResizeObserver = new ResizeObserver(measureNoticeMarquee);
noticeResizeObserver.observe(noticeCopyRef.value);
}
noticeRefreshTimer = window.setInterval(() => void refreshNotices(), 15000);
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
if (bannerTimer) window.clearInterval(bannerTimer);
if (noticeTimer) window.clearInterval(noticeTimer);
if (noticeRefreshTimer) window.clearInterval(noticeRefreshTimer);
noticeResizeObserver?.disconnect();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
+14
View File
@@ -53,6 +53,10 @@
><strong>佣金:{{ money(item.commission_amount, item.currency) }}</strong></div
>
</div>
<div v-if="isBalancePending(item)" class="m-order-balance-warning">
<strong>余额不足 · 待处理</strong>
<span>{{ item.message }}</span>
</div>
</button>
</div>
</AsyncState>
@@ -103,6 +107,14 @@
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
>
</section>
<section v-if="isBalancePending(detail)" class="m-order-balance-warning m-order-balance-warning--detail" role="alert">
<div>
<strong>余额不足,订单暂未完成</strong>
<p>{{ detail.message }}</p>
<p>充值审核通过后,系统会自动检查余额;余额足够时自动完成订单。</p>
</div>
<router-link class="m-order-recharge-link" to="/app/recharge">立即储入</router-link>
</section>
<section v-if="canSubmit" class="m-order-submit-panel">
<div>
<strong>{{ balanceInsufficient ? '账户余额不足' : '大额订单已匹配' }}</strong>
@@ -198,6 +210,8 @@
const balanceInsufficient = computed(
() => canSubmit.value && availableAmount.value < requiredAmount.value
);
const isBalancePending = (order: any) =>
order?.status === 'pending' && String(order?.message || '').startsWith('余额不足:');
async function loadList() {
listController?.abort();
const controller = new AbortController();