forum
This commit is contained in:
@@ -60,6 +60,7 @@ export const uploadUserAvatar = (data) => {
|
||||
export const getTopics = (params) => api.get('/community/topics/', { params });
|
||||
export const getTopicDetail = (id) => api.get(`/community/topics/${id}/`);
|
||||
export const createTopic = (data) => api.post('/community/topics/', data);
|
||||
export const updateTopic = (id, data) => api.patch(`/community/topics/${id}/`, data);
|
||||
export const getReplies = (params) => api.get('/community/replies/', { params });
|
||||
export const createReply = (data) => api.post('/community/replies/', data);
|
||||
export const uploadMedia = (data) => {
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Button, message, Upload, Select, Divider, Radio, Tabs, Alert } from 'antd';
|
||||
import { InboxOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { createTopic, uploadMedia, getMyPaidItems } from '../api';
|
||||
import { Modal, Form, Input, Button, message, Upload, Select } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { createTopic, updateTopic, uploadMedia, getMyPaidItems } from '../api';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkMath from 'remark-math';
|
||||
import 'katex/dist/katex.css';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
const CreateTopicModal = ({ visible, onClose, onSuccess, initialValues, isEditMode, topicId }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [paidItems, setPaidItems] = useState({ configs: [], courses: [], services: [] });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [mediaIds, setMediaIds] = useState([]);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [mediaList, setMediaList] = useState([]); // Store uploaded media details for preview
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchPaidItems();
|
||||
setMediaIds([]); // Reset media IDs
|
||||
setMediaList([]); // Reset media list
|
||||
if (isEditMode && initialValues) {
|
||||
// Edit Mode: Populate form with initial values
|
||||
form.setFieldsValue({
|
||||
title: initialValues.title,
|
||||
category: initialValues.category,
|
||||
});
|
||||
setContent(initialValues.content);
|
||||
form.setFieldValue('content', initialValues.content);
|
||||
|
||||
// Handle related item
|
||||
let relatedVal = null;
|
||||
if (initialValues.related_product) relatedVal = `config_${initialValues.related_product.id || initialValues.related_product}`;
|
||||
else if (initialValues.related_course) relatedVal = `course_${initialValues.related_course.id || initialValues.related_course}`;
|
||||
else if (initialValues.related_service) relatedVal = `service_${initialValues.related_service.id || initialValues.related_service}`;
|
||||
|
||||
if (relatedVal) form.setFieldValue('related_item', relatedVal);
|
||||
|
||||
// Note: We start with empty *new* media IDs.
|
||||
// Existing media is embedded in content or stored in DB, we don't need to re-upload or track them here unless we want to delete them (which is complex).
|
||||
// For now, we just allow adding NEW media.
|
||||
setMediaIds([]);
|
||||
setMediaList([]);
|
||||
} else {
|
||||
// Create Mode: Reset form
|
||||
setMediaIds([]);
|
||||
setMediaList([]);
|
||||
setContent("");
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ content: "", category: 'discussion' });
|
||||
}
|
||||
}
|
||||
}, [visible]);
|
||||
}, [visible, isEditMode, initialValues, form]);
|
||||
|
||||
const fetchPaidItems = async () => {
|
||||
try {
|
||||
@@ -77,14 +109,14 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
}]);
|
||||
|
||||
// 插入到编辑器
|
||||
const currentContent = form.getFieldValue('content') || '';
|
||||
const insertText = file.type.startsWith('video')
|
||||
? `\n<video src="${url}" controls width="100%"></video>\n`
|
||||
: `\n\n`;
|
||||
|
||||
form.setFieldsValue({
|
||||
content: currentContent + insertText
|
||||
});
|
||||
const newContent = content + insertText;
|
||||
setContent(newContent);
|
||||
form.setFieldsValue({ content: newContent });
|
||||
|
||||
message.success('上传成功');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -100,7 +132,8 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
try {
|
||||
// 处理关联项目 ID (select value format: "type_id")
|
||||
const relatedValue = values.related_item;
|
||||
const payload = { ...values, media_ids: mediaIds };
|
||||
// Use content state instead of form value to ensure consistency
|
||||
const payload = { ...values, content: content, media_ids: mediaIds };
|
||||
delete payload.related_item;
|
||||
|
||||
if (relatedValue) {
|
||||
@@ -108,16 +141,27 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
if (type === 'config') payload.related_product = id;
|
||||
if (type === 'course') payload.related_course = id;
|
||||
if (type === 'service') payload.related_service = id;
|
||||
} else {
|
||||
// If cleared, set to null
|
||||
payload.related_product = null;
|
||||
payload.related_course = null;
|
||||
payload.related_service = null;
|
||||
}
|
||||
|
||||
await createTopic(payload);
|
||||
message.success('发布成功');
|
||||
if (isEditMode && topicId) {
|
||||
await updateTopic(topicId, payload);
|
||||
message.success('修改成功');
|
||||
} else {
|
||||
await createTopic(payload);
|
||||
message.success('发布成功');
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
if (onSuccess) onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('发布失败: ' + (error.response?.data?.detail || '网络错误'));
|
||||
message.error((isEditMode ? '修改' : '发布') + '失败: ' + (error.response?.data?.detail || '网络错误'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -125,12 +169,12 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="发布新帖"
|
||||
title={isEditMode ? "编辑帖子" : "发布新帖"}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={800}
|
||||
width={1000}
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
<Form
|
||||
@@ -189,47 +233,33 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="内容 (支持 Markdown)"
|
||||
label="内容 (支持 Markdown 与 LaTeX 公式)"
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
>
|
||||
<div>
|
||||
<Upload
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
accept="image/*,video/*"
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="small" style={{ marginBottom: 8 }}>
|
||||
插入图片/视频
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
{/* Media Preview Area */}
|
||||
{mediaList.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{mediaList.map((item, index) => (
|
||||
<div key={index} style={{ position: 'relative', width: 80, height: 80, border: '1px solid #ddd', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{item.type === 'video' ? (
|
||||
<video src={item.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<img src={item.url} alt="preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div data-color-mode="light">
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<Upload
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
accept="image/*,video/*"
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||||
插入图片/视频
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
rows={12}
|
||||
placeholder="请详细描述您的问题...
|
||||
支持 Markdown 语法:
|
||||
**加粗**
|
||||
# 标题
|
||||
- 列表
|
||||
[链接](url)
|
||||
"
|
||||
showCount
|
||||
maxLength={10000}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={(val) => {
|
||||
setContent(val);
|
||||
form.setFieldsValue({ content: val });
|
||||
}}
|
||||
height={400}
|
||||
previewOptions={{
|
||||
rehypePlugins: [[rehypeKatex]],
|
||||
remarkPlugins: [[remarkMath]],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
@@ -238,7 +268,7 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading} size="large">
|
||||
立即发布
|
||||
{isEditMode ? "保存修改" : "立即发布"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
@@ -247,4 +277,4 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTopicModal;
|
||||
export default CreateTopicModal;
|
||||
@@ -1,12 +1,21 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Typography, Card, Avatar, Tag, Space, Button, Divider, Input, message, Breadcrumb, Tooltip } from 'antd';
|
||||
import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, LikeOutlined } from '@ant-design/icons';
|
||||
import { getTopicDetail, createReply } from '../api';
|
||||
import { Typography, Card, Avatar, Tag, Space, Button, Divider, Input, message, Upload, Tooltip } from 'antd';
|
||||
import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, UploadOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { getTopicDetail, createReply, uploadMedia } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import LoginModal from '../components/LoginModal';
|
||||
import CreateTopicModal from '../components/CreateTopicModal';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const ForumDetail = () => {
|
||||
@@ -19,6 +28,13 @@ const ForumDetail = () => {
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loginModalVisible, setLoginModalVisible] = useState(false);
|
||||
|
||||
// Edit Topic State
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
|
||||
// Reply Image State
|
||||
const [replyUploading, setReplyUploading] = useState(false);
|
||||
const [replyMediaIds, setReplyMediaIds] = useState([]);
|
||||
|
||||
const fetchTopic = async () => {
|
||||
try {
|
||||
@@ -54,10 +70,12 @@ const ForumDetail = () => {
|
||||
try {
|
||||
await createReply({
|
||||
topic: id,
|
||||
content: replyContent
|
||||
content: replyContent,
|
||||
media_ids: replyMediaIds // Send uploaded media IDs
|
||||
});
|
||||
message.success('回复成功');
|
||||
setReplyContent('');
|
||||
setReplyMediaIds([]); // Reset media IDs
|
||||
fetchTopic(); // Refresh to show new reply
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -67,19 +85,99 @@ const ForumDetail = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplyUpload = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('media_type', file.type.startsWith('video') ? 'video' : 'image');
|
||||
|
||||
setReplyUploading(true);
|
||||
try {
|
||||
const res = await uploadMedia(formData);
|
||||
if (res.data.id) {
|
||||
setReplyMediaIds(prev => [...prev, res.data.id]);
|
||||
}
|
||||
|
||||
let url = res.data.file;
|
||||
if (url) url = url.replace(/\\/g, '/');
|
||||
if (url && !url.startsWith('http')) {
|
||||
const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
const host = baseURL.replace(/\/api\/?$/, '');
|
||||
if (!url.startsWith('/')) url = '/' + url;
|
||||
url = `${host}${url}`;
|
||||
}
|
||||
url = url.replace(/([^:]\/)\/+/g, '$1');
|
||||
|
||||
const insertText = file.type.startsWith('video')
|
||||
? `\n<video src="${url}" controls width="100%"></video>\n`
|
||||
: `\n\n`;
|
||||
|
||||
setReplyContent(prev => prev + insertText);
|
||||
message.success('上传成功');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setReplyUploading(false);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: 100, textAlign: 'center', color: '#fff' }}>Loading...</div>;
|
||||
if (!topic) return <div style={{ padding: 100, textAlign: 'center', color: '#fff' }}>Topic not found</div>;
|
||||
|
||||
const markdownComponents = {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
code({node, inline, className, children, ...props}) {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
img({node, ...props}) {
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%', margin: '10px 0' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px 20px', minHeight: '100vh', maxWidth: 1000, margin: '0 auto' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
style={{ color: '#fff', marginBottom: 20 }}
|
||||
onClick={() => navigate('/forum')}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
<div style={{ padding: '80px 20px 40px', minHeight: '100vh', maxWidth: 1000, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
style={{ color: '#fff' }}
|
||||
onClick={() => navigate('/forum')}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
|
||||
{user && topic.author === user.id && (
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setEditModalVisible(true)}
|
||||
>
|
||||
编辑帖子
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Topic Content */}
|
||||
<Card
|
||||
@@ -124,45 +222,24 @@ const ForumDetail = () => {
|
||||
fontSize: 16,
|
||||
lineHeight: 1.8,
|
||||
minHeight: 200,
|
||||
whiteSpace: 'pre-wrap' // Preserve formatting
|
||||
}}>
|
||||
{topic.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]')}
|
||||
}} className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{topic.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const regexMatches = topic.content.match(/!\[.*?\]\((.*?)\)/g);
|
||||
const regexImages = regexMatches ? regexMatches.map(match => match.match(/!\[.*?\]\((.*?)\)/)[1]) : [];
|
||||
|
||||
// 优先使用 Markdown 中解析出的图片(保持顺序)
|
||||
if (regexImages.length > 0) {
|
||||
return regexImages.map((url, index) => (
|
||||
<div key={`regex-${index}`} style={{ marginTop: 12 }}>
|
||||
<img
|
||||
src={url}
|
||||
alt="content"
|
||||
style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
// 兜底:如果 Markdown 解析失败或未插入但已上传,显示关联的媒体资源
|
||||
if (topic.media && topic.media.length > 0) {
|
||||
return topic.media.map((media) => (
|
||||
return topic.media.filter(m => m.media_type === 'video').map((media) => (
|
||||
<div key={`media-${media.id}`} style={{ marginTop: 12 }}>
|
||||
{media.media_type === 'video' ? (
|
||||
<video src={media.url} controls style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }} />
|
||||
) : (
|
||||
<img
|
||||
src={media.url}
|
||||
alt="content"
|
||||
style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }}
|
||||
/>
|
||||
)}
|
||||
<video src={media.url} controls style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }} />
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</Card>
|
||||
@@ -193,7 +270,15 @@ const ForumDetail = () => {
|
||||
</Space>
|
||||
<Text style={{ color: '#444' }}>#{index + 1}</Text>
|
||||
</div>
|
||||
<div style={{ color: '#eee', whiteSpace: 'pre-wrap' }}>{reply.content}</div>
|
||||
<div style={{ color: '#eee' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{reply.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -214,10 +299,20 @@ const ForumDetail = () => {
|
||||
rows={4}
|
||||
value={replyContent}
|
||||
onChange={e => setReplyContent(e.target.value)}
|
||||
placeholder="友善回复,分享你的见解..."
|
||||
placeholder="友善回复,分享你的见解... (支持 Markdown)"
|
||||
style={{ marginBottom: 16, background: '#111', border: '1px solid #333', color: '#fff' }}
|
||||
/>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Upload
|
||||
beforeUpload={handleReplyUpload}
|
||||
showUploadList={false}
|
||||
accept="image/*,video/*"
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={replyUploading} size="small" ghost>
|
||||
插入图片/视频
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
<Button type="primary" onClick={handleSubmitReply} loading={submitting}>
|
||||
提交回复
|
||||
</Button>
|
||||
@@ -239,8 +334,22 @@ const ForumDetail = () => {
|
||||
onClose={() => setLoginModalVisible(false)}
|
||||
onLoginSuccess={() => {}}
|
||||
/>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<CreateTopicModal
|
||||
visible={editModalVisible}
|
||||
onClose={() => setEditModalVisible(false)}
|
||||
onSuccess={() => {
|
||||
fetchTopic();
|
||||
// setEditModalVisible(false) is called in modal's submit handler wrapper?
|
||||
// CreateTopicModal calls onSuccess then onClose. So we just need to refresh here.
|
||||
}}
|
||||
initialValues={topic}
|
||||
isEditMode={true}
|
||||
topicId={topic?.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumDetail;
|
||||
export default ForumDetail;
|
||||
Reference in New Issue
Block a user