feat: 实现公告板功能
- 后端:从 GitHub Issues API 获取公告数据 - 支持筛选指定 assignees 的评论 - 支持排除指定 issue 的评论 - 从评论第一行提取标题(移除 Markdown 格式) - 支持获取 reactions 数据(+1、heart、laugh 等) - 实现 10 分钟缓存机制 - 强制刷新时,如果被限流则保留缓存数据 - 添加 GitHub API 健康检查 - 前端:公告列表和详情页面 - 响应式设计(移动端和桌面端) - 移动端使用侧边抽屉显示列表 - 支持 Markdown 渲染 - 支持内容折叠(超过 30 行) - 显示 reactions(使用 emoji) - 添加刷新按钮(支持强制刷新) - 公告菜单项放在第一位 - 默认页面改为公告页 - 其他: - 更新 favicon 为 SVG 格式 - 添加 GitHub API 限流方案文档 - 添加 GitHub Token 配置文档
This commit is contained in:
@@ -32,6 +32,7 @@ import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders'
|
||||
import FilteredOrdersList from './pages/FilteredOrdersList'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
import ApiHealthStatus from './pages/ApiHealthStatus'
|
||||
import Announcements from './pages/Announcements'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
import { apiService } from './services/api'
|
||||
@@ -236,7 +237,7 @@ function App() {
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
{/* 受保护的路由 */}
|
||||
<Route path="/" element={<ProtectedRoute><AccountList /></ProtectedRoute>} />
|
||||
<Route path="/" element={<ProtectedRoute><Announcements /></ProtectedRoute>} />
|
||||
<Route path="/accounts" element={<ProtectedRoute><AccountList /></ProtectedRoute>} />
|
||||
<Route path="/accounts/import" element={<ProtectedRoute><AccountImport /></ProtectedRoute>} />
|
||||
<Route path="/accounts/detail" element={<ProtectedRoute><AccountDetail /></ProtectedRoute>} />
|
||||
@@ -259,6 +260,7 @@ function App() {
|
||||
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
|
||||
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
||||
<Route path="/announcements" element={<ProtectedRoute><Announcements /></ProtectedRoute>} />
|
||||
<Route path="/system-settings" element={<ProtectedRoute><SystemSettings /></ProtectedRoute>} />
|
||||
<Route path="/system-settings/api-health" element={<ProtectedRoute><ApiHealthStatus /></ProtectedRoute>} />
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
GithubOutlined,
|
||||
TwitterOutlined,
|
||||
CheckCircleOutlined,
|
||||
SendOutlined
|
||||
SendOutlined,
|
||||
NotificationOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -73,6 +74,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}, [location.pathname])
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '/announcements',
|
||||
icon: <NotificationOutlined />,
|
||||
label: t('menu.announcements') || '公告'
|
||||
},
|
||||
{
|
||||
key: '/accounts',
|
||||
icon: <WalletOutlined />,
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"copyTradingConfig": "Copy Trading Config",
|
||||
"positions": "Position Management",
|
||||
"statistics": "Statistics",
|
||||
"announcements": "Announcements",
|
||||
"users": "User Management",
|
||||
"systemSettings": "System",
|
||||
"systemOverview": "Overview",
|
||||
@@ -952,5 +953,18 @@
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlPlaceholder": "Webhook URL (from Slack App settings)",
|
||||
"webhookUrlRequired": "Please enter Webhook URL"
|
||||
},
|
||||
"announcements": {
|
||||
"title": "Announcements",
|
||||
"noAnnouncements": "No announcements",
|
||||
"noDetail": "Please select an announcement to view details",
|
||||
"viewMore": "View more announcements",
|
||||
"author": "Author",
|
||||
"createdAt": "Published at",
|
||||
"noTitle": "No title",
|
||||
"expand": "Expand all",
|
||||
"collapse": "Collapse",
|
||||
"refresh": "Refresh",
|
||||
"list": "List"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"copyTradingConfig": "跟单配置",
|
||||
"positions": "仓位管理",
|
||||
"statistics": "统计信息",
|
||||
"announcements": "公告",
|
||||
"users": "用户管理",
|
||||
"systemSettings": "系统管理",
|
||||
"systemOverview": "概览",
|
||||
@@ -869,5 +870,18 @@
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)",
|
||||
"webhookUrlRequired": "请输入 Webhook URL"
|
||||
},
|
||||
"announcements": {
|
||||
"title": "公告",
|
||||
"noAnnouncements": "暂无公告",
|
||||
"noDetail": "请选择一条公告查看详情",
|
||||
"viewMore": "查看更多公告",
|
||||
"author": "作者",
|
||||
"createdAt": "发布时间",
|
||||
"noTitle": "无标题",
|
||||
"expand": "展开全部",
|
||||
"collapse": "收起",
|
||||
"refresh": "刷新",
|
||||
"list": "列表"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"copyTradingConfig": "跟單配置",
|
||||
"positions": "倉位管理",
|
||||
"statistics": "統計信息",
|
||||
"announcements": "公告",
|
||||
"users": "用戶管理",
|
||||
"systemSettings": "系統管理",
|
||||
"systemOverview": "概覽",
|
||||
@@ -952,5 +953,18 @@
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)",
|
||||
"webhookUrlRequired": "請輸入 Webhook URL"
|
||||
},
|
||||
"announcements": {
|
||||
"title": "公告",
|
||||
"noAnnouncements": "暫無公告",
|
||||
"noDetail": "請選擇一條公告查看詳情",
|
||||
"viewMore": "查看更多公告",
|
||||
"author": "作者",
|
||||
"createdAt": "發布時間",
|
||||
"noTitle": "無標題",
|
||||
"expand": "展開全部",
|
||||
"collapse": "收起",
|
||||
"refresh": "刷新",
|
||||
"list": "列表"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { Card, List, Spin, Empty, Typography, Button, Avatar, Drawer } from 'antd'
|
||||
import { MessageOutlined, LinkOutlined, UpOutlined, DownOutlined, ReloadOutlined, UnorderedListOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface Reactions {
|
||||
plusOne?: number
|
||||
minusOne?: number
|
||||
laugh?: number
|
||||
confused?: number
|
||||
heart?: number
|
||||
hooray?: number
|
||||
eyes?: number
|
||||
rocket?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
interface Announcement {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
author: string
|
||||
authorAvatarUrl?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
reactions?: Reactions
|
||||
}
|
||||
|
||||
const Announcements: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([])
|
||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnnouncements()
|
||||
fetchLatestDetail()
|
||||
}, [])
|
||||
|
||||
const fetchAnnouncements = async (forceRefresh: boolean = false) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.announcements.list({ forceRefresh })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setAnnouncements(response.data.data.list || [])
|
||||
setHasMore(response.data.data.hasMore || false)
|
||||
} else {
|
||||
console.error('获取公告列表失败:', response.data.msg)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取公告列表异常:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLatestDetail = async (forceRefresh: boolean = false) => {
|
||||
setLoadingDetail(true)
|
||||
try {
|
||||
const response = await apiService.announcements.detail({ forceRefresh })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setSelectedAnnouncement(response.data.data)
|
||||
} else {
|
||||
console.error('获取公告详情失败:', response.data.msg)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取公告详情异常:', error)
|
||||
} finally {
|
||||
setLoadingDetail(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAnnouncement = async (id: number, forceRefresh: boolean = false) => {
|
||||
setLoadingDetail(true)
|
||||
try {
|
||||
const response = await apiService.announcements.detail({ id, forceRefresh })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setSelectedAnnouncement(response.data.data)
|
||||
// 移动端选择公告后关闭抽屉
|
||||
if (isMobile) {
|
||||
setDrawerVisible(false)
|
||||
}
|
||||
} else {
|
||||
console.error('获取公告详情失败:', response.data.msg)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取公告详情异常:', error)
|
||||
} finally {
|
||||
setLoadingDetail(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([
|
||||
fetchAnnouncements(true),
|
||||
fetchLatestDetail(true)
|
||||
])
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 计算内容行数(通过换行符计算)
|
||||
const getLineCount = (text: string): number => {
|
||||
if (!text) return 0
|
||||
return text.split('\n').length
|
||||
}
|
||||
|
||||
// 检查是否需要折叠(超过30行)
|
||||
const shouldCollapse = (body: string): boolean => {
|
||||
return getLineCount(body) > 30
|
||||
}
|
||||
|
||||
// 当选中公告改变时,重置展开状态
|
||||
useEffect(() => {
|
||||
if (selectedAnnouncement) {
|
||||
const shouldCollapseContent = shouldCollapse(selectedAnnouncement.body)
|
||||
setIsExpanded(!shouldCollapseContent) // 如果超过30行,默认折叠(isExpanded = false)
|
||||
}
|
||||
}, [selectedAnnouncement])
|
||||
|
||||
// 渲染公告详情内容(带折叠功能)
|
||||
const renderAnnouncementContent = (announcement: Announcement, isMobileView: boolean) => {
|
||||
const lineCount = getLineCount(announcement.body)
|
||||
const needsCollapse = shouldCollapse(announcement.body)
|
||||
const showCollapseButton = needsCollapse
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Avatar
|
||||
src={announcement.authorAvatarUrl}
|
||||
icon={<MessageOutlined />}
|
||||
size={isMobileView ? 'default' : 'large'}
|
||||
/>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: isMobileView ? 14 : 16 }}>
|
||||
{announcement.author}
|
||||
</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDate(announcement.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{
|
||||
padding: isMobileView ? '16px' : '24px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '4px',
|
||||
minHeight: isMobileView ? '200px' : '400px',
|
||||
maxHeight: needsCollapse && !isExpanded ? '600px' : 'none',
|
||||
overflow: needsCollapse && !isExpanded ? 'hidden' : 'visible',
|
||||
transition: 'max-height 0.3s ease',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{announcement.body}
|
||||
</ReactMarkdown>
|
||||
{needsCollapse && !isExpanded && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '80px',
|
||||
background: 'linear-gradient(to bottom, rgba(250, 250, 250, 0), rgba(250, 250, 250, 1))',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showCollapseButton && (
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded
|
||||
? (t('announcements.collapse') || '收起')
|
||||
: (t('announcements.expand') || `展开全部 (共 ${lineCount} 行)`)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染 reactions(使用 emoji)
|
||||
const renderReactions = (reactions?: Reactions) => {
|
||||
if (!reactions || reactions.total === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const reactionItems: Array<{ emoji: string; count: number; key: string }> = []
|
||||
|
||||
if (reactions.plusOne && reactions.plusOne > 0) {
|
||||
reactionItems.push({ emoji: '👍', count: reactions.plusOne, key: 'plusOne' })
|
||||
}
|
||||
if (reactions.minusOne && reactions.minusOne > 0) {
|
||||
reactionItems.push({ emoji: '👎', count: reactions.minusOne, key: 'minusOne' })
|
||||
}
|
||||
if (reactions.laugh && reactions.laugh > 0) {
|
||||
reactionItems.push({ emoji: '😄', count: reactions.laugh, key: 'laugh' })
|
||||
}
|
||||
if (reactions.confused && reactions.confused > 0) {
|
||||
reactionItems.push({ emoji: '😕', count: reactions.confused, key: 'confused' })
|
||||
}
|
||||
if (reactions.heart && reactions.heart > 0) {
|
||||
reactionItems.push({ emoji: '❤️', count: reactions.heart, key: 'heart' })
|
||||
}
|
||||
if (reactions.hooray && reactions.hooray > 0) {
|
||||
reactionItems.push({ emoji: '🎉', count: reactions.hooray, key: 'hooray' })
|
||||
}
|
||||
if (reactions.eyes && reactions.eyes > 0) {
|
||||
reactionItems.push({ emoji: '👀', count: reactions.eyes, key: 'eyes' })
|
||||
}
|
||||
if (reactions.rocket && reactions.rocket > 0) {
|
||||
reactionItems.push({ emoji: '🚀', count: reactions.rocket, key: 'rocket' })
|
||||
}
|
||||
|
||||
if (reactionItems.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid #f0f0f0'
|
||||
}}>
|
||||
{reactionItems.map((item) => (
|
||||
<span
|
||||
key={item.key}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '13px',
|
||||
color: '#595959',
|
||||
backgroundColor: '#fafafa',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e8e8e8'
|
||||
}}
|
||||
>
|
||||
<span>{item.emoji}</span>
|
||||
<span style={{ fontWeight: 500 }}>{item.count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染公告列表(用于抽屉)
|
||||
const renderAnnouncementList = () => {
|
||||
return (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : announcements.length === 0 ? (
|
||||
<Empty description={t('announcements.noAnnouncements') || '暂无公告'} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{announcements.map((item) => {
|
||||
const isSelected = selectedAnnouncement?.id === item.id
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
onClick={() => handleSelectAnnouncement(item.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: '12px',
|
||||
boxShadow: isSelected
|
||||
? '0 4px 12px rgba(24, 144, 255, 0.2)'
|
||||
: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: isSelected
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #e8e8e8',
|
||||
backgroundColor: isSelected ? '#f0f8ff' : '#ffffff',
|
||||
transition: 'all 0.3s ease',
|
||||
transform: isSelected ? 'scale(1.02)' : 'scale(1)'
|
||||
}}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* 标题 */}
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#262626',
|
||||
lineHeight: '1.5',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{item.title || t('announcements.noTitle') || '无标题'}
|
||||
</div>
|
||||
|
||||
{/* 时间和作者 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#8c8c8c'
|
||||
}}>
|
||||
<Avatar
|
||||
src={item.authorAvatarUrl}
|
||||
icon={<MessageOutlined />}
|
||||
size="small"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{ fontWeight: 500 }}>{item.author}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Reactions */}
|
||||
{renderReactions(item.reactions)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<LinkOutlined />}
|
||||
href="https://github.com/WrBug/PolyHermes/issues/1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('announcements.viewMore') || '查看更多公告'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
// 移动端布局:详情在主要内容区,列表在侧边抽屉
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t('announcements.list') || '列表'}
|
||||
</Button>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{t('announcements.title') || '公告'}
|
||||
</Title>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading || loadingDetail}
|
||||
size="small"
|
||||
>
|
||||
{t('announcements.refresh') || '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 公告详情 */}
|
||||
<div>
|
||||
{loadingDetail ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : selectedAnnouncement ? (
|
||||
renderAnnouncementContent(selectedAnnouncement, true)
|
||||
) : (
|
||||
<Empty description={t('announcements.noDetail') || '请选择一条公告查看详情'} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 侧边抽屉:公告列表 */}
|
||||
<Drawer
|
||||
title={t('announcements.list') || '公告列表'}
|
||||
placement="right"
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
open={drawerVisible}
|
||||
width="85%"
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
{renderAnnouncementList()}
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 桌面端布局:左右结构
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
{t('announcements.title') || '公告'}
|
||||
</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading || loadingDetail}
|
||||
>
|
||||
{t('announcements.refresh') || '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 24, minHeight: '600px' }}>
|
||||
{/* 左侧:公告列表 */}
|
||||
<div style={{ width: '300px', flexShrink: 0 }}>
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={announcements}
|
||||
locale={{ emptyText: <Empty description={t('announcements.noAnnouncements') || '暂无公告'} /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedAnnouncement?.id === item.id ? '#e6f7ff' : 'transparent',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
border: selectedAnnouncement?.id === item.id ? '1px solid #1890ff' : '1px solid transparent'
|
||||
}}
|
||||
onClick={() => handleSelectAnnouncement(item.id)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{item.title || t('announcements.noTitle') || '无标题'}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
{formatDate(item.createdAt)}
|
||||
</Text>
|
||||
{renderReactions(item.reactions)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
{hasMore && (
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<LinkOutlined />}
|
||||
href="https://github.com/WrBug/PolyHermes/issues/1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('announcements.viewMore') || '查看更多公告'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:公告详情 */}
|
||||
<div style={{ flex: 1, borderLeft: '1px solid #e8e8e8', paddingLeft: 24 }}>
|
||||
{loadingDetail ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : selectedAnnouncement ? (
|
||||
renderAnnouncementContent(selectedAnnouncement, false)
|
||||
) : (
|
||||
<Empty description={t('announcements.noDetail') || '请选择一条公告查看详情'} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Announcements
|
||||
|
||||
@@ -613,6 +613,65 @@ export const apiService = {
|
||||
*/
|
||||
getAutoRedeemStatus: () =>
|
||||
apiClient.post<ApiResponse<{ enabled: boolean }>>('/system/config/auto-redeem/status', {})
|
||||
},
|
||||
|
||||
/**
|
||||
* 公告 API
|
||||
*/
|
||||
announcements: {
|
||||
/**
|
||||
* 获取公告列表(最近10条)
|
||||
*/
|
||||
list: (data?: { forceRefresh?: boolean }) =>
|
||||
apiClient.post<ApiResponse<{
|
||||
list: Array<{
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
author: string
|
||||
authorAvatarUrl?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
reactions?: {
|
||||
plusOne?: number
|
||||
minusOne?: number
|
||||
laugh?: number
|
||||
confused?: number
|
||||
heart?: number
|
||||
hooray?: number
|
||||
eyes?: number
|
||||
rocket?: number
|
||||
total?: number
|
||||
}
|
||||
}>
|
||||
hasMore: boolean
|
||||
total: number
|
||||
}>>('/announcements/list', data || {}),
|
||||
|
||||
/**
|
||||
* 获取公告详情
|
||||
*/
|
||||
detail: (data: { id?: number; forceRefresh?: boolean }) =>
|
||||
apiClient.post<ApiResponse<{
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
author: string
|
||||
authorAvatarUrl?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
reactions?: {
|
||||
plusOne?: number
|
||||
minusOne?: number
|
||||
laugh?: number
|
||||
confused?: number
|
||||
heart?: number
|
||||
hooray?: number
|
||||
eyes?: number
|
||||
rocket?: number
|
||||
total?: number
|
||||
}
|
||||
}>>('/announcements/detail', data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user