feat: add notices feature with modal and API integration

This commit is contained in:
shiyu
2026-01-14 22:01:29 +08:00
parent 1679b03d3a
commit 3e1b75d81a
5 changed files with 260 additions and 1 deletions

55
web/src/api/notices.ts Normal file
View File

@@ -0,0 +1,55 @@
export interface NoticeItem {
id: number;
title: string;
contentMd: string;
isPopup: boolean;
createdAt: number;
}
export interface GetNoticesResponse {
items: NoticeItem[];
page: number;
pageSize: number;
total: number;
}
export interface GetNoticesParams {
version: string;
page?: number;
}
const FOXEL_CORE_BASE = 'https://foxel.cc';
function normalizeVersion(version: string) {
return (version || '').trim().replace(/^v/i, '');
}
function extractErrorMessage(data: any) {
if (!data) return '';
if (typeof data === 'string') return data;
if (typeof data.detail === 'string') return data.detail;
if (typeof data.code === 'string') return data.code;
if (typeof data.message === 'string') return data.message;
if (typeof data.msg === 'string') return data.msg;
return '';
}
export const noticesApi = {
list: async (params: GetNoticesParams): Promise<GetNoticesResponse> => {
const url = new URL('/api/notices', FOXEL_CORE_BASE);
url.searchParams.set('version', normalizeVersion(params.version));
url.searchParams.set('page', String(params.page ?? 1));
const resp = await fetch(url.href);
if (!resp.ok) {
let msg = resp.statusText || `Request failed: ${resp.status}`;
try {
const data = await resp.json();
msg = extractErrorMessage(data) || msg;
} catch { void 0; }
throw new Error(msg);
}
return await resp.json();
},
};

View File

@@ -0,0 +1,184 @@
import { memo, useEffect, useMemo, useState } from 'react';
import { Modal, List, Typography, theme, Flex, Button, Empty, message, Divider, Spin } from 'antd';
import ReactMarkdown from 'react-markdown';
import { format } from 'date-fns';
import { noticesApi, type NoticeItem } from '../api/notices';
import { useI18n } from '../i18n';
export interface NoticesModalProps {
open: boolean;
version: string;
onClose: () => void;
}
const NoticesModal = memo(function NoticesModal({ open, version, onClose }: NoticesModalProps) {
const { token } = theme.useToken();
const { t } = useI18n();
const [items, setItems] = useState<NoticeItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const selected = useMemo(() => items.find(i => i.id === selectedId) ?? null, [items, selectedId]);
const hasMore = items.length < total;
const loadPage = async (targetPage: number, mode: 'replace' | 'append') => {
if (mode === 'replace') setLoading(true);
else setLoadingMore(true);
try {
const resp = await noticesApi.list({ version, page: targetPage });
setPage(resp.page ?? targetPage);
setTotal(resp.total ?? 0);
setItems(prev => mode === 'replace' ? resp.items : [...prev, ...resp.items]);
if (mode === 'replace') {
setSelectedId(resp.items[0]?.id ?? null);
} else {
setSelectedId(prev => prev ?? resp.items[0]?.id ?? null);
}
} catch (e) {
if (e instanceof Error) {
message.error(e.message || t('Error'));
}
} finally {
setLoading(false);
setLoadingMore(false);
}
};
useEffect(() => {
if (!open) return;
setItems([]);
setPage(1);
setTotal(0);
setSelectedId(null);
loadPage(1, 'replace');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, version]);
const formatTime = (ts: number) => {
try {
return format(new Date(ts), 'yyyy-MM-dd HH:mm');
} catch {
return '';
}
};
return (
<Modal
title={t('Notices')}
open={open}
onCancel={onClose}
footer={null}
width={980}
styles={{
body: {
padding: 0,
height: '70vh',
overflow: 'hidden',
},
}}
>
<Flex style={{ height: '70vh', minHeight: 0 }}>
<div style={{
width: 320,
minWidth: 280,
borderRight: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}>
<div style={{
padding: '10px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}>
<Typography.Text type="secondary">{t('Total')}: {total}</Typography.Text>
<Typography.Text type="secondary">{items.length}/{total}</Typography.Text>
</div>
<List
size="small"
loading={loading && items.length === 0}
dataSource={items}
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
renderItem={(item) => {
const isSelected = item.id === selectedId;
return (
<List.Item
onClick={() => setSelectedId(item.id)}
style={{
cursor: 'pointer',
background: isSelected ? 'rgba(22,119,255,0.08)' : undefined,
borderInlineStart: isSelected ? `3px solid ${token.colorPrimary}` : '3px solid transparent',
paddingInlineStart: 10,
}}
>
<List.Item.Meta
title={<Typography.Text strong={isSelected}>{item.title}</Typography.Text>}
description={<Typography.Text type="secondary">{formatTime(item.createdAt)}</Typography.Text>}
/>
</List.Item>
);
}}
/>
<div style={{
padding: 12,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}>
<Button
block
loading={loadingMore}
disabled={!hasMore}
onClick={() => loadPage(page + 1, 'append')}
>
{t('Load more')}
</Button>
</div>
</div>
<div style={{ flex: 1, minWidth: 0, padding: 16, overflow: 'auto' }}>
{selected ? (
<>
<Typography.Title level={4} style={{ marginTop: 0, marginBottom: 6 }}>
{selected.title}
</Typography.Title>
<Typography.Text type="secondary">{formatTime(selected.createdAt)}</Typography.Text>
<Divider style={{ margin: '12px 0' }} />
{selected.contentMd?.trim() ? (
<div style={{ color: token.colorText, lineHeight: 1.7 }}>
<ReactMarkdown
components={{
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
ul: ({ ...props }) => <ul style={{ paddingLeft: 20, marginBottom: 12 }} {...props} />,
li: ({ ...props }) => <li style={{ marginBottom: 6 }} {...props} />,
p: ({ ...props }) => <p style={{ marginBottom: 12 }} {...props} />,
}}
>
{selected.contentMd}
</ReactMarkdown>
</div>
) : (
<Empty description={t('No content')} />
)}
</>
) : (
loading ? (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
<Spin />
</div>
) : (
<Empty description={t('No notices')} />
)
)}
</div>
</Flex>
</Modal>
);
});
export default NoticesModal;

View File

@@ -693,6 +693,9 @@
"Open with {app}": "Open with {app}",
"Set as default for .{ext}": "Set as default for .{ext}",
"AI Agent": "AI Agent",
"Notices": "Notices",
"No notices": "No notices",
"Load more": "Load more",
"Auto execute": "Auto execute",
"Start a conversation": "Start a conversation",
"No content": "No content",

View File

@@ -695,6 +695,9 @@
"Open with {app}": "使用 {app} 打开",
"Set as default for .{ext}": "设为该类型(.{ext})默认应用",
"AI Agent": "AI 助手",
"Notices": "公告",
"No notices": "暂无公告",
"Load more": "加载更多",
"Auto execute": "自动执行",
"Start a conversation": "开始对话",
"No content": "无内容",

View File

@@ -1,5 +1,5 @@
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip } from 'antd';
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined } from '@ant-design/icons';
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined } from '@ant-design/icons';
import { memo, useState } from 'react';
import SearchDialog from './SearchDialog.tsx';
import { authApi } from '../api/auth.ts';
@@ -8,6 +8,8 @@ import { useI18n } from '../i18n';
import LanguageSwitcher from '../components/LanguageSwitcher';
import { useAuth } from '../contexts/AuthContext';
import ProfileModal from '../components/ProfileModal';
import NoticesModal from '../components/NoticesModal';
import { useSystemStatus } from '../contexts/SystemContext';
const { Header } = Layout;
@@ -24,6 +26,8 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
const { t } = useI18n();
const { user } = useAuth();
const [profileOpen, setProfileOpen] = useState(false);
const [noticesOpen, setNoticesOpen] = useState(false);
const status = useSystemStatus();
const handleLogout = () => {
authApi.logout();
@@ -51,6 +55,15 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
</Button>
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
<Tooltip title={t('Notices')}>
<Button
type="text"
icon={<BellOutlined />}
aria-label={t('Notices')}
onClick={() => setNoticesOpen(true)}
style={{ paddingInline: 8, height: 40 }}
/>
</Tooltip>
<Tooltip title={t('AI Agent')}>
<Button
type="text"
@@ -81,6 +94,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
</Button>
</Dropdown>
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
</Flex>
</Header>
);