完成Work组件的界面和一些小修改
> 1. 修改优化路由守卫
> 2. 去除拦截器中的调试信息
> 3. 修改头部导航条下拉菜单的样式增加图标。
> 4. work组件现在使用mock数据
Change-Id: Ic602a35bb02e645a0d5253c5cbd12a68d70bfb33
diff --git a/src/feature/auth/authSlice.ts b/src/feature/auth/authSlice.ts
index 607a6fd..289ce16 100644
--- a/src/feature/auth/authSlice.ts
+++ b/src/feature/auth/authSlice.ts
@@ -1,5 +1,7 @@
+// src/feature/auth/authSlice.ts
+
import { createAsyncThunk, createSlice, type PayloadAction } from "@reduxjs/toolkit";
-import type { AuthState } from "../../store/types";
+import type { AuthState } from "./types";
import type { LoginRequest } from "../../api/Auth/type";
import AuthAPI from "../../api/Auth/AuthApi";
@@ -9,9 +11,9 @@
const initialState: AuthState = {
token: storedToken || '',
loading: false,
- isAuth: false,
- error: ''
-}
+ isAuth: !!storedToken, // 如果有token就设为true
+ error: '',
+};
export const loginUser = createAsyncThunk<
{token: string},
@@ -33,17 +35,47 @@
}
);
+// 新增:从localStorage恢复登录状态
+export const loginFromLocalStorage = createAsyncThunk<
+ {token: string} | {empty: true},
+ void,
+ { rejectValue: string }
+>(
+ 'auth/loginFromLocalStorage',
+ async (_, { rejectWithValue, dispatch }) => {
+ try {
+ const token = localStorage.getItem('token');
+ if (!token) {
+ // 返回空状态而不是错误
+ return { empty: true };
+ }
+
+ // 直接使用refreshToken来验证token有效性
+ const result = await dispatch(refreshToken(token));
+ if (refreshToken.fulfilled.match(result)) {
+ // refresh成功,返回新的token
+ return { token: result.payload.token };
+ } else {
+ // refresh失败,token无效
+ localStorage.removeItem('token');
+ return rejectWithValue('token已失效,需要重新登录');
+ }
+ } catch {
+ localStorage.removeItem('token');
+ return rejectWithValue('恢复登录状态失败');
+ }
+ }
+);
+
export const refreshToken = createAsyncThunk<
{token: string},
string,
{ rejectValue: string }
>(
-
'auth/refresh',
async (oldToken: string, { rejectWithValue }) => {
try {
const response = await AuthAPI.refreshToken(oldToken);
- console.log(response);
if(response.data.code == 0)
return {token: response.data.data};
else
@@ -61,42 +93,77 @@
logout: (state) => {
state.token = '';
state.isAuth = false;
- localStorage.clear()
- },
- },extraReducers: (builder) => {
- // 处理登录的异步操作
- builder
- .addCase(loginUser.pending, (state) => {
- state.loading = true;
- })
- .addCase(loginUser.fulfilled, (state, action: PayloadAction<{token: string}>) => {
- state.loading = false;
- state.token = action.payload.token;
- state.isAuth = true;
-
- localStorage.setItem('token', state.token);
- })
- .addCase(loginUser.rejected, (state, action) => {
- state.loading = false;
- state.error = action.payload ? action.payload : '' // 错误处理
- })
-
- // 处理刷新 token 的异步操作
- .addCase(refreshToken.pending, (state) => {
- state.loading = true;
- })
- .addCase(refreshToken.fulfilled, (state, action) => {
- state.loading = false;
- state.token = action.payload.token;
- localStorage.setItem('token', state.token);
- })
- .addCase(refreshToken.rejected, (state, action) => {
- state.loading = false;
- state.error = action.payload ? action.payload : ''
- });
+ localStorage.clear();
},
+ // 清除错误信息
+ clearError: (state) => {
+ state.error = '';
+ },
+ },
+ extraReducers: (builder) => {
+ // 处理普通登录的异步操作
+ builder
+ .addCase(loginUser.pending, (state) => {
+ state.loading = true;
+ state.error = '';
+ })
+ .addCase(loginUser.fulfilled, (state, action: PayloadAction<{token: string}>) => {
+ state.loading = false;
+ state.token = action.payload.token;
+ state.isAuth = true;
+ localStorage.setItem('token', state.token);
+ })
+ .addCase(loginUser.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.payload ? action.payload : '';
+ });
+
+ // 处理从localStorage恢复登录状态
+ builder
+ .addCase(loginFromLocalStorage.pending, (state) => {
+ state.loading = true;
+ state.error = '';
+ })
+ .addCase(loginFromLocalStorage.fulfilled, (state, action) => {
+ state.loading = false;
+ if ('token' in action.payload) {
+ // 有token的情况
+ state.token = action.payload.token;
+ state.isAuth = true;
+ } else {
+ // 空token的情况
+ state.token = '';
+ state.isAuth = false;
+ }
+ })
+ .addCase(loginFromLocalStorage.rejected, (state, action) => {
+ state.loading = false;
+ state.token = '';
+ state.isAuth = false;
+ state.error = action.payload ? action.payload : '';
+ localStorage.removeItem('token');
+ });
+
+ // 处理刷新 token 的异步操作
+ builder
+ .addCase(refreshToken.pending, (state) => {
+ state.loading = true;
+ state.error = '';
+ })
+ .addCase(refreshToken.fulfilled, (state, action) => {
+ state.loading = false;
+ state.token = action.payload.token;
+ state.isAuth = true;
+ localStorage.setItem('token', state.token);
+ })
+ .addCase(refreshToken.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.payload ? action.payload : '';
+ state.isAuth = false;
+ });
+ },
});
-export const { logout } = authSlice.actions;
+export const { logout, clearError } = authSlice.actions;
export default authSlice.reducer;
\ No newline at end of file
diff --git a/src/feature/auth/types.ts b/src/feature/auth/types.ts
new file mode 100644
index 0000000..6d91e01
--- /dev/null
+++ b/src/feature/auth/types.ts
@@ -0,0 +1,7 @@
+// 定义认证状态
+export interface AuthState {
+ token: string;
+ loading: boolean;
+ isAuth: boolean;
+ error: string
+}
\ No newline at end of file
diff --git a/src/feature/Home.tsx b/src/feature/home/Home.tsx
similarity index 100%
rename from src/feature/Home.tsx
rename to src/feature/home/Home.tsx
diff --git a/src/feature/user/userSlice.ts b/src/feature/user/userSlice.ts
index d6200af..c3f7e44 100644
--- a/src/feature/user/userSlice.ts
+++ b/src/feature/user/userSlice.ts
@@ -32,12 +32,8 @@
async (_, { rejectWithValue }) => {
const response = await UserAPi.getMe();
if (response.data.code == 0) {
- console.log("xixi")
- console.log(response)
return response.data.data;
} else {
- console.log("buxixi")
- console.log(response)
return rejectWithValue(response.data.message);
}
}
diff --git a/src/feature/work/CreatWorkComponents.tsx b/src/feature/work/CreatWorkComponents.tsx
new file mode 100644
index 0000000..12766ee
--- /dev/null
+++ b/src/feature/work/CreatWorkComponents.tsx
@@ -0,0 +1,545 @@
+import React, { useState, useCallback } from 'react';
+import {
+ Form, Input, Select, Upload, Button, Card, message, Space, Modal,
+ Divider, Row, Col, Typography, Alert, List, Tag,
+ type FormInstance
+} from 'antd';
+import {
+ PlusOutlined, InboxOutlined, DeleteOutlined, EditOutlined,
+ CheckCircleOutlined, SendOutlined, FileTextOutlined
+} from '@ant-design/icons';
+import ReactMarkdown from 'react-markdown';
+import type {
+ UploadFile, UploadProps, UploadChangeParam, RcFile,
+} from 'antd/es/upload';
+import { type StepFormProps, type BasicInfo, ARTWORK_CATEGORIES, type VersionFormData } from './types';
+import { getBase64 } from './utils';
+
+const { TextArea } = Input;
+const { Dragger } = Upload;
+const { Title, Text, Paragraph } = Typography;
+
+// ==================== 描述组件 ====================
+interface DescriptionsProps {
+ bordered?: boolean;
+ column?: number;
+ children: React.ReactNode;
+}
+
+interface DescriptionsItemProps {
+ label: string;
+ children: React.ReactNode;
+}
+
+export const Descriptions: React.FC<DescriptionsProps> & {
+ Item: React.FC<DescriptionsItemProps>;
+} = ({ children }) => {
+ return <div style={{ marginBottom: 16 }}>{children}</div>;
+};
+
+Descriptions.Item = ({ label, children }: DescriptionsItemProps) => {
+ return (
+ <div style={{ marginBottom: 12 }}>
+ <Text strong style={{ marginRight: 16 }}>{label}:</Text>
+ <span>{children}</span>
+ </div>
+ );
+};
+
+// ==================== 步骤1:基础信息 ====================
+export const BasicInfoStep: React.FC<StepFormProps> = ({ data, onUpdate, onNext }) => {
+ const [form]: [FormInstance<BasicInfo>] = Form.useForm<BasicInfo>();
+ const [previewMode, setPreviewMode] = useState<boolean>(false);
+
+ const handleFinish = useCallback((values: BasicInfo) => {
+ onUpdate('basicInfo', values);
+ onNext?.();
+ }, [onUpdate, onNext]);
+
+ const handlePreview = useCallback(() => {
+ form.validateFields().then((values) => {
+ onUpdate('basicInfo', values);
+ setPreviewMode(true);
+ }).catch(() => {
+ message.error('请先填写完整信息');
+ });
+ }, [form, onUpdate]);
+
+ return (
+ <>
+ <Card>
+ <Form form={form} layout="vertical" initialValues={data.basicInfo} onFinish={handleFinish} autoComplete="off">
+ <Form.Item
+ label="作品名称"
+ name="artworkName"
+ rules={[
+ { required: true, message: '请输入作品名称' },
+ { min: 2, message: '作品名称至少2个字符' },
+ { max: 50, message: '作品名称最多50个字符' },
+ ]}
+ >
+ <Input placeholder="请输入作品名称,例如:未来城市概念设计" size="large" showCount maxLength={50} />
+ </Form.Item>
+
+ <Form.Item label="作品分类" name="artworkCategory" rules={[{ required: true, message: '请选择作品分类' }]}>
+ <Select placeholder="请选择作品分类" size="large" options={ARTWORK_CATEGORIES} />
+ </Form.Item>
+
+ <Form.Item
+ label="作品描述"
+ name="artworkDescription"
+ rules={[
+ { required: true, message: '请输入作品描述' },
+ { min: 20, message: '作品描述至少20个字符' },
+ { max: 2000, message: '作品描述最多2000个字符' },
+ ]}
+ extra="支持 Markdown 格式,可以使用 # 标题、**粗体**、*斜体* 等格式"
+ >
+ <TextArea
+ placeholder="请详细描述你的作品,包括创作理念、技术特点、使用说明等"
+ rows={10}
+ showCount
+ maxLength={2000}
+ />
+ </Form.Item>
+
+ <Form.Item>
+ <Space size="middle">
+ <Button type="primary" htmlType="submit" size="large">下一步</Button>
+ <Button onClick={handlePreview} size="large">预览描述</Button>
+ </Space>
+ </Form.Item>
+ </Form>
+ </Card>
+
+ <Modal title="作品描述预览" open={previewMode} onCancel={() => setPreviewMode(false)} footer={null} width={800}>
+ <div style={{ maxHeight: '60vh', overflow: 'auto' }}>
+ <ReactMarkdown>{form.getFieldValue('artworkDescription') || ''}</ReactMarkdown>
+ </div>
+ </Modal>
+ </>
+ );
+};
+
+// ==================== 步骤2:封面上传 ====================
+export const CoverUploadStep: React.FC<StepFormProps> = ({ data, onUpdate, onNext, onPrev }) => {
+ const [fileList, setFileList] = useState<UploadFile[]>(data.coverInfo.coverFile ? [data.coverInfo.coverFile] : []);
+ const [previewImage, setPreviewImage] = useState<string>('');
+ const [previewOpen, setPreviewOpen] = useState<boolean>(false);
+
+ const handleChange: UploadProps['onChange'] = useCallback((info: UploadChangeParam<UploadFile>) => {
+ const { fileList: newFileList } = info;
+ setFileList(newFileList);
+ onUpdate('coverInfo', { coverFile: newFileList[0] });
+ }, [onUpdate]);
+
+ 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 handleNext = useCallback((): void => {
+ if (fileList.length === 0) {
+ message.error('请上传作品封面');
+ return;
+ }
+ onNext?.();
+ }, [fileList, onNext]);
+
+ return (
+ <Card>
+ <Alert
+ message="封面图片要求"
+ description={
+ <ul style={{ marginBottom: 0, paddingLeft: 20 }}>
+ <li>图片格式:JPG、PNG、GIF</li>
+ <li>图片大小:不超过 5MB</li>
+ <li>建议尺寸:宽高比 3:4,最小分辨率 600x800</li>
+ <li>内容要求:清晰展示作品特色,避免模糊或像素化</li>
+ </ul>
+ }
+ type="info"
+ showIcon
+ style={{ marginBottom: 24 }}
+ />
+
+ <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 open={previewOpen} title="封面预览" footer={null} onCancel={() => setPreviewOpen(false)}>
+ <img alt="封面预览" style={{ width: '100%' }} src={previewImage} />
+ </Modal>
+
+ <Divider />
+ <Space size="middle">
+ <Button onClick={onPrev} size="large">上一步</Button>
+ <Button type="primary" onClick={handleNext} size="large">下一步</Button>
+ </Space>
+ </Card>
+ );
+};
+
+// ==================== 版本管理相关组件 ====================
+const VersionItem: React.FC<{
+ version: VersionFormData;
+ index: number;
+ onEdit: (index: number) => void;
+ onDelete: (index: number) => void;
+}> = ({ version, index, onEdit, onDelete }) => {
+ return (
+ <Row align="middle">
+ <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={() => onEdit(index)} />
+ <Button type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(index)} />
+ </Space>
+ </Col>
+ </Row>
+ );
+};
+
+const VersionEditForm: React.FC<{
+ form: FormInstance<VersionFormData>;
+ version: VersionFormData;
+ onSave: () => void;
+ onCancel: () => void;
+ onFileChange: (file: UploadFile | undefined) => void;
+}> = ({ 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 (
+ <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 }) => onFileChange(fileList[0])}
+ onRemove={() => 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>
+ );
+};
+
+// ==================== 步骤3:版本管理 ====================
+export const VersionManagementStep: React.FC<StepFormProps> = ({ data, onUpdate, onNext, onPrev }) => {
+ const [versions, setVersions] = useState<VersionFormData[]>(
+ data.versions.length > 0 ? data.versions : [{ version: '1.0', versionDescription: '', seedFile: undefined }]
+ );
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [form]: [FormInstance<VersionFormData>] = Form.useForm<VersionFormData>();
+
+ const handleAddVersion = useCallback(() => {
+ const newVersion: VersionFormData = {
+ version: `${versions.length + 1}.0`,
+ versionDescription: '',
+ seedFile: undefined,
+ };
+ setVersions([...versions, newVersion]);
+ setEditingIndex(versions.length);
+ }, [versions]);
+
+ const handleSaveVersion = useCallback((index: number) => {
+ form.validateFields().then((values) => {
+ const newVersions = [...versions];
+ newVersions[index] = { ...newVersions[index], ...values };
+ setVersions(newVersions);
+ setEditingIndex(null);
+ form.resetFields();
+ message.success('版本信息已保存');
+ }).catch(() => {
+ message.error('请完整填写版本信息');
+ });
+ }, [form, versions]);
+
+ const handleDeleteVersion = useCallback((index: number) => {
+ Modal.confirm({
+ title: '确认删除',
+ content: '确定要删除这个版本吗?',
+ onOk: () => {
+ const newVersions = versions.filter((_, i) => i !== index);
+ setVersions(newVersions);
+ if (editingIndex === index) {
+ setEditingIndex(null);
+ }
+ },
+ });
+ }, [versions, editingIndex]);
+
+ const handleFileChange = useCallback((index: number, file: UploadFile | undefined) => {
+ const newVersions = [...versions];
+ newVersions[index].seedFile = file;
+ setVersions(newVersions);
+ }, [versions]);
+
+ const handleNext = useCallback(() => {
+ if (versions.length === 0) {
+ message.error('至少需要添加一个版本');
+ return;
+ }
+
+ const incompleteVersion = versions.find((v, index) =>
+ !v.version || !v.versionDescription || !v.seedFile || index === editingIndex
+ );
+
+ if (incompleteVersion) {
+ message.error('请完成所有版本的信息填写');
+ return;
+ }
+
+ onUpdate('versions', versions);
+ onNext?.();
+ }, [versions, editingIndex, onUpdate, onNext]);
+
+ return (
+ <Card>
+ <div style={{ marginBottom: 16 }}>
+ <Title level={4}>版本列表</Title>
+ <Paragraph type="secondary">每个版本需要包含版本号、版本描述和种子文件</Paragraph>
+ </div>
+
+ <List
+ dataSource={versions}
+ renderItem={(version, index) => (
+ <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={() => handleSaveVersion(index)}
+ onCancel={() => setEditingIndex(null)}
+ onFileChange={(file) => handleFileChange(index, file)}
+ />
+ ) : (
+ <VersionItem
+ version={version}
+ index={index}
+ onEdit={(idx) => {
+ setEditingIndex(idx);
+ form.setFieldsValue(version);
+ }}
+ onDelete={handleDeleteVersion}
+ />
+ )}
+ </List.Item>
+ )}
+ />
+
+ {editingIndex === null && (
+ <Button
+ type="dashed"
+ onClick={handleAddVersion}
+ style={{ width: '100%', marginBottom: 24 }}
+ icon={<PlusOutlined />}
+ >
+ 添加版本
+ </Button>
+ )}
+
+ <Divider />
+ <Space size="middle">
+ <Button onClick={onPrev} size="large">上一步</Button>
+ <Button type="primary" onClick={handleNext} size="large">下一步</Button>
+ </Space>
+ </Card>
+ );
+};
+
+// ==================== 步骤4:确认发布 ====================
+export const ConfirmPublishStep: React.FC<StepFormProps & { onPublish: () => void }> = ({ data, onPrev, onPublish }) => {
+ const [publishing, setPublishing] = useState<boolean>(false);
+
+ const handlePublish = useCallback(async () => {
+ setPublishing(true);
+ try {
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ message.success('作品发布成功!');
+ onPublish();
+ } catch {
+ message.error('发布失败,请重试');
+ } finally {
+ setPublishing(false);
+ }
+ }, [onPublish]);
+
+ return (
+ <Card>
+ <Title level={3} style={{ marginBottom: 24 }}>
+ <CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />
+ 确认发布信息
+ </Title>
+
+ <div style={{ marginBottom: 32 }}>
+ <Title level={4}>基础信息</Title>
+ <Descriptions bordered column={1}>
+ <Descriptions.Item label="作品名称">{data.basicInfo.artworkName}</Descriptions.Item>
+ <Descriptions.Item label="作品分类">
+ <Tag color="blue">{data.basicInfo.artworkCategory}</Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="作品描述">
+ <div style={{ maxHeight: 200, overflow: 'auto' }}>
+ <ReactMarkdown>{data.basicInfo.artworkDescription}</ReactMarkdown>
+ </div>
+ </Descriptions.Item>
+ </Descriptions>
+ </div>
+
+ <div style={{ marginBottom: 32 }}>
+ <Title level={4}>作品封面</Title>
+ {data.coverInfo.coverFile && (
+ <img
+ src={data.coverInfo.coverFile.thumbUrl || data.coverInfo.coverFile.url}
+ alt="作品封面"
+ style={{ maxWidth: 300, maxHeight: 400, objectFit: 'cover' }}
+ />
+ )}
+ </div>
+
+ <div style={{ marginBottom: 32 }}>
+ <Title level={4}>版本信息</Title>
+ <List
+ dataSource={data.versions}
+ renderItem={(version) => (
+ <List.Item>
+ <List.Item.Meta
+ title={
+ <Space>
+ <Tag color="green">v{version.version}</Tag>
+ <Text>{version.versionDescription}</Text>
+ </Space>
+ }
+ description={
+ version.seedFile && (
+ <Space>
+ <FileTextOutlined />
+ <Text type="secondary">{version.seedFile.name}</Text>
+ </Space>
+ )
+ }
+ />
+ </List.Item>
+ )}
+ />
+ </div>
+
+ <Alert
+ message="发布须知"
+ description={
+ <ul style={{ marginBottom: 0, paddingLeft: 20 }}>
+ <li>发布后的作品将公开展示,所有用户都可以查看和下载</li>
+ <li>请确保作品内容符合社区规范,不包含违法违规内容</li>
+ <li>发布后您仍可以编辑作品信息和添加新版本</li>
+ <li>请尊重他人知识产权,确保作品为原创或已获得授权</li>
+ </ul>
+ }
+ type="warning"
+ showIcon
+ style={{ marginBottom: 24 }}
+ />
+
+ <Space size="middle">
+ <Button onClick={onPrev} size="large">上一步</Button>
+ <Button
+ type="primary"
+ onClick={handlePublish}
+ loading={publishing}
+ icon={<SendOutlined />}
+ size="large"
+ >
+ 确认发布
+ </Button>
+ </Space>
+ </Card>
+ );
+};
\ No newline at end of file
diff --git a/src/feature/work/CreateWork.tsx b/src/feature/work/CreateWork.tsx
new file mode 100644
index 0000000..5e97433
--- /dev/null
+++ b/src/feature/work/CreateWork.tsx
@@ -0,0 +1,53 @@
+import React, { useMemo } from 'react';
+import { Layout, Steps, Card, Typography } from 'antd';
+import { FileTextOutlined, FileImageOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons';
+import { BasicInfoStep, CoverUploadStep, VersionManagementStep, ConfirmPublishStep } from './CreatWorkComponents';
+import { useCreateWorkForm } from './hooks';
+
+
+const { Content } = Layout;
+const { Title, Paragraph } = Typography;
+
+const CreateWork: React.FC = () => {
+ const { currentStep, formData, handleUpdateFormData, handleNext, handlePrev, handlePublish } = useCreateWorkForm();
+
+ const steps = useMemo(() => [
+ { title: '基础信息', description: '填写作品基本信息', icon: <FileTextOutlined /> },
+ { title: '上传封面', description: '上传作品封面图片', icon: <FileImageOutlined /> },
+ { title: '版本管理', description: '添加作品版本和文件', icon: <SaveOutlined /> },
+ { title: '确认发布', description: '检查并发布作品', icon: <SendOutlined /> },
+ ], []);
+
+ const renderStepContent = useMemo(() => {
+ const commonProps = { data: formData, onUpdate: handleUpdateFormData, onNext: handleNext, onPrev: handlePrev };
+
+ switch (currentStep) {
+ case 0: return <BasicInfoStep {...commonProps} />;
+ case 1: return <CoverUploadStep {...commonProps} />;
+ case 2: return <VersionManagementStep {...commonProps} />;
+ case 3: return <ConfirmPublishStep {...commonProps} onPublish={handlePublish} />;
+ default: return null;
+ }
+ }, [currentStep, formData, handleUpdateFormData, handleNext, handlePrev, handlePublish]);
+
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <div style={{ maxWidth: 1000, margin: '0 auto' }}>
+ <Card style={{ marginBottom: 24 }}>
+ <Title level={2} style={{ marginBottom: 8 }}>创建新作品</Title>
+ <Paragraph type="secondary">通过以下步骤发布您的创意作品,与社区分享您的才华</Paragraph>
+ </Card>
+
+ <Card style={{ marginBottom: 24 }}>
+ <Steps current={currentStep} items={steps} />
+ </Card>
+
+ {renderStepContent}
+ </div>
+ </Content>
+ </Layout>
+ );
+};
+
+export default CreateWork;
\ No newline at end of file
diff --git a/src/feature/work/EditWork.tsx b/src/feature/work/EditWork.tsx
new file mode 100644
index 0000000..f56d42a
--- /dev/null
+++ b/src/feature/work/EditWork.tsx
@@ -0,0 +1,753 @@
+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}
+ />
+ </>
+ );
+};
\ No newline at end of file
diff --git a/src/feature/work/Work.tsx b/src/feature/work/Work.tsx
new file mode 100644
index 0000000..56939d1
--- /dev/null
+++ b/src/feature/work/Work.tsx
@@ -0,0 +1,288 @@
+// src/feature/work/Work.tsx
+
+import React, { useEffect, useState, useCallback } from 'react';
+import { Layout, Flex, Spin, Alert, message } from 'antd';
+import { useParams } from 'react-router';
+import { useAppDispatch, useAppSelector } from '../../store/hooks';
+import {
+ fetchArtworkDetail, fetchComments, addComment, selectCurrentArtwork,
+ selectWorkLoading, selectWorkError, selectComments, setCommentsPage, clearCurrentArtwork,
+ updateArtwork, deleteComment
+} from './workSlice';
+import { Sidebar, MainContent } from './WorkComponents';
+import { EditWorkControls } from './EditWork';
+import type { ArtworkData, Comment } from './types';
+
+const { Content } = Layout;
+
+interface UserState {
+ userid?: string | number;
+ username?: string;
+}
+
+interface RootState {
+ user: UserState;
+ work: {
+ currentArtwork: ArtworkData | null;
+ loading: {
+ artwork: boolean;
+ comments: boolean;
+ addComment: boolean;
+ updateArtwork?: boolean;
+ deleteComment?: boolean;
+ };
+ error: {
+ artwork: string | null;
+ comments: string | null;
+ addComment: string | null;
+ updateArtwork?: string | null;
+ deleteComment?: string | null;
+ };
+ comments: {
+ list: Comment[];
+ total: number;
+ current: number;
+ pageSize: number;
+ };
+ };
+}
+
+const Work: React.FC = () => {
+ const dispatch = useAppDispatch();
+ const { work_id } = useParams<{ work_id: string }>();
+
+ // Redux state
+ const currentArtwork = useAppSelector(selectCurrentArtwork);
+ const loading = useAppSelector(selectWorkLoading);
+ const error = useAppSelector(selectWorkError);
+ const comments = useAppSelector(selectComments);
+ const currentUser = useAppSelector((state: RootState) => state.user);
+
+ // Local state for edit functionality
+ const [showEditControls, setShowEditControls] = useState<boolean>(false);
+
+ // 初始化数据
+ useEffect(() => {
+ if (work_id) {
+ dispatch(clearCurrentArtwork());
+ dispatch(fetchArtworkDetail(work_id));
+ dispatch(fetchComments({ workId: work_id, page: 1, pageSize: 5 }));
+ }
+ }, [work_id, dispatch]);
+
+ // 权限判断
+ const isAuthor: boolean = Boolean(
+ currentUser?.userid && currentArtwork?.authorId &&
+ String(currentUser.userid) === String(currentArtwork.authorId)
+ );
+
+ // 显示编辑控件
+ useEffect(() => {
+ setShowEditControls(isAuthor);
+ }, [isAuthor]);
+
+ // 评论分页处理
+ const handleCommentsPageChange = useCallback((page: number, pageSize: number): void => {
+ dispatch(setCommentsPage({ current: page, pageSize }));
+ if (work_id) {
+ dispatch(fetchComments({ workId: work_id, page, pageSize }));
+ }
+ }, [work_id, dispatch]);
+
+ // 添加评论
+ const handleAddComment = useCallback(async (content: string, parentId?: string): Promise<void> => {
+ if (!work_id) return;
+ try {
+ await dispatch(addComment({ workId: work_id, content, parentId })).unwrap();
+ message.success(parentId ? '回复发表成功!' : '评论发表成功!');
+ } catch {
+ message.error('评论发表失败,请重试');
+ }
+ }, [work_id, dispatch]);
+
+ // ==================== EditWork 集成功能 ====================
+
+ // 更新作品信息
+ const handleUpdateArtwork = useCallback(async (updates: Partial<ArtworkData>): Promise<void> => {
+ if (!work_id || !currentArtwork) return;
+
+ try {
+ // 检查 updateArtwork action 是否存在
+ if (updateArtwork) {
+ await dispatch(updateArtwork({
+ workId: work_id,
+ updates
+ })).unwrap();
+ message.success('作品信息更新成功!');
+ } else {
+ // 临时处理:直接更新本地状态
+ console.log('updateArtwork action not available, using local update');
+ message.success('作品信息更新成功!(本地更新)');
+ }
+ } catch (error) {
+ console.error('更新作品失败:', error);
+ message.error('更新失败,请重试');
+ throw error;
+ }
+ }, [work_id, currentArtwork, dispatch]);
+
+ // 删除评论
+ const handleDeleteComment = useCallback(async (commentId: string): Promise<void> => {
+ if (!work_id) return;
+
+ try {
+ // 检查 deleteComment action 是否存在
+ if (deleteComment) {
+ await dispatch(deleteComment({
+ workId: work_id,
+ commentId
+ })).unwrap();
+ message.success('评论删除成功!');
+
+ // 重新加载评论列表
+ dispatch(fetchComments({
+ workId: work_id,
+ page: comments.current,
+ pageSize: comments.pageSize
+ }));
+ } else {
+ // 临时处理
+ console.log('deleteComment action not available');
+ message.success('评论删除成功!(本地处理)');
+ }
+ } catch (error) {
+ console.error('删除评论失败:', error);
+ message.error('删除评论失败,请重试');
+ throw error;
+ }
+ }, [work_id, dispatch, comments.current, comments.pageSize]);
+
+ // 兼容旧的编辑处理器
+ const handleEditArtwork = useCallback((): void => {
+ if (isAuthor) {
+ setShowEditControls(true);
+ message.info('请使用上方的编辑控件来修改作品信息');
+ } else {
+ message.warning('您没有编辑此作品的权限');
+ }
+ }, [isAuthor]);
+
+ // ==================== 渲染逻辑 ====================
+
+ // 加载状态
+ if (loading.artwork) {
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh' }}>
+ <Spin size="large" tip="加载作品详情中..." />
+ </div>
+ </Content>
+ </Layout>
+ );
+ }
+
+ // 错误状态
+ if (error.artwork) {
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <Flex justify="center" style={{ width: '100%' }}>
+ <div style={{ width: '90%', maxWidth: 1200 }}>
+ <Alert
+ message="加载失败"
+ description={error.artwork}
+ type="error"
+ showIcon
+ />
+ </div>
+ </Flex>
+ </Content>
+ </Layout>
+ );
+ }
+
+ // 作品不存在
+ if (!currentArtwork) {
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <Flex justify="center" align="center" style={{ height: '50vh' }}>
+ <Alert
+ message="作品不存在"
+ description="未找到对应的作品信息"
+ type="warning"
+ showIcon
+ />
+ </Flex>
+ </Content>
+ </Layout>
+ );
+ }
+
+ // 确保数据完整性,添加默认值
+ const safeArtwork = {
+ ...currentArtwork,
+ usersSeedingCurrently: currentArtwork.usersSeedingCurrently || [],
+ usersSeedingHistory: currentArtwork.usersSeedingHistory || [],
+ versionList: currentArtwork.versionList || [],
+ comments: comments.list || []
+ };
+
+ const safeComments = {
+ ...comments,
+ list: comments.list || []
+ };
+
+ // 主要内容渲染
+ return (
+ <Layout style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
+ <Content style={{ padding: '20px' }}>
+ <Flex justify="center" style={{ width: '100%' }}>
+ <div style={{ width: '90%', maxWidth: 1200 }}>
+ {/* EditWork 编辑控件 - 仅作者可见 */}
+ {showEditControls && (
+ <div style={{ marginBottom: 20 }}>
+ <EditWorkControls
+ artwork={safeArtwork}
+ isAuthor={isAuthor}
+ onUpdate={handleUpdateArtwork}
+ onDeleteComment={handleDeleteComment}
+ />
+ </div>
+ )}
+
+ {/* 原有的作品展示布局 */}
+ <Flex gap={20}>
+ <Sidebar
+ coverUrl={safeArtwork.artworkCover}
+ currentUsers={safeArtwork.usersSeedingCurrently}
+ historyUsers={safeArtwork.usersSeedingHistory}
+ />
+ <MainContent
+ artworkName={safeArtwork.artworkName || safeArtwork.artworkCategory}
+ author={safeArtwork.author}
+ category={safeArtwork.artworkCategory}
+ description={safeArtwork.artworkDescription}
+ versions={safeArtwork.versionList}
+ comments={safeComments.list}
+ commentsTotal={safeComments.total}
+ commentsLoading={loading.comments}
+ commentsError={error.comments}
+ addCommentLoading={loading.addComment}
+ onCommentsPageChange={handleCommentsPageChange}
+ onAddComment={handleAddComment}
+ currentPage={safeComments.current}
+ pageSize={safeComments.pageSize}
+ isAuthor={isAuthor}
+ onEditArtwork={handleEditArtwork}
+ />
+ </Flex>
+ </div>
+ </Flex>
+ </Content>
+ </Layout>
+ );
+};
+
+export default Work;
\ No newline at end of file
diff --git a/src/feature/work/WorkComponents.tsx b/src/feature/work/WorkComponents.tsx
new file mode 100644
index 0000000..64ef37a
--- /dev/null
+++ b/src/feature/work/WorkComponents.tsx
@@ -0,0 +1,368 @@
+import React, { useState } from 'react';
+import { Card, Typography, Tag, Flex, Table, Collapse, List, Spin, Alert, Button, Input, Form, message } from 'antd';
+import { EditOutlined, SendOutlined } from '@ant-design/icons';
+import ReactMarkdown from 'react-markdown';
+import type { ColumnsType } from 'antd/es/table';
+import type { PaginationConfig } from 'antd/es/pagination';
+import type { FormInstance } from 'antd/es/form';
+import type { Comment, Version, User, HistoryUser } from './types';
+import { parseUploadSize } from './types';
+
+const { Title, Paragraph } = Typography;
+const { Panel } = Collapse;
+const { TextArea } = Input;
+
+// 作品封面组件
+export const ArtworkCover: React.FC<{ coverUrl: string }> = ({ coverUrl }) => (
+ <Card cover={<img alt="作品封面" src={coverUrl} style={{ height: 250, objectFit: 'cover' }} />} />
+);
+
+// 当前做种用户组件
+export const CurrentSeedingUsers: React.FC<{ users: User[] }> = ({ users }) => (
+ <Card>
+ <Title level={4} style={{ marginBottom: 12 }}>当前做种用户</Title>
+ <Flex wrap="wrap" gap={8}>
+ {users.map((user) => (
+ <Tag key={user.userId} color="green">{user.username}</Tag>
+ ))}
+ </Flex>
+ </Card>
+);
+
+// 历史做种用户组件
+export const HistorySeedingUsers: React.FC<{ users: HistoryUser[] }> = ({ users }) => {
+ const sortedUsers = [...users].sort((a, b) => parseUploadSize(b.uploadTotal) - parseUploadSize(a.uploadTotal));
+
+ const columns: ColumnsType<HistoryUser> = [
+ { title: '用户名', dataIndex: 'username', key: 'username' },
+ {
+ title: '上传总量',
+ dataIndex: 'uploadTotal',
+ key: 'uploadTotal',
+ sorter: (a: HistoryUser, b: HistoryUser) => parseUploadSize(a.uploadTotal) - parseUploadSize(b.uploadTotal),
+ },
+ ];
+
+ return (
+ <Card>
+ <Title level={4} style={{ marginBottom: 12 }}>历史做种用户</Title>
+ <Table columns={columns} dataSource={sortedUsers} rowKey="username" pagination={false} size="small" />
+ </Card>
+ );
+};
+
+// 作品描述组件
+export const ArtworkDescription: React.FC<{
+ name: string;
+ author: string;
+ category: string;
+ description: string;
+ isAuthor?: boolean;
+ onEdit?: () => void;
+}> = ({ name, author, category, description, isAuthor = false, onEdit }) => (
+ <Card style={{ marginBottom: 20 }}>
+ <Flex justify="space-between" align="flex-start">
+ <div style={{ flex: 1 }}>
+ <Title level={2} style={{ marginBottom: 8 }}>{name}</Title>
+ <Paragraph style={{ marginBottom: 8, fontSize: 16 }}>
+ <strong>作者:</strong>{author}
+ </Paragraph>
+ <div style={{ marginBottom: 16 }}>
+ <Tag color="blue">{category}</Tag>
+ </div>
+ <div style={{ lineHeight: 1.6 }}>
+ <ReactMarkdown>{description}</ReactMarkdown>
+ </div>
+ </div>
+ {isAuthor && (
+ <Button type="primary" icon={<EditOutlined />} onClick={onEdit} style={{ marginLeft: 16 }}>
+ 编辑作品
+ </Button>
+ )}
+ </Flex>
+ </Card>
+);
+
+// 版本历史组件
+export const VersionHistory: React.FC<{ versions: Version[] }> = ({ versions }) => (
+ <Card title="版本历史" style={{ marginBottom: 20 }}>
+ <Collapse>
+ {versions.map((version, index) => (
+ <Panel
+ header={`版本 ${version.version}`}
+ key={`version-${index}`}
+ extra={<Tag color="blue">v{version.version}</Tag>}
+ >
+ <div style={{ marginBottom: 16 }}>
+ <strong>版本描述:</strong>
+ <div style={{ marginTop: 8, lineHeight: 1.6 }}>
+ <ReactMarkdown>{version.versionDescription}</ReactMarkdown>
+ </div>
+ </div>
+ <div>
+ <strong>种子文件:</strong>
+ <a href={version.seedFile} target="_blank" rel="noopener noreferrer" style={{ marginLeft: 8 }}>
+ 下载链接
+ </a>
+ </div>
+ </Panel>
+ ))}
+ </Collapse>
+ </Card>
+);
+
+// 评论项组件(递归)
+export const CommentItem: React.FC<{
+ comment: Comment;
+ level?: number;
+ onReply?: (parentId: string, parentAuthor: string) => void;
+}> = ({ comment, level = 0, onReply }) => (
+ <div style={{ marginLeft: level * 20 }}>
+ <div style={{ marginBottom: 8 }}>
+ <Paragraph style={{ marginBottom: 4 }}>
+ <strong>{comment.author}:</strong>{comment.content}
+ </Paragraph>
+ <div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>
+ {comment.createdAt && <span style={{ marginRight: 16 }}>{comment.createdAt}</span>}
+ {onReply && (
+ <Button
+ type="link"
+ size="small"
+ style={{ padding: 0, height: 'auto', fontSize: 12 }}
+ onClick={() => onReply(comment.id || comment.author, comment.author)}
+ >
+ 回复
+ </Button>
+ )}
+ </div>
+ </div>
+ {comment.child && comment.child.length > 0 && (
+ <div style={{
+ borderLeft: level === 0 ? '2px solid #f0f0f0' : 'none',
+ paddingLeft: level === 0 ? 12 : 0
+ }}>
+ {comment.child.map((child, index) => (
+ <CommentItem
+ key={child.id || `child-${level}-${index}`}
+ comment={child}
+ level={level + 1}
+ onReply={onReply}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+);
+
+// 发表评论组件
+export const CommentForm: React.FC<{
+ loading?: boolean;
+ onSubmit: (content: string, parentId?: string) => void;
+ replyTo?: { id: string; author: string } | null;
+ onCancelReply?: () => void;
+}> = ({ loading = false, onSubmit, replyTo, onCancelReply }) => {
+ const [form]: [FormInstance] = Form.useForm();
+ const [content, setContent] = useState('');
+
+ const handleSubmit = (): void => {
+ if (!content.trim()) {
+ message.warning('请输入评论内容');
+ return;
+ }
+ onSubmit(content.trim(), replyTo?.id);
+ setContent('');
+ form.resetFields();
+ };
+
+ const placeholder = replyTo ? `回复 @${replyTo.author}:` : "发表你的看法...";
+
+ return (
+ <Card
+ size="small"
+ style={{ marginBottom: 16 }}
+ title={replyTo ? (
+ <div style={{ fontSize: 14 }}>
+ <span>回复 @{replyTo.author}</span>
+ <Button type="link" size="small" onClick={onCancelReply} style={{ padding: '0 0 0 8px', fontSize: 12 }}>
+ 取消
+ </Button>
+ </div>
+ ) : undefined}
+ >
+ <Form form={form} layout="vertical">
+ <Form.Item>
+ <TextArea
+ value={content}
+ onChange={(e) => setContent(e.target.value)}
+ placeholder={placeholder}
+ rows={3}
+ maxLength={500}
+ showCount
+ />
+ </Form.Item>
+ <Form.Item style={{ marginBottom: 0 }}>
+ <Flex justify="flex-end" gap={8}>
+ {replyTo && <Button onClick={onCancelReply}>取消</Button>}
+ <Button
+ type="primary"
+ icon={<SendOutlined />}
+ loading={loading}
+ onClick={handleSubmit}
+ disabled={!content.trim()}
+ >
+ {replyTo ? '发表回复' : '发表评论'}
+ </Button>
+ </Flex>
+ </Form.Item>
+ </Form>
+ </Card>
+ );
+};
+
+// 评论区组件
+export const CommentSection: React.FC<{
+ comments: Comment[];
+ total: number;
+ loading?: boolean;
+ error?: string | null;
+ addCommentLoading?: boolean;
+ onPageChange: (page: number, pageSize: number) => void;
+ onAddComment: (content: string, parentId?: string) => void;
+ currentPage: number;
+ pageSize: number;
+}> = ({ comments, total, loading, error, addCommentLoading, onPageChange, onAddComment, currentPage, pageSize }) => {
+ const [replyTo, setReplyTo] = useState<{ id: string; author: string } | null>(null);
+
+ const handleReply = (parentId: string, parentAuthor: string): void => {
+ setReplyTo({ id: parentId, author: parentAuthor });
+ };
+
+ const handleCancelReply = (): void => {
+ setReplyTo(null);
+ };
+
+ const handleSubmitComment = (content: string, parentId?: string): void => {
+ onAddComment(content, parentId);
+ setReplyTo(null);
+ };
+
+ const paginationConfig: PaginationConfig = {
+ current: currentPage,
+ pageSize,
+ total,
+ showSizeChanger: true,
+ showQuickJumper: true,
+ showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条评论`,
+ pageSizeOptions: ['5', '10', '20'],
+ onChange: onPageChange,
+ onShowSizeChange: onPageChange,
+ };
+
+ return (
+ <Card title={`评论 (${total})`} style={{ marginBottom: 20 }}>
+ <CommentForm
+ loading={addCommentLoading}
+ onSubmit={handleSubmitComment}
+ replyTo={replyTo}
+ onCancelReply={handleCancelReply}
+ />
+
+ {error ? (
+ <Alert message="加载评论失败" description={error} type="error" showIcon />
+ ) : loading ? (
+ <Flex justify="center" align="center" style={{ minHeight: 200 }}>
+ <Spin size="large" />
+ </Flex>
+ ) : comments.length > 0 ? (
+ <List
+ dataSource={comments}
+ pagination={paginationConfig}
+ renderItem={(comment, index) => (
+ <List.Item
+ key={comment.id || `comment-${index}`}
+ style={{ border: 'none', padding: '16px 0', borderBottom: '1px solid #f0f0f0' }}
+ >
+ <CommentItem comment={comment} onReply={handleReply} />
+ </List.Item>
+ )}
+ />
+ ) : (
+ <Paragraph style={{ textAlign: 'center', color: '#999', margin: '20px 0' }}>
+ 暂无评论,来发表第一条评论吧!
+ </Paragraph>
+ )}
+ </Card>
+ );
+};
+
+// 侧边栏组合组件
+export const Sidebar: React.FC<{
+ coverUrl: string;
+ currentUsers: User[];
+ historyUsers: HistoryUser[];
+ loading?: boolean;
+ error?: string | null;
+}> = ({ coverUrl, currentUsers, historyUsers, loading, error }) => (
+ <Flex flex={1} vertical gap={20}>
+ <ArtworkCover coverUrl={coverUrl} />
+ {loading ? (
+ <Flex justify="center" align="center" style={{ minHeight: 200 }}>
+ <Spin size="large" />
+ </Flex>
+ ) : error ? (
+ <Alert message="加载用户信息失败" description={error} type="error" showIcon />
+ ) : (
+ <>
+ <CurrentSeedingUsers users={currentUsers} />
+ <HistorySeedingUsers users={historyUsers} />
+ </>
+ )}
+ </Flex>
+);
+
+// 主内容区组合组件
+export const MainContent: React.FC<{
+ artworkName: string;
+ author: string;
+ category: string;
+ description: string;
+ versions: Version[];
+ comments: Comment[];
+ commentsTotal: number;
+ commentsLoading?: boolean;
+ commentsError?: string | null;
+ addCommentLoading?: boolean;
+ onCommentsPageChange: (page: number, pageSize: number) => void;
+ onAddComment: (content: string, parentId?: string) => void;
+ currentPage: number;
+ pageSize: number;
+ isAuthor?: boolean;
+ onEditArtwork?: () => void;
+}> = ({
+ artworkName, author, category, description, versions, comments, commentsTotal,
+ commentsLoading, commentsError, addCommentLoading, onCommentsPageChange, onAddComment,
+ currentPage, pageSize, isAuthor, onEditArtwork
+}) => (
+ <Flex flex={4} vertical>
+ <ArtworkDescription
+ name={artworkName}
+ author={author}
+ category={category}
+ description={description}
+ isAuthor={isAuthor}
+ onEdit={onEditArtwork}
+ />
+ <VersionHistory versions={versions} />
+ <CommentSection
+ comments={comments}
+ total={commentsTotal}
+ loading={commentsLoading}
+ error={commentsError}
+ addCommentLoading={addCommentLoading}
+ onPageChange={onCommentsPageChange}
+ onAddComment={onAddComment}
+ currentPage={currentPage}
+ pageSize={pageSize}
+ />
+ </Flex>
+ );
\ No newline at end of file
diff --git a/src/feature/work/hooks.ts b/src/feature/work/hooks.ts
new file mode 100644
index 0000000..f07189e
--- /dev/null
+++ b/src/feature/work/hooks.ts
@@ -0,0 +1,34 @@
+import { useCallback, useState } from "react";
+import { useNavigate } from "react-router";
+import type {
+ WorkFormData,
+ BasicInfo,
+ CoverInfo,
+ VersionFormData,
+ ArtworkCategory
+} from './types';
+
+// ==================== Hook ====================
+export const useCreateWorkForm = () => {
+ const navigate = useNavigate();
+ const [currentStep, setCurrentStep] = useState<number>(0);
+ const [formData, setFormData] = useState<WorkFormData>({
+ basicInfo: {
+ artworkName: '',
+ artworkCategory: '概念设计' as ArtworkCategory,
+ artworkDescription: ''
+ },
+ coverInfo: {},
+ versions: [],
+ });
+
+ const handleUpdateFormData = useCallback((field: keyof WorkFormData, value: BasicInfo | CoverInfo | VersionFormData[]) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ }, []);
+
+ const handleNext = useCallback(() => setCurrentStep(prev => Math.min(prev + 1, 3)), []);
+ const handlePrev = useCallback(() => setCurrentStep(prev => Math.max(prev - 1, 0)), []);
+ const handlePublish = useCallback(() => navigate('/work/new-work-id'), [navigate]);
+
+ return { currentStep, formData, handleUpdateFormData, handleNext, handlePrev, handlePublish };
+};
\ No newline at end of file
diff --git a/src/feature/work/mockData.ts b/src/feature/work/mockData.ts
new file mode 100644
index 0000000..8470e61
--- /dev/null
+++ b/src/feature/work/mockData.ts
@@ -0,0 +1,405 @@
+import type { ArtworkData, Comment } from './types';
+
+// 生成随机时间的工具函数
+const generateRandomDate = (daysAgo: number): string => {
+ const date = new Date();
+ date.setDate(date.getDate() - Math.floor(Math.random() * daysAgo));
+ return date.toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+};
+
+// 通用评论数据
+const baseComments: Comment[] = [
+ {
+ id: 'comment_1',
+ content: '这个作品真的很棒!设计思路很新颖,学到了很多东西。',
+ author: '设计爱好者小王',
+ authorId: 'user_wang_001',
+ createdAt: generateRandomDate(3),
+ child: [
+ {
+ id: 'comment_1_1',
+ content: '同感!特别是色彩搭配的部分,很有启发性。',
+ author: '视觉设计师李雷',
+ authorId: 'user_lilei_002',
+ createdAt: generateRandomDate(2),
+ child: [],
+ },
+ {
+ id: 'comment_1_2',
+ content: '确实,作者的审美很在线,期待更多作品!',
+ author: '创意总监韩梅梅',
+ authorId: 'user_hanmeimei_003',
+ createdAt: generateRandomDate(2),
+ child: [
+ {
+ id: 'comment_1_2_1',
+ content: '@创意总监韩梅梅 您说得对,这种风格确实很难得',
+ author: '设计爱好者小王',
+ authorId: 'user_wang_001',
+ createdAt: generateRandomDate(1),
+ child: [],
+ }
+ ],
+ },
+ ],
+ },
+ {
+ id: 'comment_2',
+ content: '技术实现方面有什么特别的难点吗?想了解一下制作过程。',
+ author: '技术控阿明',
+ authorId: 'user_aming_004',
+ createdAt: generateRandomDate(5),
+ child: [],
+ },
+ {
+ id: 'comment_3',
+ content: '看起来很专业,请问这是用什么软件制作的?',
+ author: '新手小白',
+ authorId: 'user_xiaobai_005',
+ createdAt: generateRandomDate(4),
+ child: [
+ {
+ id: 'comment_3_1',
+ content: '从效果来看应该是用Adobe全家桶,主要是PS和AI',
+ author: '软件专家老刘',
+ authorId: 'user_laoliu_006',
+ createdAt: generateRandomDate(3),
+ child: [],
+ }
+ ],
+ },
+ {
+ id: 'comment_4',
+ content: '色彩运用得很巧妙,给人一种很舒服的视觉感受。',
+ author: '色彩搭配师小张',
+ authorId: 'user_xiaozhang_007',
+ createdAt: generateRandomDate(6),
+ child: [],
+ },
+ {
+ id: 'comment_5',
+ content: '作品质量很高,但是感觉在某些细节上还可以再优化一下。',
+ author: '完美主义者',
+ authorId: 'user_perfect_008',
+ createdAt: generateRandomDate(7),
+ child: [
+ {
+ id: 'comment_5_1',
+ content: '能具体说说哪些地方可以优化吗?我也想学习学习',
+ author: '求知者小陈',
+ authorId: 'user_xiaochen_009',
+ createdAt: generateRandomDate(6),
+ child: [],
+ }
+ ],
+ },
+ {
+ id: 'comment_6',
+ content: '已经下载收藏了,感谢分享!🎉',
+ author: '收藏家大佬',
+ authorId: 'user_collector_010',
+ createdAt: generateRandomDate(8),
+ child: [],
+ },
+ {
+ id: 'comment_7',
+ content: '这个风格很适合我正在做的项目,能不能商用呢?',
+ author: '商务合作方',
+ authorId: 'user_business_011',
+ createdAt: generateRandomDate(9),
+ child: [],
+ },
+ {
+ id: 'comment_8',
+ content: '教程什么时候出?期待大神的分享!',
+ author: '学习狂人',
+ authorId: 'user_learner_012',
+ createdAt: generateRandomDate(10),
+ child: [],
+ }
+];
+
+// 作品数据集合
+export const mockArtworks: Record<string, ArtworkData> = {
+ '12345': {
+ artworkId: '12345',
+ artworkCover: 'https://picsum.photos/300/400?random=1',
+ author: '视觉设计师张三',
+ authorId: 'author_zhangsan_001',
+ artworkName: '未来城市概念设计',
+ artworkCategory: '概念设计',
+ comments: [],
+ artworkDescription: `# 未来城市概念设计
+
+这是一个关于2050年智慧城市的概念设计作品。整个设计融合了**可持续发展**、**人工智能**和**绿色科技**的理念。
+
+## 设计理念
+
+- 🌱 **生态友好**:建筑与自然和谐共生
+- 🤖 **智能化**:AI驱动的城市管理系统
+- 🚗 **零排放**:全面电动化的交通系统
+- 🏗️ **模块化**:可扩展的建筑结构
+
+## 技术特点
+
+采用了最新的**参数化设计**方法,通过算法生成建筑形态,确保每个结构都能最大化利用自然光和风能。
+
+*希望这个设计能为未来城市规划提供一些启发。*`,
+ versionList: [
+ {
+ version: '1.0',
+ seedFile: 'magnet:?xt=urn:btih:future_city_v1_0&dn=未来城市v1.0.zip',
+ versionDescription: `## 初始版本 v1.0
+
+### 包含内容
+- 🎨 **高分辨率效果图** (4K)
+- 📐 **CAD源文件** (.dwg)
+- 🖼️ **PSD分层文件**
+- 📄 **设计说明文档**
+
+### 更新说明
+首次发布,包含完整的设计方案和素材文件。`,
+ },
+ {
+ version: '1.1',
+ seedFile: 'magnet:?xt=urn:btih:future_city_v1_1&dn=未来城市v1.1.zip',
+ versionDescription: `## 更新版本 v1.1
+
+### 新增内容
+- 🌃 **夜景渲染图**
+- 🎬 **动画演示视频** (1080p)
+- 🔧 **Blender源文件**
+
+### 修复内容
+- 修复了部分贴图丢失问题
+- 优化了文件结构
+- 添加了英文版说明文档`,
+ },
+ ],
+ usersSeedingCurrently: [
+ { username: '设计师小李', userId: 'user_xiaoli_101' },
+ { username: '建筑师王工', userId: 'user_wanggong_102' },
+ { username: '学生小赵', userId: 'user_xiaozhao_103' },
+ { username: '创意总监', userId: 'user_director_104' },
+ ],
+ usersSeedingHistory: [
+ {
+ username: '资深下载者', uploadTotal: '156.8GB',
+ userId: ''
+ },
+ {
+ username: '设计素材库', uploadTotal: '89.2GB',
+ userId: ''
+ },
+ {
+ username: '创意工作室', uploadTotal: '67.5GB',
+ userId: ''
+ },
+ {
+ username: '学院资源组', uploadTotal: '45.3GB',
+ userId: ''
+ },
+ {
+ username: '独立设计师', uploadTotal: '23.7GB',
+ userId: ''
+ },
+ ],
+ },
+
+ '23456': {
+ artworkId: '23456',
+ artworkCover: 'https://picsum.photos/300/400?random=2',
+ author: 'UI设计师李四',
+ authorId: 'author_lisi_002',
+ artworkName: '移动应用界面设计套件',
+ artworkCategory: 'UI/UX设计',
+ comments: [],
+ artworkDescription: `# 移动应用界面设计套件
+
+一套完整的移动端UI设计规范和组件库,包含**100+个精美界面**和**500+个设计组件**。
+
+## 包含内容
+
+### 📱 界面设计
+- 登录注册页面
+- 主页和导航
+- 商品展示页面
+- 个人中心
+- 设置页面
+
+### 🎨 设计系统
+- **颜色规范**:主色调、辅助色、状态色
+- **字体系统**:标题、正文、注释文字
+- **图标库**:线性图标、填充图标
+- **组件库**:按钮、输入框、卡片等`,
+ versionList: [
+ {
+ version: '1.0',
+ seedFile: 'magnet:?xt=urn:btih:ui_kit_v1_0&dn=UI设计套件v1.0.zip',
+ versionDescription: `## 基础版本 v1.0
+
+### 核心功能
+- 📱 **50个界面模板**
+- 🎨 **基础组件库**
+- 📋 **设计规范文档**
+- 🎯 **Sketch源文件**`,
+ },
+ ],
+ usersSeedingCurrently: [
+ { username: 'UI设计新手', userId: 'user_ui_newbie_201' },
+ { username: '产品经理小王', userId: 'user_pm_wang_202' },
+ ],
+ usersSeedingHistory: [
+ {
+ username: 'UI设计公司', uploadTotal: '78.9GB',
+ userId: ''
+ },
+ {
+ username: '设计师联盟', uploadTotal: '45.6GB',
+ userId: ''
+ },
+ {
+ username: '学习小组', uploadTotal: '23.4GB',
+ userId: ''
+ },
+ ],
+ },
+
+ '67890': {
+ artworkId: '67890',
+ artworkCover: 'https://picsum.photos/300/400?random=6',
+ author: '刘松林',
+ authorId: '2', // 用户ID为2
+ artworkName: 'React组件库开发指南',
+ artworkCategory: '前端开发',
+ comments: [],
+ artworkDescription: `# React组件库开发指南
+
+一套完整的**企业级React组件库**开发教程和源码,包含从设计到发布的完整流程。
+
+## 项目特色
+
+### 🚀 技术栈
+- **React 18** + **TypeScript**
+- **Styled-components** 样式解决方案
+- **Storybook** 组件文档
+- **Jest** + **Testing Library** 测试
+
+### 📦 组件覆盖
+- **基础组件**: Button, Input, Select, Modal等
+- **布局组件**: Grid, Layout, Container等
+- **数据展示**: Table, List, Card, Timeline等`,
+ versionList: [
+ {
+ version: '1.0',
+ seedFile: 'magnet:?xt=urn:btih:react_components_v1_0&dn=React组件库v1.0.zip',
+ versionDescription: `## 基础版本 v1.0
+
+### 核心内容
+- 📚 **完整教程文档** (50+页)
+- 💻 **基础组件源码** (20+个组件)
+- 🧪 **单元测试示例**
+- 📖 **Storybook配置**`,
+ },
+ ],
+ usersSeedingCurrently: [
+ { username: '前端新手小李', userId: 'user_frontend_newbie_601' },
+ { username: 'React爱好者', userId: 'user_react_fan_602' },
+ ],
+ usersSeedingHistory: [
+ {
+ username: '大厂前端团队', uploadTotal: '567.8GB',
+ userId: ''
+ },
+ {
+ username: '开源社区', uploadTotal: '234.5GB',
+ userId: ''
+ },
+ {
+ username: '技术培训机构', uploadTotal: '189.7GB',
+ userId: ''
+ },
+ ],
+ },
+};
+
+// 获取指定作品的评论数据
+export const getCommentsForArtwork = (artworkId: string): Comment[] => {
+ // 为不同作品生成不同的评论
+ const commentVariations: Record<string, Comment[]> = {
+ '12345': baseComments,
+ '23456': baseComments.slice(0, 5).map(comment => ({
+ ...comment,
+ id: `ui_${comment.id}`,
+ content: comment.content.replace('作品', 'UI套件').replace('设计', '界面设计'),
+ })),
+ '67890': [
+ {
+ id: 'dev_comment_1',
+ content: '这个组件库的设计思路很棒!TypeScript类型定义特别完善。',
+ author: '前端架构师张工',
+ authorId: 'user_architect_zhang',
+ createdAt: generateRandomDate(2),
+ child: [
+ {
+ id: 'dev_comment_1_1',
+ content: '同感!特别是组件API的设计,很符合React的设计理念。',
+ author: 'React核心开发者',
+ authorId: 'user_react_core',
+ createdAt: generateRandomDate(1),
+ child: [],
+ }
+ ],
+ },
+ {
+ id: 'dev_comment_2',
+ content: '构建配置写得很详细,我们团队已经参考这个搭建了自己的组件库。',
+ author: '技术负责人小刘',
+ authorId: 'user_tech_liu',
+ createdAt: generateRandomDate(3),
+ child: [],
+ },
+ ],
+ };
+
+ return commentVariations[artworkId] || baseComments;
+};
+
+// 根据作品ID获取作品数据
+export const getArtworkById = (artworkId: string): ArtworkData | null => {
+ const artwork = mockArtworks[artworkId];
+ if (artwork) {
+ return {
+ ...artwork,
+ comments: getCommentsForArtwork(artworkId)
+ };
+ }
+ return null;
+};
+
+// 获取所有作品列表
+export const getAllArtworks = (): ArtworkData[] => {
+ return Object.values(mockArtworks);
+};
+
+// 按分类获取作品
+export const getArtworksByCategory = (category: string): ArtworkData[] => {
+ return Object.values(mockArtworks).filter(artwork => artwork.artworkCategory === category);
+};
+
+// 搜索作品
+export const searchArtworks = (keyword: string): ArtworkData[] => {
+ const lowerKeyword = keyword.toLowerCase();
+ return Object.values(mockArtworks).filter(artwork =>
+ artwork.artworkName.toLowerCase().includes(lowerKeyword) ||
+ artwork.artworkDescription.toLowerCase().includes(lowerKeyword) ||
+ artwork.author.toLowerCase().includes(lowerKeyword) ||
+ artwork.artworkCategory.toLowerCase().includes(lowerKeyword)
+ );
+};
\ No newline at end of file
diff --git a/src/feature/work/types.ts b/src/feature/work/types.ts
new file mode 100644
index 0000000..2d7dbd7
--- /dev/null
+++ b/src/feature/work/types.ts
@@ -0,0 +1,283 @@
+import type { UploadFile } from 'antd/es/upload';
+
+// ==================== 基础类型 ====================
+
+export interface User {
+ userId: string;
+ username: string;
+}
+
+export interface HistoryUser extends User {
+ uploadTotal: string;
+}
+
+// ==================== 评论相关 ====================
+
+export interface Comment {
+ id?: string;
+ content: string;
+ author: string;
+ authorId?: string;
+ createdAt?: string;
+ child: Comment[];
+}
+
+// ==================== 版本管理相关 ====================
+
+// 用于表单创建的版本信息
+export interface VersionFormData {
+ version: string;
+ versionDescription: string;
+ seedFile?: UploadFile; // 表单中的文件对象
+}
+
+// 用于展示的版本信息(后端返回)
+export interface Version {
+ version: string;
+ versionDescription: string;
+ seedFile: string; // 文件URL或路径
+ uploadedAt?: string;
+ fileSize?: number;
+}
+
+// ==================== 作品相关 ====================
+
+// 基础信息(表单用)
+export interface BasicInfo {
+ artworkName: string;
+ artworkCategory: ArtworkCategory;
+ artworkDescription: string;
+}
+
+// 封面信息(表单用)
+export interface CoverInfo {
+ coverFile?: UploadFile;
+}
+
+// 表单数据结构
+export interface WorkFormData {
+ basicInfo: BasicInfo;
+ coverInfo: CoverInfo;
+ versions: VersionFormData[];
+}
+
+// 完整的作品数据(后端返回/展示用)
+export interface ArtworkData {
+ artworkId: string;
+ artworkCover: string;
+ author: string;
+ authorId: string;
+ artworkName: string;
+ artworkCategory: ArtworkCategory;
+ artworkDescription: string;
+ versionList: Version[];
+ comments: Comment[];
+ usersSeedingCurrently: User[];
+ usersSeedingHistory: HistoryUser[];
+ createdAt?: string;
+ updatedAt?: string;
+ downloadCount?: number;
+ likeCount?: number;
+}
+
+// ==================== 表单相关 ====================
+
+export interface StepFormProps {
+ data: WorkFormData;
+ onUpdate: (field: keyof WorkFormData, value: BasicInfo | CoverInfo | VersionFormData[]) => void;
+ onNext?: () => void;
+ onPrev?: () => void;
+}
+
+// ==================== 常量定义 ====================
+
+export const ARTWORK_CATEGORIES = [
+ { label: '概念设计', value: '概念设计' },
+ { label: 'UI/UX设计', value: 'UI/UX设计' },
+ { label: '3D建模', value: '3D建模' },
+ { label: '摄影作品', value: '摄影作品' },
+ { label: '音乐制作', value: '音乐制作' },
+ { label: '前端开发', value: '前端开发' },
+ { label: '插画艺术', value: '插画艺术' },
+ { label: '动画制作', value: '动画制作' },
+ { label: '视频剪辑', value: '视频剪辑' },
+ { label: '其他', value: '其他' },
+];
+
+// 提取分类值的联合类型
+export type ArtworkCategory = '概念设计' | 'UI/UX设计' | '3D建模' | '摄影作品' | '音乐制作' | '前端开发' | '插画艺术' | '动画制作' | '视频剪辑' | '其他';
+
+// 分类选项类型(用于 Select 组件)
+export interface ArtworkCategoryOption {
+ label: string;
+ value: ArtworkCategory;
+}
+
+// ==================== 工具函数 ====================
+
+/**
+ * 解析上传大小字符串为MB数值
+ * @param uploadStr 上传大小字符串,如 "1.5GB", "500MB"
+ * @returns 大小值(以MB为单位)
+ */
+export const parseUploadSize = (uploadStr: string): number => {
+ const match = uploadStr.match(/(\d+(?:\.\d+)?)\s*(GB|MB|KB|TB)?/i);
+ if (!match) return 0;
+
+ const size = parseFloat(match[1]);
+ const unit = (match[2] || '').toUpperCase();
+
+ const multipliers: Record<string, number> = {
+ 'TB': 1024 * 1024,
+ 'GB': 1024,
+ 'MB': 1,
+ 'KB': 1 / 1024,
+ '': 1
+ };
+
+ return size * (multipliers[unit] || 1);
+};
+
+/**
+ * 格式化文件大小为可读字符串
+ * @param sizeInMB 文件大小(MB)
+ * @returns 格式化后的字符串
+ */
+export const formatFileSize = (sizeInMB: number): string => {
+ if (sizeInMB >= 1024) {
+ return `${(sizeInMB / 1024).toFixed(1)}GB`;
+ } else if (sizeInMB >= 1) {
+ return `${sizeInMB.toFixed(1)}MB`;
+ } else {
+ return `${(sizeInMB * 1024).toFixed(0)}KB`;
+ }
+};
+
+/**
+ * 格式化日期
+ * @param dateString 日期字符串
+ * @returns 格式化后的日期字符串
+ */
+export const formatDate = (dateString?: string): string => {
+ if (!dateString) return '';
+
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
+
+ if (diffInHours < 1) {
+ return '刚刚';
+ } else if (diffInHours < 24) {
+ return `${Math.floor(diffInHours)}小时前`;
+ } else if (diffInHours < 24 * 7) {
+ return `${Math.floor(diffInHours / 24)}天前`;
+ } else {
+ return date.toLocaleDateString('zh-CN');
+ }
+};
+
+/**
+ * 验证文件大小是否符合要求
+ * @param fileSize 文件大小(字节)
+ * @param maxSizeMB 最大允许大小(MB)
+ * @returns 是否符合要求
+ */
+export const isValidFileSize = (fileSize: number, maxSizeMB: number): boolean => {
+ const fileSizeMB = fileSize / (1024 * 1024);
+ return fileSizeMB <= maxSizeMB;
+};
+
+/**
+ * 验证文件类型是否符合要求
+ * @param fileName 文件名
+ * @param allowedExtensions 允许的扩展名数组
+ * @returns 是否符合要求
+ */
+export const isValidFileType = (fileName: string, allowedExtensions: string[]): boolean => {
+ if (!fileName) return false;
+ const extension = fileName.toLowerCase().split('.').pop();
+ return extension ? allowedExtensions.includes(extension) : false;
+};
+
+/**
+ * 验证图片文件类型
+ * @param fileName 文件名
+ * @returns 是否为有效图片
+ */
+export const isValidImageFile = (fileName: string): boolean => {
+ return isValidFileType(fileName, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
+};
+
+/**
+ * 安全地解析JSON字符串
+ * @param jsonString JSON字符串
+ * @returns 解析结果或null
+ */
+export const safeJsonParse = <T = unknown>(jsonString: string): T | null => {
+ try {
+ return JSON.parse(jsonString) as T;
+ } catch {
+ return null;
+ }
+};
+
+// ==================== 类型守卫 ====================
+
+export const isValidArtworkCategory = (category: string): category is ArtworkCategory => {
+ return ARTWORK_CATEGORIES.some(cat => cat.value === category);
+};
+
+export const isComment = (obj: unknown): obj is Comment => {
+ return typeof obj === 'object' &&
+ obj !== null &&
+ 'content' in obj &&
+ 'author' in obj &&
+ 'child' in obj &&
+ typeof (obj as Record<string, unknown>).content === 'string' &&
+ typeof (obj as Record<string, unknown>).author === 'string' &&
+ Array.isArray((obj as Record<string, unknown>).child);
+};
+
+export const isUser = (obj: unknown): obj is User => {
+ return typeof obj === 'object' &&
+ obj !== null &&
+ 'userId' in obj &&
+ 'username' in obj &&
+ typeof (obj as Record<string, unknown>).userId === 'string' &&
+ typeof (obj as Record<string, unknown>).username === 'string';
+};
+
+export const isHistoryUser = (obj: unknown): obj is HistoryUser => {
+ return isUser(obj) &&
+ 'uploadTotal' in obj &&
+ typeof (obj as Record<string, unknown>).uploadTotal === 'string';
+};
+
+export const isVersion = (obj: unknown): obj is Version => {
+ return typeof obj === 'object' &&
+ obj !== null &&
+ 'version' in obj &&
+ 'versionDescription' in obj &&
+ 'seedFile' in obj &&
+ typeof (obj as Record<string, unknown>).version === 'string' &&
+ typeof (obj as Record<string, unknown>).versionDescription === 'string' &&
+ typeof (obj as Record<string, unknown>).seedFile === 'string';
+};
+
+export const isArtworkData = (obj: unknown): obj is ArtworkData => {
+ if (typeof obj !== 'object' || obj === null) return false;
+
+ const artwork = obj as Record<string, unknown>;
+
+ return typeof artwork.artworkId === 'string' &&
+ typeof artwork.artworkCover === 'string' &&
+ typeof artwork.author === 'string' &&
+ typeof artwork.authorId === 'string' &&
+ typeof artwork.artworkName === 'string' &&
+ typeof artwork.artworkCategory === 'string' &&
+ typeof artwork.artworkDescription === 'string' &&
+ Array.isArray(artwork.versionList) &&
+ Array.isArray(artwork.comments) &&
+ Array.isArray(artwork.usersSeedingCurrently) &&
+ Array.isArray(artwork.usersSeedingHistory);
+};
\ No newline at end of file
diff --git a/src/feature/work/utils.ts b/src/feature/work/utils.ts
new file mode 100644
index 0000000..a10d95f
--- /dev/null
+++ b/src/feature/work/utils.ts
@@ -0,0 +1,155 @@
+import type { RcFile } from 'antd/es/upload';
+
+// 将文件转换为Base64
+export const getBase64 = (file: RcFile): Promise<string> =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = (error) => reject(error);
+ });
+
+// 验证图片文件
+interface ValidationResult {
+ valid: boolean;
+ message?: string;
+}
+
+export const validateImageFile = (file: RcFile): ValidationResult => {
+ const isImage = file.type.startsWith('image/');
+ if (!isImage) {
+ return { valid: false, message: '只能上传图片文件!' };
+ }
+
+ const isLt5M = file.size / 1024 / 1024 < 5;
+ if (!isLt5M) {
+ return { valid: false, message: '图片大小不能超过 5MB!' };
+ }
+
+ return { valid: true };
+};
+
+// 验证种子文件
+export const validateSeedFile = (file: RcFile): ValidationResult => {
+ const isLt100M = file.size / 1024 / 1024 < 100;
+ if (!isLt100M) {
+ return { valid: false, message: '种子文件大小不能超过 100MB!' };
+ }
+
+ return { valid: true };
+};
+
+// 格式化文件大小
+export const formatFileSize = (bytes: number): string => {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+};
+
+// 防抖函数 - 修复版本
+export const debounce = <T extends (...args: unknown[]) => unknown>(
+ func: T,
+ wait: number
+): ((...args: Parameters<T>) => void) => {
+ let timeout: ReturnType<typeof setTimeout>;
+
+ return (...args: Parameters<T>) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => func(...args), wait);
+ };
+};
+
+// 节流函数 - 修复版本
+export const throttle = <T extends (...args: unknown[]) => unknown>(
+ func: T,
+ limit: number
+): ((...args: Parameters<T>) => void) => {
+ let inThrottle: boolean;
+
+ return (...args: Parameters<T>) => {
+ if (!inThrottle) {
+ func(...args);
+ inThrottle = true;
+ setTimeout(() => inThrottle = false, limit);
+ }
+ };
+};
+// 生成唯一ID
+export const generateId = (): string => {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2);
+};
+
+// 安全的JSON解析
+export const safeJsonParse = <T>(str: string, defaultValue: T): T => {
+ try {
+ return JSON.parse(str) as T;
+ } catch {
+ return defaultValue;
+ }
+};
+
+// 检查是否为空值
+export const isEmpty = (value: unknown): boolean => {
+ if (value === null || value === undefined) return true;
+ if (typeof value === 'string') return value.trim() === '';
+ if (Array.isArray(value)) return value.length === 0;
+ if (typeof value === 'object') return Object.keys(value).length === 0;
+ return false;
+};
+
+// URL参数解析
+export const parseUrlParams = (url: string): Record<string, string> => {
+ const params: Record<string, string> = {};
+ const urlObj = new URL(url);
+
+ urlObj.searchParams.forEach((value, key) => {
+ params[key] = value;
+ });
+
+ return params;
+};
+
+// 时间格式化
+export const formatDate = (date: Date | string | number, format: string = 'YYYY-MM-DD HH:mm:ss'): string => {
+ const d = new Date(date);
+
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ const hours = String(d.getHours()).padStart(2, '0');
+ const minutes = String(d.getMinutes()).padStart(2, '0');
+ const seconds = String(d.getSeconds()).padStart(2, '0');
+
+ return format
+ .replace('YYYY', year.toString())
+ .replace('MM', month)
+ .replace('DD', day)
+ .replace('HH', hours)
+ .replace('mm', minutes)
+ .replace('ss', seconds);
+};
+
+// 相对时间格式化(例如:2小时前)
+export const formatRelativeTime = (date: Date | string | number): string => {
+ const now = new Date();
+ const target = new Date(date);
+ const diff = now.getTime() - target.getTime();
+
+ const seconds = Math.floor(diff / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+ const months = Math.floor(days / 30);
+ const years = Math.floor(months / 12);
+
+ if (years > 0) return `${years}年前`;
+ if (months > 0) return `${months}个月前`;
+ if (days > 0) return `${days}天前`;
+ if (hours > 0) return `${hours}小时前`;
+ if (minutes > 0) return `${minutes}分钟前`;
+ return '刚刚';
+};
\ No newline at end of file
diff --git a/src/feature/work/workSlice.ts b/src/feature/work/workSlice.ts
new file mode 100644
index 0000000..6b2c12c
--- /dev/null
+++ b/src/feature/work/workSlice.ts
@@ -0,0 +1,469 @@
+import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
+import type { ArtworkData, Comment } from './types';
+import type { RootState } from '../../store/store';
+import { getArtworkById, getCommentsForArtwork } from './mockData';
+
+// ==================== 类型定义 ====================
+interface WorkState {
+ currentArtwork: ArtworkData | null;
+ loading: {
+ artwork: boolean;
+ comments: boolean;
+ addComment: boolean;
+ updateArtwork: boolean;
+ deleteComment: boolean;
+ };
+ error: {
+ artwork: string | null;
+ comments: string | null;
+ addComment: string | null;
+ updateArtwork: string | null;
+ deleteComment: string | null;
+ };
+ comments: {
+ list: Comment[];
+ total: number;
+ current: number;
+ pageSize: number;
+ };
+}
+
+interface FetchCommentsParams {
+ workId: string;
+ page: number;
+ pageSize: number;
+}
+
+interface AddCommentParams {
+ workId: string;
+ content: string;
+ parentId?: string;
+}
+
+interface UpdateArtworkParams {
+ workId: string;
+ updates: Partial<ArtworkData>;
+}
+
+interface DeleteCommentParams {
+ workId: string;
+ commentId: string;
+}
+
+interface SetCommentsPageParams {
+ current: number;
+ pageSize: number;
+}
+
+// ==================== 初始状态 ====================
+const initialState: WorkState = {
+ currentArtwork: null,
+ loading: {
+ artwork: false,
+ comments: false,
+ addComment: false,
+ updateArtwork: false,
+ deleteComment: false,
+ },
+ error: {
+ artwork: null,
+ comments: null,
+ addComment: null,
+ updateArtwork: null,
+ deleteComment: null,
+ },
+ comments: {
+ list: [],
+ total: 0,
+ current: 1,
+ pageSize: 5,
+ },
+};
+
+// ==================== Mock 工具函数 ====================
+
+// 模拟网络延迟
+const mockDelay = (ms: number = 800): Promise<void> =>
+ new Promise(resolve => setTimeout(resolve, ms));
+
+// 生成新评论ID
+const generateCommentId = (): string => {
+ return `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+};
+
+// 生成新评论数据
+const createNewComment = (content: string): Comment => {
+ return {
+ id: generateCommentId(),
+ content,
+ author: '当前用户', // 实际应用中从用户状态获取
+ authorId: 'current_user_id',
+ createdAt: new Date().toLocaleString('zh-CN'),
+ child: [],
+ };
+};
+
+// 递归删除评论
+const removeCommentById = (comments: Comment[], targetId: string): Comment[] => {
+ return comments.filter(comment => {
+ if (comment.id === targetId) {
+ return false;
+ }
+ if (comment.child.length > 0) {
+ comment.child = removeCommentById(comment.child, targetId);
+ }
+ return true;
+ });
+};
+
+// 递归添加回复评论
+const addReplyToComment = (comments: Comment[], parentId: string, newComment: Comment): Comment[] => {
+ return comments.map(comment => {
+ if (comment.id === parentId) {
+ return {
+ ...comment,
+ child: [...comment.child, newComment]
+ };
+ }
+ if (comment.child.length > 0) {
+ return {
+ ...comment,
+ child: addReplyToComment(comment.child, parentId, newComment)
+ };
+ }
+ return comment;
+ });
+};
+
+// 分页处理评论
+const paginateComments = (comments: Comment[], page: number, pageSize: number): Comment[] => {
+ const startIndex = (page - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+ return comments.slice(startIndex, endIndex);
+};
+
+// ==================== 异步 Actions ====================
+
+// 获取作品详情
+export const fetchArtworkDetail = createAsyncThunk<
+ ArtworkData,
+ string,
+ { rejectValue: string }
+>(
+ 'work/fetchArtworkDetail',
+ async (workId: string, { rejectWithValue }) => {
+ try {
+ await mockDelay(600); // 模拟网络延迟
+
+ const artwork = getArtworkById(workId);
+
+ if (!artwork) {
+ throw new Error(`作品 ${workId} 不存在`);
+ }
+
+ return artwork;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '获取作品详情失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// 获取评论列表
+export const fetchComments = createAsyncThunk<
+ { comments: Comment[]; total: number },
+ FetchCommentsParams,
+ { rejectValue: string }
+>(
+ 'work/fetchComments',
+ async ({ workId, page, pageSize }, { rejectWithValue }) => {
+ try {
+ await mockDelay(400); // 模拟网络延迟
+
+ const allComments = getCommentsForArtwork(workId);
+ const paginatedComments = paginateComments(allComments, page, pageSize);
+
+ return {
+ comments: paginatedComments,
+ total: allComments.length
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '获取评论失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// 添加评论
+export const addComment = createAsyncThunk<
+ Comment,
+ AddCommentParams,
+ { rejectValue: string }
+>(
+ 'work/addComment',
+ async ({ workId, content }, { rejectWithValue }) => {
+ try {
+ await mockDelay(500); // 模拟网络延迟
+
+ // 验证作品是否存在
+ const artwork = getArtworkById(workId);
+ if (!artwork) {
+ throw new Error('作品不存在');
+ }
+
+ // 创建新评论
+ const newComment = createNewComment(content);
+
+ // 模拟服务器返回完整的评论数据
+ return newComment;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '添加评论失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// 更新作品信息
+export const updateArtwork = createAsyncThunk<
+ ArtworkData,
+ UpdateArtworkParams,
+ { rejectValue: string }
+>(
+ 'work/updateArtwork',
+ async ({ workId, updates }, { rejectWithValue }) => {
+ try {
+ await mockDelay(1000); // 模拟网络延迟
+
+ const currentArtwork = getArtworkById(workId);
+ if (!currentArtwork) {
+ throw new Error('作品不存在');
+ }
+
+ // 模拟文件上传处理
+ const processedUpdates = { ...updates };
+
+ // 如果包含 blob URL,模拟转换为正式URL
+ if (updates.artworkCover && updates.artworkCover.startsWith('blob:')) {
+ // 模拟上传成功,生成新的图片URL
+ processedUpdates.artworkCover = `https://picsum.photos/300/400?random=${Date.now()}`;
+ }
+
+ // 处理版本文件上传
+ if (updates.versionList) {
+ processedUpdates.versionList = updates.versionList.map(version => ({
+ ...version,
+ seedFile: version.seedFile.startsWith?.('blob:')
+ ? `magnet:?xt=urn:btih:updated_${Date.now()}&dn=${version.version}.zip`
+ : version.seedFile
+ }));
+ }
+
+ // 合并更新后的数据
+ const updatedArtwork: ArtworkData = {
+ ...currentArtwork,
+ ...processedUpdates,
+ updatedAt: new Date().toISOString(),
+ };
+
+ return updatedArtwork;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '更新作品失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// 删除评论
+export const deleteComment = createAsyncThunk<
+ string,
+ DeleteCommentParams,
+ { rejectValue: string }
+>(
+ 'work/deleteComment',
+ async ({ workId, commentId }, { rejectWithValue }) => {
+ try {
+ await mockDelay(300); // 模拟网络延迟
+
+ // 验证作品是否存在
+ const artwork = getArtworkById(workId);
+ if (!artwork) {
+ throw new Error('作品不存在');
+ }
+
+ // 模拟删除成功
+ return commentId;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '删除评论失败';
+ return rejectWithValue(message);
+ }
+ }
+);
+
+// ==================== Slice 定义 ====================
+const workSlice = createSlice({
+ name: 'work',
+ initialState,
+ reducers: {
+ // 清除当前作品
+ clearCurrentArtwork: (state) => {
+ state.currentArtwork = null;
+ state.comments.list = [];
+ state.comments.total = 0;
+ state.comments.current = 1;
+ // 清除所有错误状态
+ Object.keys(state.error).forEach(key => {
+ state.error[key as keyof typeof state.error] = null;
+ });
+ },
+
+ // 设置评论分页
+ setCommentsPage: (state, action: PayloadAction<SetCommentsPageParams>) => {
+ state.comments.current = action.payload.current;
+ state.comments.pageSize = action.payload.pageSize;
+ },
+
+ // 清除特定错误
+ clearError: (state, action: PayloadAction<keyof WorkState['error']>) => {
+ state.error[action.payload] = null;
+ },
+
+ // 清除所有错误
+ clearAllErrors: (state) => {
+ Object.keys(state.error).forEach(key => {
+ state.error[key as keyof typeof state.error] = null;
+ });
+ },
+ },
+ extraReducers: (builder) => {
+ // 获取作品详情
+ builder
+ .addCase(fetchArtworkDetail.pending, (state) => {
+ state.loading.artwork = true;
+ state.error.artwork = null;
+ })
+ .addCase(fetchArtworkDetail.fulfilled, (state, action) => {
+ state.loading.artwork = false;
+ state.currentArtwork = action.payload;
+ state.error.artwork = null;
+ })
+ .addCase(fetchArtworkDetail.rejected, (state, action) => {
+ state.loading.artwork = false;
+ state.error.artwork = action.payload || '获取作品详情失败';
+ });
+
+ // 获取评论列表
+ builder
+ .addCase(fetchComments.pending, (state) => {
+ state.loading.comments = true;
+ state.error.comments = null;
+ })
+ .addCase(fetchComments.fulfilled, (state, action) => {
+ state.loading.comments = false;
+ state.comments.list = action.payload.comments;
+ state.comments.total = action.payload.total;
+ state.error.comments = null;
+ })
+ .addCase(fetchComments.rejected, (state, action) => {
+ state.loading.comments = false;
+ state.error.comments = action.payload || '获取评论失败';
+ });
+
+ // 添加评论
+ builder
+ .addCase(addComment.pending, (state) => {
+ state.loading.addComment = true;
+ state.error.addComment = null;
+ })
+ .addCase(addComment.fulfilled, (state, action) => {
+ state.loading.addComment = false;
+
+ const newComment = action.payload;
+ const { parentId } = action.meta.arg;
+
+ if (parentId) {
+ // 添加回复评论
+ state.comments.list = addReplyToComment(state.comments.list, parentId, newComment);
+ } else {
+ // 添加顶级评论
+ state.comments.list.unshift(newComment);
+ state.comments.total += 1;
+ }
+
+ state.error.addComment = null;
+ })
+ .addCase(addComment.rejected, (state, action) => {
+ state.loading.addComment = false;
+ state.error.addComment = action.payload || '添加评论失败';
+ });
+
+ // 更新作品信息
+ builder
+ .addCase(updateArtwork.pending, (state) => {
+ state.loading.updateArtwork = true;
+ state.error.updateArtwork = null;
+ })
+ .addCase(updateArtwork.fulfilled, (state, action) => {
+ state.loading.updateArtwork = false;
+ state.currentArtwork = action.payload;
+ state.error.updateArtwork = null;
+ })
+ .addCase(updateArtwork.rejected, (state, action) => {
+ state.loading.updateArtwork = false;
+ state.error.updateArtwork = action.payload || '更新作品失败';
+ });
+
+ // 删除评论
+ builder
+ .addCase(deleteComment.pending, (state) => {
+ state.loading.deleteComment = true;
+ state.error.deleteComment = null;
+ })
+ .addCase(deleteComment.fulfilled, (state, action) => {
+ state.loading.deleteComment = false;
+
+ // 从评论列表中移除已删除的评论
+ state.comments.list = removeCommentById(state.comments.list, action.payload);
+ state.comments.total = Math.max(0, state.comments.total - 1);
+ state.error.deleteComment = null;
+ })
+ .addCase(deleteComment.rejected, (state, action) => {
+ state.loading.deleteComment = false;
+ state.error.deleteComment = action.payload || '删除评论失败';
+ });
+ },
+});
+
+// ==================== Actions 导出 ====================
+export const {
+ clearCurrentArtwork,
+ setCommentsPage,
+ clearError,
+ clearAllErrors
+} = workSlice.actions;
+
+// ==================== Selectors 导出 ====================
+export const selectCurrentArtwork = (state: RootState): ArtworkData | null =>
+ state.work.currentArtwork;
+
+export const selectWorkLoading = (state: RootState): WorkState['loading'] =>
+ state.work.loading;
+
+export const selectWorkError = (state: RootState): WorkState['error'] =>
+ state.work.error;
+
+export const selectComments = (state: RootState): WorkState['comments'] =>
+ state.work.comments;
+
+export const selectIsAuthor = (state: RootState): boolean => {
+ const currentUser = state.user;
+ const currentArtwork = state.work.currentArtwork;
+
+ return Boolean(
+ currentUser?.userid &&
+ currentArtwork?.authorId &&
+ String(currentUser.userid) === String(currentArtwork.authorId)
+ );
+};
+
+// ==================== Reducer 导出 ====================
+export default workSlice.reducer;
\ No newline at end of file