wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 1 | // src/components/Admin.jsx |
| 2 | import React, { useState, useEffect, useCallback } from 'react' |
| 3 | import { useParams } from 'react-router-dom' |
| 4 | import 'antd/dist/antd.css' |
| 5 | import { |
| 6 | Layout, |
| 7 | Tabs, |
| 8 | Input, |
| 9 | List, |
| 10 | Card, |
| 11 | Button, |
| 12 | Tag, |
| 13 | Spin, |
| 14 | Typography, |
| 15 | Divider |
| 16 | } from 'antd' |
| 17 | import '../style/Admin.css' |
| 18 | import { fetchPosts, approvePost, rejectPost } from '../api/posts_trm' |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 19 | |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 20 | export default function AdminPage() { |
| 21 | const { userId } = useParams() // ← 从路由拿到 |
| 22 | const [posts, setPosts] = useState([]) |
| 23 | const [loading, setLoading] = useState(true) |
| 24 | const [hasPermission, setHasPermission] = useState(true) |
| 25 | const [activeTab, setActiveTab] = useState('all') |
| 26 | const [selectedPost, setSelectedPost] = useState(null) |
| 27 | const [searchTerm, setSearchTerm] = useState('') |
| 28 | const [leftPanelWidth, setLeftPanelWidth] = useState(300) |
| 29 | const [isResizing, setIsResizing] = useState(false) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 30 | |
| 31 | const statusColors = { |
| 32 | draft: 'orange', |
| 33 | pending: 'blue', |
| 34 | published: 'green', |
| 35 | deleted: 'gray', |
| 36 | rejected: 'red' |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 37 | } |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 38 | |
| 39 | useEffect(() => { |
| 40 | async function load() { |
| 41 | try { |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 42 | const list = await fetchPosts(userId) // ← 传入 userId |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 43 | setPosts(list) |
| 44 | } catch (e) { |
| 45 | if (e.message === 'Unauthorized') { |
| 46 | setHasPermission(false) |
| 47 | } else { |
| 48 | console.error(e) |
| 49 | } |
| 50 | } finally { |
| 51 | setLoading(false) |
| 52 | } |
| 53 | } |
| 54 | load() |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 55 | }, [userId]) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 56 | |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 57 | const sortedPosts = React.useMemo(() => { |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 58 | return [...posts].sort((a, b) => { |
| 59 | if (a.status === 'pending' && b.status !== 'pending') return -1 |
| 60 | if (b.status === 'pending' && a.status !== 'pending') return 1 |
| 61 | return 0 |
| 62 | }) |
| 63 | }, [posts]) |
| 64 | |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 65 | const filteredPosts = React.useMemo(() => { |
| 66 | let list = sortedPosts |
| 67 | if (activeTab !== 'all') { |
| 68 | list = sortedPosts.filter(p => p.status === activeTab) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 69 | } |
| 70 | return list.filter(p => |
| 71 | p.title.toLowerCase().includes(searchTerm.toLowerCase()) |
| 72 | ) |
| 73 | }, [sortedPosts, activeTab, searchTerm]) |
| 74 | |
| 75 | const handleApprove = async id => { |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 76 | await approvePost(id, userId) // ← 传入 userId |
| 77 | setPosts(ps => |
| 78 | ps.map(x => (x.id === id ? { ...x, status: 'published' } : x)) |
| 79 | ) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 80 | if (selectedPost?.id === id) { |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 81 | setSelectedPost(prev => ({ ...prev, status: 'published' })) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 82 | } |
| 83 | } |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 84 | |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 85 | const handleReject = async id => { |
| 86 | await rejectPost(id, userId) // ← 传入 userId |
| 87 | setPosts(ps => |
| 88 | ps.map(x => (x.id === id ? { ...x, status: 'rejected' } : x)) |
| 89 | ) |
| 90 | if (selectedPost?.id === id) { |
| 91 | setSelectedPost(prev => ({ ...prev, status: 'rejected' })) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 92 | } |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 93 | } |
| 94 | |
| 95 | const handleSelect = post => setSelectedPost(post) |
| 96 | |
| 97 | const handleMouseMove = useCallback( |
| 98 | e => { |
| 99 | if (!isResizing) return |
| 100 | const newWidth = e.clientX |
| 101 | const minWidth = 200 |
| 102 | const maxWidth = window.innerWidth - 300 |
| 103 | if (newWidth >= minWidth && newWidth <= maxWidth) { |
| 104 | setLeftPanelWidth(newWidth) |
| 105 | } |
| 106 | }, |
| 107 | [isResizing] |
| 108 | ) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 109 | |
| 110 | const handleMouseUp = useCallback(() => { |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 111 | setIsResizing(false) |
| 112 | document.removeEventListener('mousemove', handleMouseMove) |
| 113 | document.removeEventListener('mouseup', handleMouseUp) |
| 114 | document.body.style.cursor = '' |
| 115 | document.body.style.userSelect = '' |
| 116 | }, [handleMouseMove]) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 117 | |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 118 | const handleMouseDown = useCallback( |
| 119 | e => { |
| 120 | e.preventDefault() |
| 121 | setIsResizing(true) |
| 122 | document.addEventListener('mousemove', handleMouseMove) |
| 123 | document.addEventListener('mouseup', handleMouseUp) |
| 124 | document.body.style.cursor = 'col-resize' |
| 125 | document.body.style.userSelect = 'none' |
| 126 | }, |
| 127 | [handleMouseMove, handleMouseUp] |
| 128 | ) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 129 | |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 130 | useEffect(() => { |
| 131 | return () => { |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 132 | document.removeEventListener('mousemove', handleMouseMove) |
| 133 | document.removeEventListener('mouseup', handleMouseUp) |
| 134 | document.body.style.cursor = '' |
| 135 | document.body.style.userSelect = '' |
| 136 | } |
| 137 | }, [handleMouseMove, handleMouseUp]) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 138 | |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 139 | if (loading) |
| 140 | return ( |
| 141 | <Spin |
| 142 | spinning |
| 143 | tip="加载中…" |
| 144 | style={{ width: '100%', marginTop: 100 }} |
| 145 | /> |
| 146 | ) |
| 147 | if (!hasPermission) |
| 148 | return ( |
| 149 | <div style={{ textAlign: 'center', marginTop: 100 }}> |
| 150 | 权限不足 |
| 151 | </div> |
| 152 | ) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 153 | |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 154 | const { Content } = Layout |
| 155 | const { TabPane } = Tabs |
| 156 | const { Title, Text } = Typography |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 157 | |
| 158 | return ( |
| 159 | <div style={{ height: '100vh', display: 'flex' }}> |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 160 | <div |
| 161 | style={{ |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 162 | width: leftPanelWidth, |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 163 | background: '#fff', |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 164 | padding: 16, |
| 165 | borderRight: '1px solid #f0f0f0', |
| 166 | overflow: 'hidden' |
| 167 | }} |
| 168 | > |
| 169 | <div style={{ marginBottom: 24 }}> |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 170 | <Title level={3}>小红书 管理</Title> |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 171 | <Input.Search |
| 172 | placeholder="搜索帖子标题..." |
| 173 | value={searchTerm} |
| 174 | onChange={e => setSearchTerm(e.target.value)} |
| 175 | enterButton |
| 176 | /> |
| 177 | </div> |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 178 | <Tabs |
| 179 | activeKey={activeTab} |
| 180 | onChange={key => { |
| 181 | setActiveTab(key) |
| 182 | setSelectedPost(null) |
| 183 | }} |
| 184 | > |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 185 | <TabPane tab="全部" key="all" /> |
| 186 | <TabPane tab="待审核" key="pending" /> |
| 187 | <TabPane tab="已通过" key="published" /> |
| 188 | <TabPane tab="已驳回" key="rejected" /> |
| 189 | </Tabs> |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 190 | <div |
| 191 | style={{ |
| 192 | height: 'calc(100vh - 200px)', |
| 193 | overflow: 'auto' |
| 194 | }} |
| 195 | > |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 196 | <List |
| 197 | dataSource={filteredPosts} |
| 198 | pagination={{ |
| 199 | pageSize: 5, |
| 200 | showSizeChanger: true, |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 201 | pageSizeOptions: ['5', '10', '20'], |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 202 | onChange: () => setSelectedPost(null) |
| 203 | }} |
| 204 | renderItem={p => ( |
| 205 | <List.Item |
| 206 | key={p.id} |
| 207 | style={{ |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 208 | background: |
| 209 | selectedPost?.id === p.id ? '#e6f7ff' : '', |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 210 | cursor: 'pointer', |
| 211 | marginBottom: 8 |
| 212 | }} |
| 213 | onClick={() => handleSelect(p)} |
| 214 | > |
| 215 | <List.Item.Meta |
| 216 | avatar={ |
| 217 | p.thumbnail && ( |
| 218 | <img |
| 219 | src={p.thumbnail} |
| 220 | alt="" |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 221 | style={{ |
| 222 | width: 64, |
| 223 | height: 64, |
| 224 | objectFit: 'cover' |
| 225 | }} |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 226 | /> |
| 227 | ) |
| 228 | } |
| 229 | title={p.title} |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 230 | description={`${p.createdAt} · ${p.author} · ${ |
| 231 | p.likes || 0 |
| 232 | }赞`} |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 233 | /> |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 234 | <Tag color={statusColors[p.status]}> |
| 235 | {p.status} |
| 236 | </Tag> |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 237 | </List.Item> |
| 238 | )} |
| 239 | /> |
| 240 | </div> |
| 241 | </div> |
| 242 | |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 243 | <div |
| 244 | style={{ |
| 245 | width: 5, |
| 246 | cursor: 'col-resize', |
| 247 | background: isResizing ? '#1890ff' : '#f0f0f0', |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 248 | position: 'relative', |
| 249 | flexShrink: 0 |
| 250 | }} |
| 251 | onMouseDown={handleMouseDown} |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 252 | onSelectStart={e => e.preventDefault()} |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 253 | > |
| 254 | <div |
| 255 | style={{ |
| 256 | position: 'absolute', |
| 257 | top: '50%', |
| 258 | left: '50%', |
| 259 | transform: 'translate(-50%, -50%)', |
| 260 | width: 2, |
| 261 | height: 20, |
| 262 | background: '#999', |
| 263 | borderRadius: 1 |
| 264 | }} |
| 265 | /> |
| 266 | </div> |
| 267 | |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 268 | <div |
| 269 | style={{ |
| 270 | flex: 1, |
| 271 | display: 'flex', |
| 272 | flexDirection: 'column' |
| 273 | }} |
| 274 | > |
| 275 | <Content |
| 276 | style={{ |
| 277 | padding: 24, |
| 278 | background: '#fff', |
| 279 | overflow: 'auto' |
| 280 | }} |
| 281 | > |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 282 | {selectedPost ? ( |
| 283 | <Card |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 284 | cover={ |
| 285 | selectedPost.image && ( |
| 286 | <img |
| 287 | alt="cover" |
| 288 | src={selectedPost.image} |
| 289 | /> |
| 290 | ) |
| 291 | } |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 292 | title={selectedPost.title} |
| 293 | extra={ |
| 294 | <div> |
| 295 | {selectedPost.status === 'pending' && ( |
| 296 | <> |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 297 | <Button |
| 298 | type="primary" |
| 299 | onClick={() => |
| 300 | handleApprove(selectedPost.id) |
| 301 | } |
| 302 | > |
| 303 | 通过 |
| 304 | </Button> |
| 305 | <Button |
| 306 | danger |
| 307 | onClick={() => |
| 308 | handleReject(selectedPost.id) |
| 309 | } |
| 310 | > |
| 311 | 驳回 |
| 312 | </Button> |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 313 | </> |
| 314 | )} |
| 315 | {selectedPost.status === 'published' && ( |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 316 | <Button |
| 317 | danger |
| 318 | onClick={() => |
| 319 | handleReject(selectedPost.id) |
| 320 | } |
| 321 | > |
| 322 | 驳回 |
| 323 | </Button> |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 324 | )} |
| 325 | {selectedPost.status === 'rejected' && ( |
| 326 | <> |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 327 | <Button |
| 328 | onClick={() => { |
| 329 | setPosts(ps => |
| 330 | ps.map(x => |
| 331 | x.id === selectedPost.id |
| 332 | ? { ...x, status: 'pending' } |
| 333 | : x |
| 334 | ) |
| 335 | ) |
| 336 | setSelectedPost(prev => ({ |
| 337 | ...prev, |
| 338 | status: 'pending' |
| 339 | })) |
| 340 | }} |
| 341 | > |
| 342 | 恢复待审 |
| 343 | </Button> |
| 344 | <Button |
| 345 | onClick={() => { |
| 346 | setPosts(ps => |
| 347 | ps.map(x => |
| 348 | x.id === selectedPost.id |
| 349 | ? { |
| 350 | ...x, |
| 351 | status: 'published' |
| 352 | } |
| 353 | : x |
| 354 | ) |
| 355 | ) |
| 356 | setSelectedPost(prev => ({ |
| 357 | ...prev, |
| 358 | status: 'published' |
| 359 | })) |
| 360 | }} |
| 361 | > |
| 362 | 恢复已发 |
| 363 | </Button> |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 364 | </> |
| 365 | )} |
| 366 | </div> |
| 367 | } |
| 368 | > |
| 369 | <Text type="secondary"> |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 370 | {`${selectedPost.createdAt} · ${selectedPost.author} · ${ |
| 371 | selectedPost.likes || 0 |
| 372 | }赞`} |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 373 | </Text> |
| 374 | <Divider /> |
| 375 | <p>{selectedPost.content}</p> |
| 376 | <Divider /> |
| 377 | <Title level={4}>合规性指引</Title> |
| 378 | <ul> |
| 379 | <li>不含违法违规内容</li> |
| 380 | <li>不侵害他人合法权益</li> |
| 381 | </ul> |
| 382 | </Card> |
| 383 | ) : ( |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 384 | <Text type="secondary"> |
| 385 | 请选择左侧列表中的帖子查看详情 |
| 386 | </Text> |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 387 | )} |
| 388 | </Content> |
| 389 | </div> |
| 390 | </div> |
wu | 70fc8c5 | 2025-06-19 15:55:03 +0800 | [diff] [blame] | 391 | ) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 392 | } |