mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-12 16:03:27 +08:00
feat: add notices feature with modal and API integration
This commit is contained in:
55
web/src/api/notices.ts
Normal file
55
web/src/api/notices.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
|
||||
184
web/src/components/NoticesModal.tsx
Normal file
184
web/src/components/NoticesModal.tsx
Normal 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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "无内容",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user