mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 23:57:19 +08:00
feat: add notices feature with modal and API integration
This commit is contained in:
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -693,6 +693,9 @@
|
|||||||
"Open with {app}": "Open with {app}",
|
"Open with {app}": "Open with {app}",
|
||||||
"Set as default for .{ext}": "Set as default for .{ext}",
|
"Set as default for .{ext}": "Set as default for .{ext}",
|
||||||
"AI Agent": "AI Agent",
|
"AI Agent": "AI Agent",
|
||||||
|
"Notices": "Notices",
|
||||||
|
"No notices": "No notices",
|
||||||
|
"Load more": "Load more",
|
||||||
"Auto execute": "Auto execute",
|
"Auto execute": "Auto execute",
|
||||||
"Start a conversation": "Start a conversation",
|
"Start a conversation": "Start a conversation",
|
||||||
"No content": "No content",
|
"No content": "No content",
|
||||||
|
|||||||
@@ -695,6 +695,9 @@
|
|||||||
"Open with {app}": "使用 {app} 打开",
|
"Open with {app}": "使用 {app} 打开",
|
||||||
"Set as default for .{ext}": "设为该类型(.{ext})默认应用",
|
"Set as default for .{ext}": "设为该类型(.{ext})默认应用",
|
||||||
"AI Agent": "AI 助手",
|
"AI Agent": "AI 助手",
|
||||||
|
"Notices": "公告",
|
||||||
|
"No notices": "暂无公告",
|
||||||
|
"Load more": "加载更多",
|
||||||
"Auto execute": "自动执行",
|
"Auto execute": "自动执行",
|
||||||
"Start a conversation": "开始对话",
|
"Start a conversation": "开始对话",
|
||||||
"No content": "无内容",
|
"No content": "无内容",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip } from 'antd';
|
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 { memo, useState } from 'react';
|
||||||
import SearchDialog from './SearchDialog.tsx';
|
import SearchDialog from './SearchDialog.tsx';
|
||||||
import { authApi } from '../api/auth.ts';
|
import { authApi } from '../api/auth.ts';
|
||||||
@@ -8,6 +8,8 @@ import { useI18n } from '../i18n';
|
|||||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ProfileModal from '../components/ProfileModal';
|
import ProfileModal from '../components/ProfileModal';
|
||||||
|
import NoticesModal from '../components/NoticesModal';
|
||||||
|
import { useSystemStatus } from '../contexts/SystemContext';
|
||||||
|
|
||||||
const { Header } = Layout;
|
const { Header } = Layout;
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
|
const [noticesOpen, setNoticesOpen] = useState(false);
|
||||||
|
const status = useSystemStatus();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
authApi.logout();
|
authApi.logout();
|
||||||
@@ -51,6 +55,15 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
|
|||||||
</Button>
|
</Button>
|
||||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
|
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
|
<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')}>
|
<Tooltip title={t('AI Agent')}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -81,6 +94,7 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }
|
|||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
|
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
|
||||||
|
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Header>
|
</Header>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user