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, Grid, Modal } from 'antd'; import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, UploadOutlined, EditOutlined, StarFilled, CloseOutlined, LikeOutlined, LikeFilled } from '@ant-design/icons'; import { getTopicDetail, createReply, uploadMedia, getStarUsers, likeTopic, likeReply } 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'; import styles from './ForumDetail.module.less'; import CodeBlock from '../components/CodeBlock'; const { Title, Text } = Typography; const { TextArea } = Input; const { useBreakpoint } = Grid; const ForumDetail = () => { const { id } = useParams(); const navigate = useNavigate(); const { user, login } = useAuth(); const screens = useBreakpoint(); const isMobile = !screens.md; 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([]); // Star Users State const [starUsers, setStarUsers] = useState([]); const fetchTopic = async () => { try { const res = await getTopicDetail(id); setTopic(res.data); } catch (error) { console.error(error); message.error('加载失败'); } finally { setLoading(false); } }; const fetchStarUsers = async () => { try { const res = await getStarUsers(); setStarUsers(res.data || []); } catch (error) { console.error('Fetch star users failed', error); } }; const hasFetched = React.useRef(false); useEffect(() => { if (!hasFetched.current) { fetchTopic(); fetchStarUsers(); hasFetched.current = true; } }, [id]); const handleReplyToUser = (nickname) => { const mentionText = `@${nickname} `; setReplyContent(prev => prev + mentionText); // Focus logic if needed, but simple append works message.info(`已添加 @${nickname}`); }; // Expert Info Modal const [expertModalVisible, setExpertModalVisible] = useState(false); const [selectedExpert, setSelectedExpert] = useState(null); const showUserTitle = (author) => { if (author?.is_star) { setSelectedExpert(author); setExpertModalVisible(true); } else if (author?.title) { message.info(author.title); } }; const handleLikeTopic = async () => { if (!user) { setLoginModalVisible(true); return; } try { const res = await likeTopic(topic.id); setTopic(prev => ({ ...prev, is_liked: res.data.liked, like_count: res.data.count })); } catch (error) { message.error('操作失败'); } }; const handleLikeReply = async (replyId) => { if (!user) { setLoginModalVisible(true); return; } try { const res = await likeReply(replyId); setTopic(prev => ({ ...prev, replies: prev.replies.map(r => { if (r.id === replyId) { return { ...r, is_liked: res.data.liked, like_count: res.data.count }; } return r; }) })); } catch (error) { message.error('操作失败'); } }; 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\n` : `\n![${file.name}](${url})\n`; setReplyContent(prev => prev + insertText); message.success('上传成功'); } catch (error) { console.error(error); message.error('上传失败'); } finally { setReplyUploading(false); } return false; }; if (loading) return
Loading...
; if (!topic) return
Topic not found
; const markdownComponents = { // eslint-disable-next-line no-unused-vars code({node, inline, className, children, ...props}) { const match = /language-(\w+)/.exec(className || '') return !inline && match ? ( {String(children).replace(/\n$/, '')} ) : ( {children} ) }, // eslint-disable-next-line no-unused-vars img({node, ...props}) { return ( ); } }; return (
{user && String(topic.author) === String(user.id) && ( )}
{/* Topic Content */}
{topic.is_pinned && 置顶} {topic.product_info && {topic.product_info.name}} {topic.title} } size={isMobile ? 'small' : 'default'} style={{ cursor: 'pointer' }} onClick={() => showUserTitle(topic.author_info)} /> {topic.author_info?.nickname} {topic.is_verified_owner && ( )} {new Date(topic.created_at).toLocaleString()} {topic.view_count} 阅读 {topic.is_liked ? : } {topic.like_count || 0} 点赞
{topic.content}
{(() => { if (topic.media && topic.media.length > 0) { return topic.media.filter(m => m.media_type === 'video').map((media) => (
)); } return null; })()}
{/* Replies List */}
{topic.replies?.length || 0} 条回复 {topic.replies?.map((reply, index) => (
} size={isMobile ? 'small' : 'default'} style={{ cursor: 'pointer' }} onClick={() => showUserTitle(reply.author_info)} />
{reply.author_info?.nickname} {reply.is_pinned && 置顶} {new Date(reply.created_at).toLocaleString()} handleLikeReply(reply.id)} style={{ cursor: 'pointer', background: reply.is_liked ? 'rgba(0, 185, 107, 0.15)' : 'transparent', padding: '2px 8px', borderRadius: 4 }}> {reply.is_liked ? : } {reply.like_count || 0} #{index + 1}
{reply.content}
))}
{/* Reply Form */} 发表回复 {user ? ( <>