✨ feat: Enhance announcements UX with unread badge, tabbed NoticeModal, and shine animation
• HeaderBar - Added dynamic unread badge; click now opens NoticeModal on “System Announcements” tab - Passes `defaultTab` and `unreadKeys` props to NoticeModal for contextual behaviour • NoticeModal - Introduced Tabs inside the modal title with Lucide icons (Bell, Megaphone) - Displays in-app notice (markdown) and system announcements separately - Highlights unread announcements with “shine” text animation - Accepts new props `defaultTab`, `unreadKeys` to control initial tab and highlight logic • CSS (index.css) - Implemented `sweep-shine` keyframes and `.shine-text` utility for left-to-right glow - Added dark-mode variant for better contrast - Ensured cross-browser support with standard `background-clip` Overall, users now see an unread counter, are directed to new announcements automatically, and benefit from an eye-catching glow effect that works in both light and dark themes.
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
|
Badge,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { StatusContext } from '../../context/Status/index.js';
|
import { StatusContext } from '../../context/Status/index.js';
|
||||||
import { useStyle, styleActions } from '../../context/Style/index.js';
|
import { useStyle, styleActions } from '../../context/Style/index.js';
|
||||||
@@ -43,6 +44,7 @@ const HeaderBar = () => {
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
const systemName = getSystemName();
|
const systemName = getSystemName();
|
||||||
const logo = getLogo();
|
const logo = getLogo();
|
||||||
@@ -53,9 +55,44 @@ const HeaderBar = () => {
|
|||||||
const docsLink = statusState?.status?.docs_link || '';
|
const docsLink = statusState?.status?.docs_link || '';
|
||||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||||
|
|
||||||
|
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const setTheme = useSetTheme();
|
const setTheme = useSetTheme();
|
||||||
|
|
||||||
|
const announcements = statusState?.status?.announcements || [];
|
||||||
|
|
||||||
|
const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`;
|
||||||
|
|
||||||
|
const calculateUnreadCount = () => {
|
||||||
|
if (!announcements.length) return 0;
|
||||||
|
let readKeys = [];
|
||||||
|
try {
|
||||||
|
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||||
|
} catch (_) {
|
||||||
|
readKeys = [];
|
||||||
|
}
|
||||||
|
const readSet = new Set(readKeys);
|
||||||
|
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUnreadKeys = () => {
|
||||||
|
if (!announcements.length) return [];
|
||||||
|
let readKeys = [];
|
||||||
|
try {
|
||||||
|
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||||
|
} catch (_) {
|
||||||
|
readKeys = [];
|
||||||
|
}
|
||||||
|
const readSet = new Set(readKeys);
|
||||||
|
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnreadCount(calculateUnreadCount());
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [announcements]);
|
||||||
|
|
||||||
const mainNavLinks = [
|
const mainNavLinks = [
|
||||||
{
|
{
|
||||||
text: t('首页'),
|
text: t('首页'),
|
||||||
@@ -106,6 +143,25 @@ const HeaderBar = () => {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNoticeOpen = () => {
|
||||||
|
setNoticeVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNoticeClose = () => {
|
||||||
|
setNoticeVisible(false);
|
||||||
|
if (announcements.length) {
|
||||||
|
let readKeys = [];
|
||||||
|
try {
|
||||||
|
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||||
|
} catch (_) {
|
||||||
|
readKeys = [];
|
||||||
|
}
|
||||||
|
const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)]));
|
||||||
|
localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys));
|
||||||
|
}
|
||||||
|
setUnreadCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme === 'dark') {
|
if (theme === 'dark') {
|
||||||
document.body.setAttribute('theme-mode', 'dark');
|
document.body.setAttribute('theme-mode', 'dark');
|
||||||
@@ -353,15 +409,14 @@ const HeaderBar = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查当前路由是否以/console开头
|
|
||||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
||||||
<NoticeModal
|
<NoticeModal
|
||||||
visible={noticeVisible}
|
visible={noticeVisible}
|
||||||
onClose={() => setNoticeVisible(false)}
|
onClose={handleNoticeClose}
|
||||||
isMobile={styleState.isMobile}
|
isMobile={styleState.isMobile}
|
||||||
|
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
|
||||||
|
unreadKeys={getUnreadKeys()}
|
||||||
/>
|
/>
|
||||||
<div className="w-full px-2">
|
<div className="w-full px-2">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
@@ -462,14 +517,27 @@ const HeaderBar = () => {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
{unreadCount > 0 ? (
|
||||||
icon={<IconBell className="text-lg" />}
|
<Badge count={unreadCount} type="danger" overflowCount={99}>
|
||||||
aria-label={t('系统公告')}
|
<Button
|
||||||
onClick={() => setNoticeVisible(true)}
|
icon={<IconBell className="text-lg" />}
|
||||||
theme="borderless"
|
aria-label={t('系统公告')}
|
||||||
type="tertiary"
|
onClick={handleNoticeOpen}
|
||||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
theme="borderless"
|
||||||
/>
|
type="tertiary"
|
||||||
|
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
icon={<IconBell className="text-lg" />}
|
||||||
|
aria-label={t('系统公告')}
|
||||||
|
onClick={handleNoticeOpen}
|
||||||
|
theme="borderless"
|
||||||
|
type="tertiary"
|
||||||
|
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
|
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
|
||||||
|
|||||||
@@ -1,14 +1,36 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useContext, useMemo } from 'react';
|
||||||
import { Button, Modal, Empty } from '@douyinfe/semi-ui';
|
import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { API, showError } from '../../helpers';
|
import { API, showError, getRelativeTime } from '../../helpers';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
||||||
|
import { StatusContext } from '../../context/Status/index.js';
|
||||||
|
import { Bell, Megaphone } from 'lucide-react';
|
||||||
|
|
||||||
const NoticeModal = ({ visible, onClose, isMobile }) => {
|
const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadKeys = [] }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [noticeContent, setNoticeContent] = useState('');
|
const [noticeContent, setNoticeContent] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||||
|
|
||||||
|
const [statusState] = useContext(StatusContext);
|
||||||
|
|
||||||
|
const announcements = statusState?.status?.announcements || [];
|
||||||
|
|
||||||
|
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
|
||||||
|
|
||||||
|
const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
|
||||||
|
|
||||||
|
const processedAnnouncements = useMemo(() => {
|
||||||
|
return (announcements || []).slice(0, 20).map(item => ({
|
||||||
|
key: getKeyForItem(item),
|
||||||
|
type: item.type || 'default',
|
||||||
|
time: getRelativeTime(item.publishDate),
|
||||||
|
content: item.content,
|
||||||
|
extra: item.extra,
|
||||||
|
isUnread: unreadSet.has(getKeyForItem(item))
|
||||||
|
}));
|
||||||
|
}, [announcements, unreadSet]);
|
||||||
|
|
||||||
const handleCloseTodayNotice = () => {
|
const handleCloseTodayNotice = () => {
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
@@ -44,7 +66,13 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
|
|||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const renderContent = () => {
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setActiveTab(defaultTab);
|
||||||
|
}
|
||||||
|
}, [defaultTab, visible]);
|
||||||
|
|
||||||
|
const renderMarkdownNotice = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
|
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
|
||||||
}
|
}
|
||||||
@@ -64,14 +92,74 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
||||||
className="notice-content-scroll max-h-[60vh] overflow-y-auto pr-2"
|
className="notice-content-scroll max-h-[55vh] overflow-y-auto pr-2"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderAnnouncementTimeline = () => {
|
||||||
|
if (processedAnnouncements.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-12">
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||||
|
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||||
|
description={t('暂无系统公告')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[55vh] overflow-y-auto pr-2 card-content-scroll">
|
||||||
|
<Timeline mode="alternate">
|
||||||
|
{processedAnnouncements.map((item, idx) => (
|
||||||
|
<Timeline.Item
|
||||||
|
key={idx}
|
||||||
|
type={item.type}
|
||||||
|
time={item.time}
|
||||||
|
className={item.isUnread ? '' : ''}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{item.isUnread ? (
|
||||||
|
<span className="shine-text">
|
||||||
|
{item.content}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
item.content
|
||||||
|
)}
|
||||||
|
{item.extra && <div className="text-xs text-gray-500">{item.extra}</div>}
|
||||||
|
</div>
|
||||||
|
</Timeline.Item>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
if (activeTab === 'inApp') {
|
||||||
|
return renderMarkdownNotice();
|
||||||
|
}
|
||||||
|
return renderAnnouncementTimeline();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t('系统公告')}
|
title={
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span>{t('系统公告')}</span>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
type='card'
|
||||||
|
size='small'
|
||||||
|
>
|
||||||
|
<TabPane tab={<span className="flex items-center gap-1"><Bell size={14} /> {t('通知')}</span>} itemKey='inApp' />
|
||||||
|
<TabPane tab={<span className="flex items-center gap-1"><Megaphone size={14} /> {t('系统公告')}</span>} itemKey='system' />
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={(
|
footer={(
|
||||||
@@ -82,7 +170,7 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
|
|||||||
)}
|
)}
|
||||||
size={isMobile ? 'full-width' : 'large'}
|
size={isMobile ? 'full-width' : 'large'}
|
||||||
>
|
>
|
||||||
{renderContent()}
|
{renderBody()}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1700,5 +1700,6 @@
|
|||||||
"最低充值美元数量": "Minimum recharge dollar amount",
|
"最低充值美元数量": "Minimum recharge dollar amount",
|
||||||
"充值分组倍率": "Recharge group ratio",
|
"充值分组倍率": "Recharge group ratio",
|
||||||
"充值方式设置": "Recharge method settings",
|
"充值方式设置": "Recharge method settings",
|
||||||
"更新支付设置": "Update payment settings"
|
"更新支付设置": "Update payment settings",
|
||||||
|
"通知": "Notice"
|
||||||
}
|
}
|
||||||
@@ -500,4 +500,32 @@ code {
|
|||||||
|
|
||||||
.components-transfer-selected-item .semi-icon-close:hover {
|
.components-transfer-selected-item .semi-icon-close:hover {
|
||||||
color: var(--semi-color-text-0);
|
color: var(--semi-color-text-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 未读通知闪光效果 ==================== */
|
||||||
|
@keyframes sweep-shine {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shine-text {
|
||||||
|
background: linear-gradient(90deg, currentColor 0%, currentColor 40%, rgba(255, 255, 255, 0.9) 50%, currentColor 60%, currentColor 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: sweep-shine 4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .shine-text {
|
||||||
|
background: linear-gradient(90deg, currentColor 0%, currentColor 40%, #facc15 50%, currentColor 60%, currentColor 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user