538 lines
21 KiB
JavaScript
538 lines
21 KiB
JavaScript
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<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 ? (
|
|
<CodeBlock
|
|
language={match[1]}
|
|
{...props}
|
|
>
|
|
{String(children).replace(/\n$/, '')}
|
|
</CodeBlock>
|
|
) : (
|
|
<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: isMobile ? '60px 10px 20px' : '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 && String(topic.author) === String(user.id) && (
|
|
<Button
|
|
type="primary"
|
|
ghost
|
|
icon={<EditOutlined />}
|
|
onClick={() => setEditModalVisible(true)}
|
|
size={isMobile ? 'small' : 'middle'}
|
|
>
|
|
{isMobile ? '编辑' : '编辑帖子'}
|
|
</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: isMobile ? '15px' : '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={isMobile ? 3 : 2} style={{ color: '#fff', margin: '10px 0', wordBreak: 'break-word' }}>{topic.title}</Title>
|
|
|
|
<Space size={isMobile ? 'small' : 'large'} style={{ color: '#888', marginTop: 10, flexWrap: 'wrap' }}>
|
|
<Space>
|
|
<Avatar
|
|
src={topic.author_info?.avatar_url}
|
|
icon={<UserOutlined />}
|
|
size={isMobile ? 'small' : 'default'}
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => showUserTitle(topic.author_info)}
|
|
/>
|
|
<span style={{ color: '#ccc', fontSize: isMobile ? 12 : 14 }}>{topic.author_info?.nickname}</span>
|
|
{topic.is_verified_owner && (
|
|
<Tooltip title="已验证购买过相关产品">
|
|
<CheckCircleFilled style={{ color: '#00b96b' }} />
|
|
</Tooltip>
|
|
)}
|
|
</Space>
|
|
<Space>
|
|
<ClockCircleOutlined />
|
|
<span style={{ fontSize: isMobile ? 12 : 14 }}>{new Date(topic.created_at).toLocaleString()}</span>
|
|
</Space>
|
|
<Space>
|
|
<EyeOutlined />
|
|
<span style={{ fontSize: isMobile ? 12 : 14 }}>{topic.view_count} 阅读</span>
|
|
</Space>
|
|
<Space style={{ cursor: 'pointer' }} onClick={handleLikeTopic}>
|
|
{topic.is_liked ? <LikeFilled style={{ color: '#00b96b' }} /> : <LikeOutlined />}
|
|
<span style={{ fontSize: isMobile ? 12 : 14, color: topic.is_liked ? '#00b96b' : 'inherit' }}>{topic.like_count || 0} 点赞</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={styles['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={isMobile ? 5 : 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
|
|
}}
|
|
styles={{ body: { padding: isMobile ? '15px' : '24px' } }}
|
|
>
|
|
<div style={{ display: 'flex', gap: isMobile ? 10 : 16 }}>
|
|
<Avatar
|
|
src={reply.author_info?.avatar_url}
|
|
icon={<UserOutlined />}
|
|
size={isMobile ? 'small' : 'default'}
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => showUserTitle(reply.author_info)}
|
|
/>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
<Space size={isMobile ? 'small' : 'middle'} align="center">
|
|
<Text style={{ color: '#aaa', fontWeight: 'bold', fontSize: isMobile ? 13 : 14 }}>{reply.author_info?.nickname}</Text>
|
|
{reply.is_pinned && <Tag color="red" style={{ margin: 0 }}>置顶</Tag>}
|
|
<Text style={{ color: '#666', fontSize: 12 }}>{new Date(reply.created_at).toLocaleString()}</Text>
|
|
</Space>
|
|
<Space size={isMobile ? 'small' : 'middle'} align="center">
|
|
<Space onClick={() => handleLikeReply(reply.id)} style={{ cursor: 'pointer' }}>
|
|
{reply.is_liked ? <LikeFilled style={{ color: '#00b96b' }} /> : <LikeOutlined style={{ color: '#666' }} />}
|
|
<span style={{ fontSize: 12, color: reply.is_liked ? '#00b96b' : '#666' }}>{reply.like_count || 0}</span>
|
|
</Space>
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
onClick={() => handleReplyToUser(reply.author_info?.nickname)}
|
|
style={{ padding: 0, height: 'auto' }}
|
|
>
|
|
回复
|
|
</Button>
|
|
<Text style={{ color: '#444', fontSize: 12 }}>#{index + 1}</Text>
|
|
</Space>
|
|
</div>
|
|
<div style={{ color: '#eee', fontSize: isMobile ? 14 : 16 }} className={styles['markdown-body']}>
|
|
<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)'
|
|
}}
|
|
styles={{ body: { padding: isMobile ? '15px' : '24px' } }}
|
|
>
|
|
<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' }}
|
|
/>
|
|
|
|
{starUsers.length > 0 && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Text style={{ color: '#888', marginRight: 8 }}>@技术专家:</Text>
|
|
<Space wrap>
|
|
{starUsers.map(user => (
|
|
<Tag
|
|
key={user.id}
|
|
color="gold"
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => handleReplyToUser(user.nickname)}
|
|
>
|
|
{user.nickname}
|
|
</Tag>
|
|
))}
|
|
</Space>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', justifyContent: 'space-between', alignItems: isMobile ? 'stretch' : 'center', gap: isMobile ? 10 : 0 }}>
|
|
<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)',
|
|
width: isMobile ? '100%' : 'auto'
|
|
}}
|
|
>
|
|
插入图片/视频
|
|
</Button>
|
|
</Upload>
|
|
|
|
<Button type="primary" onClick={handleSubmitReply} loading={submitting} style={{ width: isMobile ? '100%' : 'auto' }}>
|
|
提交回复
|
|
</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={(data) => login(data)}
|
|
/>
|
|
|
|
<Modal
|
|
open={expertModalVisible}
|
|
onCancel={() => setExpertModalVisible(false)}
|
|
footer={null}
|
|
title={<Space><StarFilled style={{ color: '#ffd700' }} /><span style={{ color: '#fff' }}>技术专家信息</span></Space>}
|
|
styles={{
|
|
content: { background: 'rgba(30, 30, 30, 0.95)', border: '1px solid rgba(255, 255, 255, 0.1)', backdropFilter: 'blur(10px)' },
|
|
header: { background: 'transparent', borderBottom: '1px solid rgba(255, 255, 255, 0.1)' },
|
|
body: { background: 'transparent' },
|
|
mask: { backdropFilter: 'blur(4px)' }
|
|
}}
|
|
closeIcon={<CloseOutlined style={{ color: 'rgba(255, 255, 255, 0.5)' }} />}
|
|
bodyStyle={{ textAlign: 'center' }}
|
|
>
|
|
{selectedExpert && (
|
|
<div>
|
|
<Avatar size={80} src={selectedExpert.avatar_url} icon={<UserOutlined />} style={{ border: '3px solid #ffd700', marginBottom: 15, boxShadow: '0 0 15px rgba(255, 215, 0, 0.2)' }} />
|
|
<Title level={4} style={{ marginBottom: 5, color: '#fff' }}>{selectedExpert.nickname}</Title>
|
|
<Tag color="gold" style={{ marginBottom: 20, background: 'rgba(255, 215, 0, 0.15)', border: '1px solid rgba(255, 215, 0, 0.4)', color: '#ffd700' }}>{selectedExpert.title || '技术专家'}</Tag>
|
|
|
|
{selectedExpert.skills && selectedExpert.skills.length > 0 && (
|
|
<div style={{ marginTop: 20, textAlign: 'left' }}>
|
|
<Text strong style={{ display: 'block', marginBottom: 10, color: '#00b96b' }}>擅长技能</Text>
|
|
<Space wrap>
|
|
{selectedExpert.skills.map((skill, idx) => (
|
|
<div key={idx} style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
background: 'rgba(255, 255, 255, 0.05)',
|
|
padding: '6px 12px',
|
|
borderRadius: 6,
|
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
transition: 'all 0.3s'
|
|
}}>
|
|
{skill.icon && <img src={skill.icon} style={{ width: 16, height: 16, marginRight: 6 }} />}
|
|
<Text style={{ fontSize: 12, color: '#ddd' }}>{skill.text}</Text>
|
|
</div>
|
|
))}
|
|
</Space>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* 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; |