Просмотр исходного кода

✨ 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.
Apple\Apple 8 месяцев назад
Родитель
Сommit
1ad2f63f85

+ 80 - 12
web/src/components/layout/HeaderBar.js

@@ -28,6 +28,7 @@ import {
   Tag,
   Typography,
   Skeleton,
+  Badge,
 } from '@douyinfe/semi-ui';
 import { StatusContext } from '../../context/Status/index.js';
 import { useStyle, styleActions } from '../../context/Style/index.js';
@@ -43,6 +44,7 @@ const HeaderBar = () => {
   const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
   const location = useLocation();
   const [noticeVisible, setNoticeVisible] = useState(false);
+  const [unreadCount, setUnreadCount] = useState(0);
 
   const systemName = getSystemName();
   const logo = getLogo();
@@ -53,9 +55,44 @@ const HeaderBar = () => {
   const docsLink = statusState?.status?.docs_link || '';
   const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
 
+  const isConsoleRoute = location.pathname.startsWith('/console');
+
   const theme = useTheme();
   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 = [
     {
       text: t('首页'),
@@ -106,6 +143,25 @@ const HeaderBar = () => {
     }, 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(() => {
     if (theme === 'dark') {
       document.body.setAttribute('theme-mode', 'dark');
@@ -353,15 +409,14 @@ const HeaderBar = () => {
     }
   };
 
-  // 检查当前路由是否以/console开头
-  const isConsoleRoute = location.pathname.startsWith('/console');
-
   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">
       <NoticeModal
         visible={noticeVisible}
-        onClose={() => setNoticeVisible(false)}
+        onClose={handleNoticeClose}
         isMobile={styleState.isMobile}
+        defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
+        unreadKeys={getUnreadKeys()}
       />
       <div className="w-full px-2">
         <div className="flex items-center justify-between h-16">
@@ -462,14 +517,27 @@ const HeaderBar = () => {
               </Dropdown>
             )}
 
-            <Button
-              icon={<IconBell className="text-lg" />}
-              aria-label={t('系统公告')}
-              onClick={() => setNoticeVisible(true)}
-              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"
-            />
+            {unreadCount > 0 ? (
+              <Badge count={unreadCount} type="danger" overflowCount={99}>
+                <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"
+                />
+              </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
               icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}

+ 96 - 8
web/src/components/layout/NoticeModal.js

@@ -1,14 +1,36 @@
-import React, { useEffect, useState } from 'react';
-import { Button, Modal, Empty } from '@douyinfe/semi-ui';
+import React, { useEffect, useState, useContext, useMemo } from 'react';
+import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
-import { API, showError } from '../../helpers';
+import { API, showError, getRelativeTime } from '../../helpers';
 import { marked } from 'marked';
 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 [noticeContent, setNoticeContent] = useState('');
   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 today = new Date().toDateString();
@@ -44,7 +66,13 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
     }
   }, [visible]);
 
-  const renderContent = () => {
+  useEffect(() => {
+    if (visible) {
+      setActiveTab(defaultTab);
+    }
+  }, [defaultTab, visible]);
+
+  const renderMarkdownNotice = () => {
     if (loading) {
       return <div className="py-12"><Empty description={t('加载中...')} /></div>;
     }
@@ -64,14 +92,74 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
     return (
       <div
         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 (
     <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}
       onCancel={onClose}
       footer={(
@@ -82,7 +170,7 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
       )}
       size={isMobile ? 'full-width' : 'large'}
     >
-      {renderContent()}
+      {renderBody()}
     </Modal>
   );
 };

+ 2 - 1
web/src/i18n/locales/en.json

@@ -1700,5 +1700,6 @@
   "最低充值美元数量": "Minimum recharge dollar amount",
   "充值分组倍率": "Recharge group ratio",
   "充值方式设置": "Recharge method settings",
-  "更新支付设置": "Update payment settings"
+  "更新支付设置": "Update payment settings",
+  "通知": "Notice"
 }

+ 28 - 0
web/src/index.css

@@ -500,4 +500,32 @@ code {
 
 .components-transfer-selected-item .semi-icon-close:hover {
   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;
 }