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