TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 1 | import 'antd/dist/antd.css'; |
| 2 | import React, { useState, useEffect, useMemo, useCallback } from 'react'; |
| 3 | import { Layout, Tabs, Input, List, Card, Button, Tag, Spin, Typography, Divider } from 'antd'; |
| 4 | import '../style/Admin.css'; |
TRM-coding | 85e5c32 | 2025-06-18 19:49:21 +0800 | [diff] [blame] | 5 | import { fetchPosts, approvePost, rejectPost } from '../api/posts_trm'; |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 6 | |
| 7 | export default function Admin() { |
| 8 | const ADMIN_USER_ID = 2; |
| 9 | const [posts, setPosts] = useState([]); |
| 10 | const [loading, setLoading] = useState(true); |
| 11 | const [hasPermission, setHasPermission] = useState(true); |
| 12 | const [activeTab, setActiveTab] = useState('all'); |
| 13 | const [selectedPost, setSelectedPost] = useState(null); |
| 14 | const [searchTerm, setSearchTerm] = useState(''); |
| 15 | |
| 16 | // 新增:拖拽相关状态 |
| 17 | const [leftPanelWidth, setLeftPanelWidth] = useState(300); |
| 18 | const [isResizing, setIsResizing] = useState(false); |
| 19 | |
| 20 | const statusColors = { |
| 21 | draft: 'orange', |
| 22 | pending: 'blue', |
| 23 | published: 'green', |
| 24 | deleted: 'gray', |
| 25 | rejected: 'red' |
| 26 | }; |
| 27 | |
| 28 | useEffect(() => { |
| 29 | async function load() { |
| 30 | try { |
| 31 | const list = await fetchPosts(ADMIN_USER_ID) |
| 32 | setPosts(list) |
| 33 | } catch (e) { |
| 34 | if (e.message === 'Unauthorized') { |
| 35 | setHasPermission(false) |
| 36 | } else { |
| 37 | console.error(e) |
| 38 | } |
| 39 | } finally { |
| 40 | setLoading(false) |
| 41 | } |
| 42 | } |
| 43 | load() |
| 44 | }, []) |
| 45 | |
| 46 | // 过滤并排序 |
| 47 | const sortedPosts = useMemo(() => { |
| 48 | return [...posts].sort((a, b) => { |
| 49 | if (a.status === 'pending' && b.status !== 'pending') return -1 |
| 50 | if (b.status === 'pending' && a.status !== 'pending') return 1 |
| 51 | return 0 |
| 52 | }) |
| 53 | }, [posts]) |
| 54 | |
| 55 | // 调整:根据 activeTab 及搜索关键词过滤 |
| 56 | const filteredPosts = useMemo(() => { |
| 57 | let list |
| 58 | switch (activeTab) { |
| 59 | case 'pending': |
| 60 | list = sortedPosts.filter(p => p.status === 'pending'); break |
| 61 | case 'published': |
| 62 | list = sortedPosts.filter(p => p.status === 'published'); break |
| 63 | case 'rejected': |
| 64 | list = sortedPosts.filter(p => p.status === 'rejected'); break |
| 65 | default: |
| 66 | list = sortedPosts |
| 67 | } |
| 68 | return list.filter(p => |
| 69 | p.title.toLowerCase().includes(searchTerm.toLowerCase()) |
| 70 | ) |
| 71 | }, [sortedPosts, activeTab, searchTerm]) |
| 72 | |
| 73 | const handleApprove = async id => { |
| 74 | await approvePost(id, ADMIN_USER_ID) |
| 75 | setPosts(ps => ps.map(x => x.id === id ? { ...x, status: 'published' } : x)) |
| 76 | // 同步更新选中的帖子状态 |
| 77 | if (selectedPost?.id === id) { |
| 78 | setSelectedPost(prev => ({ ...prev, status: 'published' })); |
| 79 | } |
| 80 | } |
| 81 | const handleReject = async id => { |
| 82 | await rejectPost(id, ADMIN_USER_ID) |
| 83 | setPosts(ps => ps.map(x => x.id === id ? { ...x, status: 'rejected' } : x)) |
| 84 | // 同步更新选中的帖子状态 |
| 85 | if (selectedPost?.id === id) { |
| 86 | setSelectedPost(prev => ({ ...prev, status: 'rejected' })); |
| 87 | } |
| 88 | } |
| 89 | const handleSelect = post => setSelectedPost(post) |
| 90 | |
| 91 | // 修复:拖拽处理函数 |
| 92 | const handleMouseMove = useCallback((e) => { |
| 93 | if (!isResizing) return; |
| 94 | |
| 95 | const newWidth = e.clientX; |
| 96 | const minWidth = 200; |
| 97 | const maxWidth = window.innerWidth - 300; |
| 98 | |
| 99 | if (newWidth >= minWidth && newWidth <= maxWidth) { |
| 100 | setLeftPanelWidth(newWidth); |
| 101 | } |
| 102 | }, [isResizing]); |
| 103 | |
| 104 | const handleMouseUp = useCallback(() => { |
| 105 | setIsResizing(false); |
| 106 | document.removeEventListener('mousemove', handleMouseMove); |
| 107 | document.removeEventListener('mouseup', handleMouseUp); |
| 108 | document.body.style.cursor = ''; |
| 109 | document.body.style.userSelect = ''; |
| 110 | }, [handleMouseMove]); |
| 111 | |
| 112 | const handleMouseDown = useCallback((e) => { |
| 113 | e.preventDefault(); |
| 114 | setIsResizing(true); |
| 115 | document.addEventListener('mousemove', handleMouseMove); |
| 116 | document.addEventListener('mouseup', handleMouseUp); |
| 117 | document.body.style.cursor = 'col-resize'; |
| 118 | document.body.style.userSelect = 'none'; |
| 119 | }, [handleMouseMove, handleMouseUp]); |
| 120 | |
| 121 | // 新增:组件卸载时清理事件监听器 |
| 122 | useEffect(() => { |
| 123 | return () => { |
| 124 | document.removeEventListener('mousemove', handleMouseMove); |
| 125 | document.removeEventListener('mouseup', handleMouseUp); |
| 126 | document.body.style.cursor = ''; |
| 127 | document.body.style.userSelect = ''; |
| 128 | }; |
| 129 | }, [handleMouseMove, handleMouseUp]); |
| 130 | |
| 131 | if (loading) return <Spin spinning tip="加载中…" style={{ width: '100%', marginTop: 100 }} />; |
| 132 | if (!hasPermission) return <div style={{ textAlign: 'center', marginTop: 100 }}>权限不足</div>; |
| 133 | |
| 134 | const { Content } = Layout; |
| 135 | const { TabPane } = Tabs; |
| 136 | const { Title, Text } = Typography; |
| 137 | |
| 138 | return ( |
| 139 | <div style={{ height: '100vh', display: 'flex' }}> |
| 140 | {/* 左侧面板 */} |
| 141 | <div |
| 142 | style={{ |
| 143 | width: leftPanelWidth, |
| 144 | background: '#fff', |
| 145 | padding: 16, |
| 146 | borderRight: '1px solid #f0f0f0', |
| 147 | overflow: 'hidden' |
| 148 | }} |
| 149 | > |
| 150 | <div style={{ marginBottom: 24 }}> |
| 151 | <Title level={3}>小红书</Title> |
| 152 | <Input.Search |
| 153 | placeholder="搜索帖子标题..." |
| 154 | value={searchTerm} |
| 155 | onChange={e => setSearchTerm(e.target.value)} |
| 156 | enterButton |
| 157 | /> |
| 158 | </div> |
| 159 | <Tabs activeKey={activeTab} onChange={key => { setActiveTab(key); setSelectedPost(null); }}> |
| 160 | <TabPane tab="全部" key="all" /> |
| 161 | <TabPane tab="待审核" key="pending" /> |
| 162 | <TabPane tab="已通过" key="published" /> |
| 163 | <TabPane tab="已驳回" key="rejected" /> |
| 164 | </Tabs> |
| 165 | <div style={{ height: 'calc(100vh - 200px)', overflow: 'auto' }}> |
| 166 | <List |
| 167 | dataSource={filteredPosts} |
| 168 | pagination={{ |
| 169 | pageSize: 5, |
| 170 | showSizeChanger: true, |
| 171 | pageSizeOptions: ['5','10','20'], |
| 172 | onChange: () => setSelectedPost(null) |
| 173 | }} |
| 174 | renderItem={p => ( |
| 175 | <List.Item |
| 176 | key={p.id} |
| 177 | style={{ |
| 178 | background: selectedPost?.id === p.id ? '#e6f7ff' : '', |
| 179 | cursor: 'pointer', |
| 180 | marginBottom: 8 |
| 181 | }} |
| 182 | onClick={() => handleSelect(p)} |
| 183 | > |
| 184 | <List.Item.Meta |
| 185 | avatar={ |
| 186 | p.thumbnail && ( |
| 187 | <img |
| 188 | src={p.thumbnail} |
| 189 | alt="" |
| 190 | style={{ width: 64, height: 64, objectFit: 'cover' }} |
| 191 | /> |
| 192 | ) |
| 193 | } |
| 194 | title={p.title} |
| 195 | description={`${p.createdAt} · ${p.author} · ${p.likes || 0}赞`} |
| 196 | /> |
| 197 | <Tag color={statusColors[p.status]}>{p.status}</Tag> |
| 198 | </List.Item> |
| 199 | )} |
| 200 | /> |
| 201 | </div> |
| 202 | </div> |
| 203 | |
| 204 | {/* 拖拽分割条 */} |
| 205 | <div |
| 206 | style={{ |
| 207 | width: 5, |
| 208 | cursor: 'col-resize', |
| 209 | background: isResizing ? '#1890ff' : '#f0f0f0', |
| 210 | transition: isResizing ? 'none' : 'background-color 0.2s', |
| 211 | position: 'relative', |
| 212 | flexShrink: 0 |
| 213 | }} |
| 214 | onMouseDown={handleMouseDown} |
| 215 | onSelectStart={(e) => e.preventDefault()} |
| 216 | > |
| 217 | <div |
| 218 | style={{ |
| 219 | position: 'absolute', |
| 220 | top: '50%', |
| 221 | left: '50%', |
| 222 | transform: 'translate(-50%, -50%)', |
| 223 | width: 2, |
| 224 | height: 20, |
| 225 | background: '#999', |
| 226 | borderRadius: 1 |
| 227 | }} |
| 228 | /> |
| 229 | </div> |
| 230 | |
| 231 | {/* 右侧内容区域 */} |
| 232 | <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}> |
| 233 | <Content style={{ padding: 24, background: '#fff', overflow: 'auto' }}> |
| 234 | {selectedPost ? ( |
| 235 | <Card |
| 236 | cover={selectedPost.image && <img alt="cover" src={selectedPost.image} />} |
| 237 | title={selectedPost.title} |
| 238 | extra={ |
| 239 | <div> |
| 240 | {selectedPost.status === 'pending' && ( |
| 241 | <> |
| 242 | <Button type="primary" onClick={() => handleApprove(selectedPost.id)}>通过</Button> |
| 243 | <Button danger onClick={() => handleReject(selectedPost.id)}>驳回</Button> |
| 244 | </> |
| 245 | )} |
| 246 | {selectedPost.status === 'published' && ( |
| 247 | <Button danger onClick={() => handleReject(selectedPost.id)}>驳回</Button> |
| 248 | )} |
| 249 | {selectedPost.status === 'rejected' && ( |
| 250 | <> |
| 251 | <Button onClick={() => { |
| 252 | setPosts(ps => ps.map(x => x.id === selectedPost.id ? { ...x, status: 'pending' } : x)); |
| 253 | setSelectedPost(prev => ({ ...prev, status: 'pending' })); |
| 254 | }}>恢复待审</Button> |
| 255 | <Button onClick={() => { |
| 256 | setPosts(ps => ps.map(x => x.id === selectedPost.id ? { ...x, status: 'published' } : x)); |
| 257 | setSelectedPost(prev => ({ ...prev, status: 'published' })); |
| 258 | }}>恢复已发</Button> |
| 259 | </> |
| 260 | )} |
| 261 | </div> |
| 262 | } |
| 263 | > |
| 264 | <Text type="secondary"> |
| 265 | {`${selectedPost.createdAt} · ${selectedPost.author} · ${selectedPost.likes || 0}赞`} |
| 266 | </Text> |
| 267 | <Divider /> |
| 268 | <p>{selectedPost.content}</p> |
| 269 | <Divider /> |
| 270 | <Title level={4}>合规性指引</Title> |
| 271 | <ul> |
| 272 | <li>不含违法违规内容</li> |
| 273 | <li>不侵害他人合法权益</li> |
| 274 | </ul> |
| 275 | </Card> |
| 276 | ) : ( |
| 277 | <Text type="secondary">请选择左侧列表中的帖子查看详情</Text> |
| 278 | )} |
| 279 | </Content> |
| 280 | </div> |
| 281 | </div> |
| 282 | ); |
| 283 | } |