| import React, { useState, useCallback } from 'react'; |
| import { |
| Modal, Form, Input, Upload, Button, message, Space, Card, |
| Typography, Alert, List, Tag, Popconfirm, Row, Col, |
| type FormInstance |
| } from 'antd'; |
| import { |
| EditOutlined, DeleteOutlined, PlusOutlined, InboxOutlined, |
| SaveOutlined, FileTextOutlined, UserOutlined |
| } from '@ant-design/icons'; |
| import ReactMarkdown from 'react-markdown'; |
| import type { |
| UploadFile, UploadProps, UploadChangeParam, RcFile, |
| } from 'antd/es/upload'; |
| import type { |
| ArtworkData, VersionFormData, Comment, |
| } from './types'; |
| |
| const { TextArea } = Input; |
| const { Dragger } = Upload; |
| const { Text } = Typography; |
| |
| // ==================== 工具函数 ==================== |
| const getBase64 = (file: RcFile): Promise<string> => |
| new Promise((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.readAsDataURL(file); |
| reader.onload = (): void => resolve(reader.result as string); |
| reader.onerror = (error): void => reject(error); |
| }); |
| |
| // ==================== 类型定义 ==================== |
| interface EditCoverProps { |
| visible: boolean; |
| currentCover: string; |
| onCancel: () => void; |
| onSave: (coverUrl: string) => Promise<void>; |
| } |
| |
| interface EditDescriptionProps { |
| visible: boolean; |
| currentDescription: string; |
| onCancel: () => void; |
| onSave: (description: string) => Promise<void>; |
| } |
| |
| interface EditVersionsProps { |
| visible: boolean; |
| versions: VersionFormData[]; |
| onCancel: () => void; |
| onSave: (versions: VersionFormData[]) => Promise<void>; |
| } |
| |
| interface CommentItemProps { |
| comment: Comment; |
| isAuthor: boolean; |
| onDelete: (commentId: string) => Promise<void>; |
| level?: number; |
| } |
| |
| interface EditWorkControlsProps { |
| artwork: ArtworkData; |
| isAuthor: boolean; |
| onUpdate: (updatedArtwork: Partial<ArtworkData>) => Promise<void>; |
| onDeleteComment: (commentId: string) => Promise<void>; |
| } |
| |
| interface VersionItemProps { |
| version: VersionFormData; |
| index: number; |
| onEdit: (index: number) => void; |
| onDelete: (index: number) => void; |
| } |
| |
| interface VersionEditFormProps { |
| form: FormInstance<VersionFormData>; |
| version: VersionFormData; |
| onSave: () => void; |
| onCancel: () => void; |
| onFileChange: (file: UploadFile | undefined) => void; |
| } |
| |
| // ==================== 封面编辑组件 ==================== |
| export const EditWorkCover: React.FC<EditCoverProps> = ({ |
| visible, |
| currentCover, |
| onCancel, |
| onSave |
| }) => { |
| const [fileList, setFileList] = useState<UploadFile[]>([]); |
| const [previewImage, setPreviewImage] = useState<string>(''); |
| const [previewOpen, setPreviewOpen] = useState<boolean>(false); |
| const [uploading, setUploading] = useState<boolean>(false); |
| |
| const handleChange: UploadProps['onChange'] = useCallback((info: UploadChangeParam<UploadFile>): void => { |
| setFileList(info.fileList); |
| }, []); |
| |
| const handlePreview = useCallback(async (file: UploadFile): Promise<void> => { |
| if (!file.url && !file.preview) { |
| file.preview = await getBase64(file.originFileObj as RcFile); |
| } |
| setPreviewImage(file.url || (file.preview as string)); |
| setPreviewOpen(true); |
| }, []); |
| |
| const beforeUpload = useCallback((file: RcFile): boolean => { |
| const isImage = file.type.startsWith('image/'); |
| if (!isImage) { |
| message.error('只能上传图片文件!'); |
| return false; |
| } |
| const isLt5M = file.size / 1024 / 1024 < 5; |
| if (!isLt5M) { |
| message.error('图片大小不能超过 5MB!'); |
| return false; |
| } |
| return false; // 阻止自动上传 |
| }, []); |
| |
| const handleSave = useCallback(async (): Promise<void> => { |
| if (fileList.length === 0) { |
| message.error('请选择要上传的封面图片'); |
| return; |
| } |
| |
| setUploading(true); |
| try { |
| // 模拟上传过程 |
| const file = fileList[0]; |
| const coverUrl = file.preview || URL.createObjectURL(file.originFileObj as RcFile); |
| |
| await onSave(coverUrl); |
| message.success('封面更新成功!'); |
| onCancel(); |
| } catch { |
| message.error('封面更新失败,请重试'); |
| } finally { |
| setUploading(false); |
| } |
| }, [fileList, onSave, onCancel]); |
| |
| return ( |
| <> |
| <Modal |
| title="编辑作品封面" |
| open={visible} |
| onCancel={onCancel} |
| footer={[ |
| <Button key="cancel" onClick={onCancel}> |
| 取消 |
| </Button>, |
| <Button |
| key="save" |
| type="primary" |
| loading={uploading} |
| onClick={handleSave} |
| icon={<SaveOutlined />} |
| > |
| 保存 |
| </Button>, |
| ]} |
| width={600} |
| > |
| <div style={{ marginBottom: 16 }}> |
| <Text strong>当前封面:</Text> |
| <div style={{ marginTop: 8 }}> |
| <img |
| src={currentCover} |
| alt="当前封面" |
| style={{ maxWidth: '100%', maxHeight: 200, objectFit: 'cover' }} |
| /> |
| </div> |
| </div> |
| |
| <Alert |
| message="新封面要求" |
| description="图片格式:JPG、PNG、GIF;大小不超过 5MB;建议尺寸:宽高比 3:4" |
| type="info" |
| showIcon |
| style={{ marginBottom: 16 }} |
| /> |
| |
| <Upload |
| listType="picture-card" |
| fileList={fileList} |
| onChange={handleChange} |
| onPreview={handlePreview} |
| beforeUpload={beforeUpload} |
| maxCount={1} |
| accept="image/*" |
| > |
| {fileList.length === 0 && ( |
| <div> |
| <PlusOutlined /> |
| <div style={{ marginTop: 8 }}>选择新封面</div> |
| </div> |
| )} |
| </Upload> |
| </Modal> |
| |
| <Modal |
| open={previewOpen} |
| title="图片预览" |
| footer={null} |
| onCancel={(): void => setPreviewOpen(false)} |
| > |
| <img alt="预览" style={{ width: '100%' }} src={previewImage} /> |
| </Modal> |
| </> |
| ); |
| }; |
| |
| // ==================== 作品描述编辑组件 ==================== |
| export const EditWorkDescription: React.FC<EditDescriptionProps> = ({ |
| visible, |
| currentDescription, |
| onCancel, |
| onSave |
| }) => { |
| const [form] = Form.useForm<{ description: string }>(); |
| const [previewMode, setPreviewMode] = useState<boolean>(false); |
| const [saving, setSaving] = useState<boolean>(false); |
| |
| const handleSave = useCallback(async (): Promise<void> => { |
| try { |
| const values = await form.validateFields(); |
| setSaving(true); |
| await onSave(values.description); |
| message.success('作品描述更新成功!'); |
| onCancel(); |
| } catch (error) { |
| if (error && typeof error === 'object' && 'errorFields' in error) { |
| message.error('请检查输入内容'); |
| } else { |
| message.error('更新失败,请重试'); |
| } |
| } finally { |
| setSaving(false); |
| } |
| }, [form, onSave, onCancel]); |
| |
| const handlePreview = useCallback((): void => { |
| form.validateFields().then(() => { |
| setPreviewMode(true); |
| }).catch(() => { |
| message.error('请先填写完整信息'); |
| }); |
| }, [form]); |
| |
| return ( |
| <> |
| <Modal |
| title="编辑作品描述" |
| open={visible} |
| onCancel={onCancel} |
| footer={[ |
| <Button key="preview" onClick={handlePreview}> |
| 预览 |
| </Button>, |
| <Button key="cancel" onClick={onCancel}> |
| 取消 |
| </Button>, |
| <Button |
| key="save" |
| type="primary" |
| loading={saving} |
| onClick={handleSave} |
| icon={<SaveOutlined />} |
| > |
| 保存 |
| </Button>, |
| ]} |
| width={800} |
| > |
| <Form |
| form={form} |
| layout="vertical" |
| initialValues={{ description: currentDescription }} |
| > |
| <Form.Item |
| label="作品描述" |
| name="description" |
| rules={[ |
| { required: true, message: '请输入作品描述' }, |
| { min: 20, message: '作品描述至少20个字符' }, |
| { max: 2000, message: '作品描述最多2000个字符' }, |
| ]} |
| extra="支持 Markdown 格式,可以使用 # 标题、**粗体**、*斜体* 等格式" |
| > |
| <TextArea |
| placeholder="请详细描述你的作品,包括创作理念、技术特点、使用说明等" |
| rows={12} |
| showCount |
| maxLength={2000} |
| /> |
| </Form.Item> |
| </Form> |
| </Modal> |
| |
| <Modal |
| title="作品描述预览" |
| open={previewMode} |
| onCancel={(): void => setPreviewMode(false)} |
| footer={null} |
| width={800} |
| > |
| <div style={{ maxHeight: '60vh', overflow: 'auto' }}> |
| <ReactMarkdown>{form.getFieldValue('description') || ''}</ReactMarkdown> |
| </div> |
| </Modal> |
| </> |
| ); |
| }; |
| |
| // ==================== 版本编辑相关组件 ==================== |
| const VersionItem: React.FC<VersionItemProps> = ({ |
| version, |
| index, |
| onEdit, |
| onDelete |
| }) => { |
| return ( |
| <Row align="middle" style={{ width: '100%' }}> |
| <Col span={20}> |
| <Space direction="vertical" style={{ width: '100%' }}> |
| <Space> |
| <Tag color="blue">v{version.version}</Tag> |
| <Text strong>{version.versionDescription}</Text> |
| </Space> |
| {version.seedFile && ( |
| <Space> |
| <FileTextOutlined /> |
| <Text type="secondary">{version.seedFile.name}</Text> |
| </Space> |
| )} |
| </Space> |
| </Col> |
| <Col span={4} style={{ textAlign: 'right' }}> |
| <Space> |
| <Button |
| type="text" |
| icon={<EditOutlined />} |
| onClick={(): void => onEdit(index)} |
| /> |
| <Popconfirm |
| title="确定要删除这个版本吗?" |
| onConfirm={(): void => onDelete(index)} |
| okText="确定" |
| cancelText="取消" |
| > |
| <Button type="text" danger icon={<DeleteOutlined />} /> |
| </Popconfirm> |
| </Space> |
| </Col> |
| </Row> |
| ); |
| }; |
| |
| const VersionEditForm: React.FC<VersionEditFormProps> = ({ |
| form, |
| version, |
| onSave, |
| onCancel, |
| onFileChange |
| }) => { |
| const beforeUpload = useCallback((file: RcFile): boolean => { |
| const isLt100M = file.size / 1024 / 1024 < 100; |
| if (!isLt100M) { |
| message.error('种子文件大小不能超过 100MB!'); |
| return false; |
| } |
| return false; |
| }, []); |
| |
| return ( |
| <div style={{ width: '100%' }}> |
| <Form form={form} layout="vertical" initialValues={version}> |
| <Row gutter={16}> |
| <Col span={6}> |
| <Form.Item |
| label="版本号" |
| name="version" |
| rules={[{ required: true, message: '请输入版本号' }]} |
| > |
| <Input placeholder="例如:1.0" /> |
| </Form.Item> |
| </Col> |
| <Col span={18}> |
| <Form.Item |
| label="版本描述" |
| name="versionDescription" |
| rules={[ |
| { required: true, message: '请输入版本描述' }, |
| { min: 10, message: '版本描述至少10个字符' }, |
| ]} |
| > |
| <TextArea |
| placeholder="描述此版本的更新内容、新增功能等" |
| rows={3} |
| showCount |
| maxLength={500} |
| /> |
| </Form.Item> |
| </Col> |
| </Row> |
| |
| <Form.Item label="种子文件"> |
| <Dragger |
| maxCount={1} |
| beforeUpload={beforeUpload} |
| fileList={version.seedFile ? [version.seedFile] : []} |
| onChange={({ fileList }): void => onFileChange(fileList[0])} |
| onRemove={(): void => onFileChange(undefined)} |
| > |
| <p className="ant-upload-drag-icon"><InboxOutlined /></p> |
| <p className="ant-upload-text">点击或拖拽文件到此区域上传</p> |
| <p className="ant-upload-hint">支持单个文件上传,文件大小不超过 100MB</p> |
| </Dragger> |
| </Form.Item> |
| |
| <Space> |
| <Button type="primary" onClick={onSave}> |
| 保存 |
| </Button> |
| <Button onClick={onCancel}> |
| 取消 |
| </Button> |
| </Space> |
| </Form> |
| </div> |
| ); |
| }; |
| |
| // ==================== 版本管理组件 ==================== |
| export const EditWorkVersions: React.FC<EditVersionsProps> = ({ |
| visible, |
| versions, |
| onCancel, |
| onSave |
| }) => { |
| const [localVersions, setLocalVersions] = useState<VersionFormData[]>(versions); |
| const [editingIndex, setEditingIndex] = useState<number | null>(null); |
| const [form] = Form.useForm<VersionFormData>(); |
| const [saving, setSaving] = useState<boolean>(false); |
| |
| const handleAddVersion = useCallback((): void => { |
| const newVersion: VersionFormData = { |
| version: `${localVersions.length + 1}.0`, |
| versionDescription: '', |
| seedFile: undefined, |
| }; |
| setLocalVersions([...localVersions, newVersion]); |
| setEditingIndex(localVersions.length); |
| }, [localVersions]); |
| |
| const handleSaveVersion = useCallback((index: number): void => { |
| form.validateFields().then((values) => { |
| const newVersions = [...localVersions]; |
| newVersions[index] = { ...newVersions[index], ...values }; |
| setLocalVersions(newVersions); |
| setEditingIndex(null); |
| form.resetFields(); |
| message.success('版本信息已保存'); |
| }).catch(() => { |
| message.error('请完整填写版本信息'); |
| }); |
| }, [form, localVersions]); |
| |
| const handleDeleteVersion = useCallback((index: number): void => { |
| const newVersions = localVersions.filter((_, i) => i !== index); |
| setLocalVersions(newVersions); |
| if (editingIndex === index) { |
| setEditingIndex(null); |
| } |
| }, [localVersions, editingIndex]); |
| |
| const handleFileChange = useCallback((index: number, file: UploadFile | undefined): void => { |
| const newVersions = [...localVersions]; |
| newVersions[index].seedFile = file; |
| setLocalVersions(newVersions); |
| }, [localVersions]); |
| |
| const handleSave = useCallback(async (): Promise<void> => { |
| if (localVersions.length === 0) { |
| message.error('至少需要保留一个版本'); |
| return; |
| } |
| |
| const incompleteVersion = localVersions.find((v, index) => |
| !v.version || !v.versionDescription || !v.seedFile || index === editingIndex |
| ); |
| |
| if (incompleteVersion) { |
| message.error('请完成所有版本的信息填写'); |
| return; |
| } |
| |
| setSaving(true); |
| try { |
| await onSave(localVersions); |
| message.success('版本信息更新成功!'); |
| onCancel(); |
| } catch { |
| message.error('更新失败,请重试'); |
| } finally { |
| setSaving(false); |
| } |
| }, [localVersions, editingIndex, onSave, onCancel]); |
| |
| return ( |
| <Modal |
| title="编辑版本信息" |
| open={visible} |
| onCancel={onCancel} |
| footer={[ |
| <Button key="cancel" onClick={onCancel}> |
| 取消 |
| </Button>, |
| <Button |
| key="save" |
| type="primary" |
| loading={saving} |
| onClick={handleSave} |
| icon={<SaveOutlined />} |
| > |
| 保存所有更改 |
| </Button>, |
| ]} |
| width={900} |
| > |
| <div style={{ maxHeight: '60vh', overflow: 'auto' }}> |
| <List |
| dataSource={localVersions} |
| renderItem={(version, index): React.ReactElement => ( |
| <List.Item |
| key={index} |
| style={{ |
| background: editingIndex === index ? '#fafafa' : 'transparent', |
| padding: 16, |
| marginBottom: 16, |
| border: '1px solid #f0f0f0', |
| borderRadius: 8, |
| }} |
| > |
| {editingIndex === index ? ( |
| <VersionEditForm |
| form={form} |
| version={version} |
| onSave={(): void => handleSaveVersion(index)} |
| onCancel={(): void => setEditingIndex(null)} |
| onFileChange={(file): void => handleFileChange(index, file)} |
| /> |
| ) : ( |
| <VersionItem |
| version={version} |
| index={index} |
| onEdit={(idx): void => { |
| setEditingIndex(idx); |
| form.setFieldsValue(version); |
| }} |
| onDelete={handleDeleteVersion} |
| /> |
| )} |
| </List.Item> |
| )} |
| /> |
| |
| {editingIndex === null && ( |
| <Button |
| type="dashed" |
| onClick={handleAddVersion} |
| style={{ width: '100%', marginTop: 16 }} |
| icon={<PlusOutlined />} |
| > |
| 添加新版本 |
| </Button> |
| )} |
| </div> |
| </Modal> |
| ); |
| }; |
| |
| // ==================== 评论管理组件 ==================== |
| export const EditWorkComment: React.FC<CommentItemProps> = ({ |
| comment, |
| isAuthor, |
| onDelete, |
| level = 0 |
| }) => { |
| const [deleting, setDeleting] = useState<boolean>(false); |
| |
| const handleDelete = useCallback(async (): Promise<void> => { |
| if (!comment.id) return; |
| |
| setDeleting(true); |
| try { |
| await onDelete(comment.id); |
| message.success('评论删除成功'); |
| } catch { |
| message.error('删除失败,请重试'); |
| } finally { |
| setDeleting(false); |
| } |
| }, [comment.id, onDelete]); |
| |
| return ( |
| <div style={{ marginLeft: level * 24 }}> |
| <div |
| style={{ |
| background: '#fafafa', |
| padding: 12, |
| borderRadius: 8, |
| marginBottom: 8, |
| }} |
| > |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> |
| <div style={{ flex: 1 }}> |
| <Space style={{ marginBottom: 8 }}> |
| <UserOutlined /> |
| <Text strong>{comment.author}</Text> |
| {comment.createdAt && ( |
| <Text type="secondary" style={{ fontSize: 12 }}> |
| {new Date(comment.createdAt).toLocaleString()} |
| </Text> |
| )} |
| </Space> |
| <div> |
| <Text>{comment.content}</Text> |
| </div> |
| </div> |
| |
| {isAuthor && comment.id && ( |
| <Popconfirm |
| title="确定要删除这条评论吗?" |
| onConfirm={handleDelete} |
| okText="确定" |
| cancelText="取消" |
| > |
| <Button |
| type="text" |
| danger |
| size="small" |
| icon={<DeleteOutlined />} |
| loading={deleting} |
| /> |
| </Popconfirm> |
| )} |
| </div> |
| </div> |
| |
| {/* 递归渲染子评论 */} |
| {comment.child && comment.child.length > 0 && ( |
| <div> |
| {comment.child.map((childComment, index) => ( |
| <EditWorkComment |
| key={childComment.id || index} |
| comment={childComment} |
| isAuthor={isAuthor} |
| onDelete={onDelete} |
| level={level + 1} |
| /> |
| ))} |
| </div> |
| )} |
| </div> |
| ); |
| }; |
| |
| // ==================== 主编辑控制器组件 ==================== |
| export const EditWorkControls: React.FC<EditWorkControlsProps> = ({ |
| artwork, |
| isAuthor, |
| onUpdate, |
| }) => { |
| const [editCoverVisible, setEditCoverVisible] = useState<boolean>(false); |
| const [editDescriptionVisible, setEditDescriptionVisible] = useState<boolean>(false); |
| const [editVersionsVisible, setEditVersionsVisible] = useState<boolean>(false); |
| |
| const handleUpdateCover = useCallback(async (coverUrl: string): Promise<void> => { |
| await onUpdate({ artworkCover: coverUrl }); |
| }, [onUpdate]); |
| |
| const handleUpdateDescription = useCallback(async (description: string): Promise<void> => { |
| await onUpdate({ artworkDescription: description }); |
| }, [onUpdate]); |
| |
| const handleUpdateVersions = useCallback(async (versions: VersionFormData[]): Promise<void> => { |
| // 转换为展示用的版本格式 |
| const versionList = versions.map(v => ({ |
| version: v.version, |
| versionDescription: v.versionDescription, |
| seedFile: v.seedFile?.name || '', |
| })); |
| await onUpdate({ versionList }); |
| }, [onUpdate]); |
| |
| if (!isAuthor) { |
| return null; |
| } |
| |
| return ( |
| <> |
| <Card title="作者管理" style={{ marginBottom: 24 }}> |
| <Space wrap> |
| <Button |
| icon={<EditOutlined />} |
| onClick={(): void => setEditCoverVisible(true)} |
| > |
| 编辑封面 |
| </Button> |
| <Button |
| icon={<EditOutlined />} |
| onClick={(): void => setEditDescriptionVisible(true)} |
| > |
| 编辑描述 |
| </Button> |
| <Button |
| icon={<EditOutlined />} |
| onClick={(): void => setEditVersionsVisible(true)} |
| > |
| 管理版本 |
| </Button> |
| </Space> |
| </Card> |
| |
| <EditWorkCover |
| visible={editCoverVisible} |
| currentCover={artwork.artworkCover} |
| onCancel={(): void => setEditCoverVisible(false)} |
| onSave={handleUpdateCover} |
| /> |
| |
| <EditWorkDescription |
| visible={editDescriptionVisible} |
| currentDescription={artwork.artworkDescription} |
| onCancel={(): void => setEditDescriptionVisible(false)} |
| onSave={handleUpdateDescription} |
| /> |
| |
| <EditWorkVersions |
| visible={editVersionsVisible} |
| versions={artwork.versionList.map(v => ({ |
| version: v.version, |
| versionDescription: v.versionDescription, |
| seedFile: { name: v.seedFile } as UploadFile, |
| }))} |
| onCancel={(): void => setEditVersionsVisible(false)} |
| onSave={handleUpdateVersions} |
| /> |
| </> |
| ); |
| }; |