This commit is contained in:
@@ -1,372 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
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 } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const ForumDetail = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [topic, setTopic] = useState(null);
|
||||
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 {
|
||||
const res = await getTopicDetail(id);
|
||||
setTopic(res.data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFetched = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasFetched.current) {
|
||||
fetchTopic();
|
||||
hasFetched.current = true;
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleSubmitReply = async () => {
|
||||
if (!user) {
|
||||
setLoginModalVisible(true);
|
||||
return;
|
||||
}
|
||||
if (!replyContent.trim()) {
|
||||
message.warning('请输入回复内容');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createReply({
|
||||
topic: id,
|
||||
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);
|
||||
message.error('回复失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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: '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>
|
||||
|
||||
{/* Debug Info: Remove in production */}
|
||||
{/* <div style={{ color: 'red', fontSize: 10 }}>
|
||||
User ID: {user?.id} ({typeof user?.id})<br/>
|
||||
Topic Author: {topic.author} ({typeof topic.author})<br/>
|
||||
Match: {String(topic.author) === String(user?.id) ? 'Yes' : 'No'}
|
||||
</div> */}
|
||||
|
||||
{user && String(topic.author) === String(user.id) && (
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setEditModalVisible(true)}
|
||||
>
|
||||
编辑帖子
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Topic Content */}
|
||||
<Card
|
||||
style={{
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
marginBottom: 30
|
||||
}}
|
||||
styles={{ body: { padding: '30px' } }}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{topic.is_pinned && <Tag color="red" style={{ marginRight: 10 }}>置顶</Tag>}
|
||||
{topic.product_info && <Tag color="blue">{topic.product_info.name}</Tag>}
|
||||
<Title level={2} style={{ color: '#fff', margin: '10px 0' }}>{topic.title}</Title>
|
||||
|
||||
<Space size="large" style={{ color: '#888', marginTop: 10 }}>
|
||||
<Space>
|
||||
<Avatar src={topic.author_info?.avatar_url} icon={<UserOutlined />} />
|
||||
<span style={{ color: '#ccc' }}>{topic.author_info?.nickname}</span>
|
||||
{topic.is_verified_owner && (
|
||||
<Tooltip title="已验证购买过相关产品">
|
||||
<CheckCircleFilled style={{ color: '#00b96b' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<span>{new Date(topic.created_at).toLocaleString()}</span>
|
||||
</Space>
|
||||
<Space>
|
||||
<EyeOutlined />
|
||||
<span>{topic.view_count} 阅读</span>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider style={{ borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||
|
||||
<div style={{
|
||||
color: '#ddd',
|
||||
fontSize: 16,
|
||||
lineHeight: 1.8,
|
||||
minHeight: 200,
|
||||
}} className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{topic.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
if (topic.media && topic.media.length > 0) {
|
||||
return topic.media.filter(m => m.media_type === 'video').map((media) => (
|
||||
<div key={`media-${media.id}`} style={{ marginTop: 12 }}>
|
||||
<video src={media.url} controls style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }} />
|
||||
</div>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Card>
|
||||
|
||||
{/* Replies List */}
|
||||
<div style={{ marginBottom: 30 }}>
|
||||
<Title level={4} style={{ color: '#fff', marginBottom: 20 }}>
|
||||
{topic.replies?.length || 0} 条回复
|
||||
</Title>
|
||||
|
||||
{topic.replies?.map((reply, index) => (
|
||||
<Card
|
||||
key={reply.id}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: 'none',
|
||||
marginBottom: 16,
|
||||
borderRadius: 8
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Avatar src={reply.author_info?.avatar_url} icon={<UserOutlined />} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Space>
|
||||
<Text style={{ color: '#aaa', fontWeight: 'bold' }}>{reply.author_info?.nickname}</Text>
|
||||
<Text style={{ color: '#666', fontSize: 12 }}>{new Date(reply.created_at).toLocaleString()}</Text>
|
||||
</Space>
|
||||
<Text style={{ color: '#444' }}>#{index + 1}</Text>
|
||||
</div>
|
||||
<div style={{ color: '#eee' }}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{reply.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
<Card
|
||||
style={{
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<Title level={5} style={{ color: '#fff', marginBottom: 16 }}>发表回复</Title>
|
||||
{user ? (
|
||||
<>
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={replyContent}
|
||||
onChange={e => setReplyContent(e.target.value)}
|
||||
placeholder="友善回复,分享你的见解... (支持 Markdown)"
|
||||
style={{ marginBottom: 16, background: '#111', border: '1px solid #333', color: '#fff' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Upload
|
||||
beforeUpload={handleReplyUpload}
|
||||
showUploadList={false}
|
||||
accept="image/*,video/*"
|
||||
>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
loading={replyUploading}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.2)'
|
||||
}}
|
||||
>
|
||||
插入图片/视频
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
<Button type="primary" onClick={handleSubmitReply} loading={submitting}>
|
||||
提交回复
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
<Text style={{ color: '#888' }}>登录后参与讨论</Text>
|
||||
<br/>
|
||||
<Button type="primary" style={{ marginTop: 10 }} onClick={() => setLoginModalVisible(true)}>
|
||||
立即登录
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<LoginModal
|
||||
visible={loginModalVisible}
|
||||
onClose={() => setLoginModalVisible(false)}
|
||||
onLoginSuccess={() => {}}
|
||||
/>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<CreateTopicModal
|
||||
visible={editModalVisible}
|
||||
onClose={() => {
|
||||
setEditModalVisible(false);
|
||||
// Workaround for scroll issue: Force reload page on close
|
||||
window.location.reload();
|
||||
}}
|
||||
onSuccess={() => {
|
||||
fetchTopic();
|
||||
}}
|
||||
initialValues={topic}
|
||||
isEditMode={true}
|
||||
topicId={topic?.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumDetail;
|
||||
Reference in New Issue
Block a user