22301014 | 4ce0587 | 2025-06-08 22:33:28 +0800 | [diff] [blame] | 1 | import React, { useState } from 'react'; |
| 2 | import { Card, Typography, Tag, Flex, Table, Collapse, List, Spin, Alert, Button, Input, Form, message } from 'antd'; |
| 3 | import { EditOutlined, SendOutlined } from '@ant-design/icons'; |
| 4 | import ReactMarkdown from 'react-markdown'; |
| 5 | import type { ColumnsType } from 'antd/es/table'; |
| 6 | import type { PaginationConfig } from 'antd/es/pagination'; |
| 7 | import type { FormInstance } from 'antd/es/form'; |
| 8 | import type { Comment, Version, User, HistoryUser } from './types'; |
| 9 | import { parseUploadSize } from './types'; |
| 10 | |
| 11 | const { Title, Paragraph } = Typography; |
| 12 | const { Panel } = Collapse; |
| 13 | const { TextArea } = Input; |
| 14 | |
| 15 | // 作品封面组件 |
| 16 | export const ArtworkCover: React.FC<{ coverUrl: string }> = ({ coverUrl }) => ( |
| 17 | <Card cover={<img alt="作品封面" src={coverUrl} style={{ height: 250, objectFit: 'cover' }} />} /> |
| 18 | ); |
| 19 | |
| 20 | // 当前做种用户组件 |
| 21 | export const CurrentSeedingUsers: React.FC<{ users: User[] }> = ({ users }) => ( |
| 22 | <Card> |
| 23 | <Title level={4} style={{ marginBottom: 12 }}>当前做种用户</Title> |
| 24 | <Flex wrap="wrap" gap={8}> |
| 25 | {users.map((user) => ( |
| 26 | <Tag key={user.userId} color="green">{user.username}</Tag> |
| 27 | ))} |
| 28 | </Flex> |
| 29 | </Card> |
| 30 | ); |
| 31 | |
| 32 | // 历史做种用户组件 |
| 33 | export const HistorySeedingUsers: React.FC<{ users: HistoryUser[] }> = ({ users }) => { |
| 34 | const sortedUsers = [...users].sort((a, b) => parseUploadSize(b.uploadTotal) - parseUploadSize(a.uploadTotal)); |
| 35 | |
| 36 | const columns: ColumnsType<HistoryUser> = [ |
| 37 | { title: '用户名', dataIndex: 'username', key: 'username' }, |
| 38 | { |
| 39 | title: '上传总量', |
| 40 | dataIndex: 'uploadTotal', |
| 41 | key: 'uploadTotal', |
| 42 | sorter: (a: HistoryUser, b: HistoryUser) => parseUploadSize(a.uploadTotal) - parseUploadSize(b.uploadTotal), |
| 43 | }, |
| 44 | ]; |
| 45 | |
| 46 | return ( |
| 47 | <Card> |
| 48 | <Title level={4} style={{ marginBottom: 12 }}>历史做种用户</Title> |
| 49 | <Table columns={columns} dataSource={sortedUsers} rowKey="username" pagination={false} size="small" /> |
| 50 | </Card> |
| 51 | ); |
| 52 | }; |
| 53 | |
| 54 | // 作品描述组件 |
| 55 | export const ArtworkDescription: React.FC<{ |
| 56 | name: string; |
| 57 | author: string; |
| 58 | category: string; |
| 59 | description: string; |
| 60 | isAuthor?: boolean; |
| 61 | onEdit?: () => void; |
| 62 | }> = ({ name, author, category, description, isAuthor = false, onEdit }) => ( |
| 63 | <Card style={{ marginBottom: 20 }}> |
| 64 | <Flex justify="space-between" align="flex-start"> |
| 65 | <div style={{ flex: 1 }}> |
| 66 | <Title level={2} style={{ marginBottom: 8 }}>{name}</Title> |
| 67 | <Paragraph style={{ marginBottom: 8, fontSize: 16 }}> |
| 68 | <strong>作者:</strong>{author} |
| 69 | </Paragraph> |
| 70 | <div style={{ marginBottom: 16 }}> |
| 71 | <Tag color="blue">{category}</Tag> |
| 72 | </div> |
| 73 | <div style={{ lineHeight: 1.6 }}> |
| 74 | <ReactMarkdown>{description}</ReactMarkdown> |
| 75 | </div> |
| 76 | </div> |
| 77 | {isAuthor && ( |
| 78 | <Button type="primary" icon={<EditOutlined />} onClick={onEdit} style={{ marginLeft: 16 }}> |
| 79 | 编辑作品 |
| 80 | </Button> |
| 81 | )} |
| 82 | </Flex> |
| 83 | </Card> |
| 84 | ); |
| 85 | |
| 86 | // 版本历史组件 |
| 87 | export const VersionHistory: React.FC<{ versions: Version[] }> = ({ versions }) => ( |
| 88 | <Card title="版本历史" style={{ marginBottom: 20 }}> |
| 89 | <Collapse> |
| 90 | {versions.map((version, index) => ( |
| 91 | <Panel |
| 92 | header={`版本 ${version.version}`} |
| 93 | key={`version-${index}`} |
| 94 | extra={<Tag color="blue">v{version.version}</Tag>} |
| 95 | > |
| 96 | <div style={{ marginBottom: 16 }}> |
| 97 | <strong>版本描述:</strong> |
| 98 | <div style={{ marginTop: 8, lineHeight: 1.6 }}> |
| 99 | <ReactMarkdown>{version.versionDescription}</ReactMarkdown> |
| 100 | </div> |
| 101 | </div> |
| 102 | <div> |
| 103 | <strong>种子文件:</strong> |
| 104 | <a href={version.seedFile} target="_blank" rel="noopener noreferrer" style={{ marginLeft: 8 }}> |
| 105 | 下载链接 |
| 106 | </a> |
| 107 | </div> |
| 108 | </Panel> |
| 109 | ))} |
| 110 | </Collapse> |
| 111 | </Card> |
| 112 | ); |
| 113 | |
| 114 | // 评论项组件(递归) |
| 115 | export const CommentItem: React.FC<{ |
| 116 | comment: Comment; |
| 117 | level?: number; |
| 118 | onReply?: (parentId: string, parentAuthor: string) => void; |
| 119 | }> = ({ comment, level = 0, onReply }) => ( |
| 120 | <div style={{ marginLeft: level * 20 }}> |
| 121 | <div style={{ marginBottom: 8 }}> |
| 122 | <Paragraph style={{ marginBottom: 4 }}> |
| 123 | <strong>{comment.author}:</strong>{comment.content} |
| 124 | </Paragraph> |
| 125 | <div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}> |
| 126 | {comment.createdAt && <span style={{ marginRight: 16 }}>{comment.createdAt}</span>} |
| 127 | {onReply && ( |
| 128 | <Button |
| 129 | type="link" |
| 130 | size="small" |
| 131 | style={{ padding: 0, height: 'auto', fontSize: 12 }} |
| 132 | onClick={() => onReply(comment.id || comment.author, comment.author)} |
| 133 | > |
| 134 | 回复 |
| 135 | </Button> |
| 136 | )} |
| 137 | </div> |
| 138 | </div> |
| 139 | {comment.child && comment.child.length > 0 && ( |
| 140 | <div style={{ |
| 141 | borderLeft: level === 0 ? '2px solid #f0f0f0' : 'none', |
| 142 | paddingLeft: level === 0 ? 12 : 0 |
| 143 | }}> |
| 144 | {comment.child.map((child, index) => ( |
| 145 | <CommentItem |
| 146 | key={child.id || `child-${level}-${index}`} |
| 147 | comment={child} |
| 148 | level={level + 1} |
| 149 | onReply={onReply} |
| 150 | /> |
| 151 | ))} |
| 152 | </div> |
| 153 | )} |
| 154 | </div> |
| 155 | ); |
| 156 | |
| 157 | // 发表评论组件 |
| 158 | export const CommentForm: React.FC<{ |
| 159 | loading?: boolean; |
| 160 | onSubmit: (content: string, parentId?: string) => void; |
| 161 | replyTo?: { id: string; author: string } | null; |
| 162 | onCancelReply?: () => void; |
| 163 | }> = ({ loading = false, onSubmit, replyTo, onCancelReply }) => { |
| 164 | const [form]: [FormInstance] = Form.useForm(); |
| 165 | const [content, setContent] = useState(''); |
| 166 | |
| 167 | const handleSubmit = (): void => { |
| 168 | if (!content.trim()) { |
| 169 | message.warning('请输入评论内容'); |
| 170 | return; |
| 171 | } |
| 172 | onSubmit(content.trim(), replyTo?.id); |
| 173 | setContent(''); |
| 174 | form.resetFields(); |
| 175 | }; |
| 176 | |
| 177 | const placeholder = replyTo ? `回复 @${replyTo.author}:` : "发表你的看法..."; |
| 178 | |
| 179 | return ( |
| 180 | <Card |
| 181 | size="small" |
| 182 | style={{ marginBottom: 16 }} |
| 183 | title={replyTo ? ( |
| 184 | <div style={{ fontSize: 14 }}> |
| 185 | <span>回复 @{replyTo.author}</span> |
| 186 | <Button type="link" size="small" onClick={onCancelReply} style={{ padding: '0 0 0 8px', fontSize: 12 }}> |
| 187 | 取消 |
| 188 | </Button> |
| 189 | </div> |
| 190 | ) : undefined} |
| 191 | > |
| 192 | <Form form={form} layout="vertical"> |
| 193 | <Form.Item> |
| 194 | <TextArea |
| 195 | value={content} |
| 196 | onChange={(e) => setContent(e.target.value)} |
| 197 | placeholder={placeholder} |
| 198 | rows={3} |
| 199 | maxLength={500} |
| 200 | showCount |
| 201 | /> |
| 202 | </Form.Item> |
| 203 | <Form.Item style={{ marginBottom: 0 }}> |
| 204 | <Flex justify="flex-end" gap={8}> |
| 205 | {replyTo && <Button onClick={onCancelReply}>取消</Button>} |
| 206 | <Button |
| 207 | type="primary" |
| 208 | icon={<SendOutlined />} |
| 209 | loading={loading} |
| 210 | onClick={handleSubmit} |
| 211 | disabled={!content.trim()} |
| 212 | > |
| 213 | {replyTo ? '发表回复' : '发表评论'} |
| 214 | </Button> |
| 215 | </Flex> |
| 216 | </Form.Item> |
| 217 | </Form> |
| 218 | </Card> |
| 219 | ); |
| 220 | }; |
| 221 | |
| 222 | // 评论区组件 |
| 223 | export const CommentSection: React.FC<{ |
| 224 | comments: Comment[]; |
| 225 | total: number; |
| 226 | loading?: boolean; |
| 227 | error?: string | null; |
| 228 | addCommentLoading?: boolean; |
| 229 | onPageChange: (page: number, pageSize: number) => void; |
| 230 | onAddComment: (content: string, parentId?: string) => void; |
| 231 | currentPage: number; |
| 232 | pageSize: number; |
| 233 | }> = ({ comments, total, loading, error, addCommentLoading, onPageChange, onAddComment, currentPage, pageSize }) => { |
| 234 | const [replyTo, setReplyTo] = useState<{ id: string; author: string } | null>(null); |
| 235 | |
| 236 | const handleReply = (parentId: string, parentAuthor: string): void => { |
| 237 | setReplyTo({ id: parentId, author: parentAuthor }); |
| 238 | }; |
| 239 | |
| 240 | const handleCancelReply = (): void => { |
| 241 | setReplyTo(null); |
| 242 | }; |
| 243 | |
| 244 | const handleSubmitComment = (content: string, parentId?: string): void => { |
| 245 | onAddComment(content, parentId); |
| 246 | setReplyTo(null); |
| 247 | }; |
| 248 | |
| 249 | const paginationConfig: PaginationConfig = { |
| 250 | current: currentPage, |
| 251 | pageSize, |
| 252 | total, |
| 253 | showSizeChanger: true, |
| 254 | showQuickJumper: true, |
| 255 | showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条评论`, |
| 256 | pageSizeOptions: ['5', '10', '20'], |
| 257 | onChange: onPageChange, |
| 258 | onShowSizeChange: onPageChange, |
| 259 | }; |
| 260 | |
| 261 | return ( |
| 262 | <Card title={`评论 (${total})`} style={{ marginBottom: 20 }}> |
| 263 | <CommentForm |
| 264 | loading={addCommentLoading} |
| 265 | onSubmit={handleSubmitComment} |
| 266 | replyTo={replyTo} |
| 267 | onCancelReply={handleCancelReply} |
| 268 | /> |
| 269 | |
| 270 | {error ? ( |
| 271 | <Alert message="加载评论失败" description={error} type="error" showIcon /> |
| 272 | ) : loading ? ( |
| 273 | <Flex justify="center" align="center" style={{ minHeight: 200 }}> |
| 274 | <Spin size="large" /> |
| 275 | </Flex> |
| 276 | ) : comments.length > 0 ? ( |
| 277 | <List |
| 278 | dataSource={comments} |
| 279 | pagination={paginationConfig} |
| 280 | renderItem={(comment, index) => ( |
| 281 | <List.Item |
| 282 | key={comment.id || `comment-${index}`} |
| 283 | style={{ border: 'none', padding: '16px 0', borderBottom: '1px solid #f0f0f0' }} |
| 284 | > |
| 285 | <CommentItem comment={comment} onReply={handleReply} /> |
| 286 | </List.Item> |
| 287 | )} |
| 288 | /> |
| 289 | ) : ( |
| 290 | <Paragraph style={{ textAlign: 'center', color: '#999', margin: '20px 0' }}> |
| 291 | 暂无评论,来发表第一条评论吧! |
| 292 | </Paragraph> |
| 293 | )} |
| 294 | </Card> |
| 295 | ); |
| 296 | }; |
| 297 | |
| 298 | // 侧边栏组合组件 |
| 299 | export const Sidebar: React.FC<{ |
| 300 | coverUrl: string; |
| 301 | currentUsers: User[]; |
| 302 | historyUsers: HistoryUser[]; |
| 303 | loading?: boolean; |
| 304 | error?: string | null; |
| 305 | }> = ({ coverUrl, currentUsers, historyUsers, loading, error }) => ( |
| 306 | <Flex flex={1} vertical gap={20}> |
| 307 | <ArtworkCover coverUrl={coverUrl} /> |
| 308 | {loading ? ( |
| 309 | <Flex justify="center" align="center" style={{ minHeight: 200 }}> |
| 310 | <Spin size="large" /> |
| 311 | </Flex> |
| 312 | ) : error ? ( |
| 313 | <Alert message="加载用户信息失败" description={error} type="error" showIcon /> |
| 314 | ) : ( |
| 315 | <> |
| 316 | <CurrentSeedingUsers users={currentUsers} /> |
| 317 | <HistorySeedingUsers users={historyUsers} /> |
| 318 | </> |
| 319 | )} |
| 320 | </Flex> |
| 321 | ); |
| 322 | |
| 323 | // 主内容区组合组件 |
| 324 | export const MainContent: React.FC<{ |
| 325 | artworkName: string; |
| 326 | author: string; |
| 327 | category: string; |
| 328 | description: string; |
| 329 | versions: Version[]; |
| 330 | comments: Comment[]; |
| 331 | commentsTotal: number; |
| 332 | commentsLoading?: boolean; |
| 333 | commentsError?: string | null; |
| 334 | addCommentLoading?: boolean; |
| 335 | onCommentsPageChange: (page: number, pageSize: number) => void; |
| 336 | onAddComment: (content: string, parentId?: string) => void; |
| 337 | currentPage: number; |
| 338 | pageSize: number; |
| 339 | isAuthor?: boolean; |
| 340 | onEditArtwork?: () => void; |
| 341 | }> = ({ |
| 342 | artworkName, author, category, description, versions, comments, commentsTotal, |
| 343 | commentsLoading, commentsError, addCommentLoading, onCommentsPageChange, onAddComment, |
| 344 | currentPage, pageSize, isAuthor, onEditArtwork |
| 345 | }) => ( |
| 346 | <Flex flex={4} vertical> |
| 347 | <ArtworkDescription |
| 348 | name={artworkName} |
| 349 | author={author} |
| 350 | category={category} |
| 351 | description={description} |
| 352 | isAuthor={isAuthor} |
| 353 | onEdit={onEditArtwork} |
| 354 | /> |
| 355 | <VersionHistory versions={versions} /> |
| 356 | <CommentSection |
| 357 | comments={comments} |
| 358 | total={commentsTotal} |
| 359 | loading={commentsLoading} |
| 360 | error={commentsError} |
| 361 | addCommentLoading={addCommentLoading} |
| 362 | onPageChange={onCommentsPageChange} |
| 363 | onAddComment={onAddComment} |
| 364 | currentPage={currentPage} |
| 365 | pageSize={pageSize} |
| 366 | /> |
| 367 | </Flex> |
| 368 | ); |