feat: add global system notices and Beijing order time display
Deploy olons-server production / deploy (push) Successful in 3m12s
Deploy olons-server production / deploy (push) Successful in 3m12s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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('--');
|
||||
});
|
||||
});
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user