This commit is contained in:
jeremygan2021
2026-02-12 15:51:18 +08:00
parent e69a24b555
commit 4ac8767659
14 changed files with 2851 additions and 141 deletions

View File

@@ -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![${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 <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;