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

@@ -0,0 +1,23 @@
from rest_framework import permissions
from .utils import get_current_wechat_user
class IsAuthorOrReadOnly(permissions.BasePermission):
"""
Object-level permission to only allow authors of an object to edit it.
Assumes the model instance has an `author` attribute.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS requests.
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions are only allowed to the author of the object.
# We need to manually get the user because we are using custom auth logic (get_current_wechat_user)
# instead of request.user for some reason (or in addition to).
# However, DRF's request.user might not be set if we don't use a standard authentication class.
# Based on views.py, it uses `get_current_wechat_user(request)`.
current_user = get_current_wechat_user(request)
return current_user and obj.author == current_user

View File

@@ -30,12 +30,24 @@ class TopicMediaSerializer(serializers.ModelSerializer):
class ReplySerializer(serializers.ModelSerializer):
author_info = WeChatUserSerializer(source='author', read_only=True)
media = TopicMediaSerializer(many=True, read_only=True)
media_ids = serializers.ListField(
child=serializers.IntegerField(),
write_only=True,
required=False
)
class Meta:
model = Reply
fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at']
fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at', 'media_ids']
read_only_fields = ['author', 'created_at']
def create(self, validated_data):
media_ids = validated_data.pop('media_ids', [])
reply = super().create(validated_data)
if media_ids:
TopicMedia.objects.filter(id__in=media_ids).update(reply=reply)
return reply
class TopicSerializer(serializers.ModelSerializer):
author_info = WeChatUserSerializer(source='author', read_only=True)
replies = ReplySerializer(many=True, read_only=True)

View File

@@ -0,0 +1,40 @@
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from shop.models import WeChatUser
def get_current_wechat_user(request):
"""
根据 Authorization 头获取当前微信用户
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return None
token = auth_header.split(' ')[1]
signer = TimestampSigner()
try:
# 签名包含 openid
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
user = WeChatUser.objects.filter(openid=openid).first()
if user:
return user
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
# 场景Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
if openid.startswith('web_'):
try:
# 格式: web_13800138000
parts = openid.split('_', 1)
if len(parts) == 2:
phone = parts[1]
# 尝试通过手机号查找(查找合并后的主账号)
user = WeChatUser.objects.filter(phone_number=phone).first()
if user:
return user
except Exception:
pass
return None
except (BadSignature, SignatureExpired):
return None

View File

@@ -11,22 +11,8 @@ from drf_spectacular.utils import extend_schema
from shop.models import WeChatUser
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer
def get_current_wechat_user(request):
"""
根据 Authorization 头获取当前微信用户 (复用 shop app 的逻辑)
"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return None
token = auth_header.split(' ')[1]
signer = TimestampSigner()
try:
# 签名包含 openid
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
return WeChatUser.objects.filter(openid=openid).first()
except (BadSignature, SignatureExpired):
return None
from .utils import get_current_wechat_user
from .permissions import IsAuthorOrReadOnly
class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
"""
@@ -71,6 +57,7 @@ class TopicViewSet(viewsets.ModelViewSet):
"""
queryset = Topic.objects.all()
serializer_class = TopicSerializer
permission_classes = [IsAuthorOrReadOnly]
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['title', 'content']
filterset_fields = ['category', 'is_pinned']
@@ -102,6 +89,7 @@ class ReplyViewSet(viewsets.ModelViewSet):
"""
queryset = Reply.objects.all()
serializer_class = ReplySerializer
permission_classes = [IsAuthorOrReadOnly]
def perform_create(self, serializer):
user = get_current_wechat_user(self.request)

View File

@@ -89,7 +89,7 @@ WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
# 优先使用 SQLite 进行本地开发,如果需要 PostgreSQL 请自行配置
# 数据库配置:默认使用 SQLite,如果有环境变量配置则使用 PostgreSQL
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
@@ -97,17 +97,17 @@ DATABASES = {
}
}
# 如果您坚持要使用 PostgreSQL请取消下面的注释并确保本地已启动 Postgres 服务
# DATABASES = {
# 'default': {
# 'ENGINE': 'django.db.backends.postgresql',
# 'NAME': 'market',
# 'USER': 'market',
# 'PASSWORD': '123market',
# 'HOST': 'localhost',
# 'PORT': '5432',
# }
# }
# 从环境变量获取数据库配置 (Docker 环境会自动注入这些变量)
DB_HOST = os.environ.get('DB_HOST')
if DB_HOST:
DATABASES['default'] = {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'market'),
'USER': os.environ.get('DB_USER', 'market'),
'PASSWORD': os.environ.get('DB_PASSWORD', '123market'),
'HOST': DB_HOST,
'PORT': os.environ.get('DB_PORT', '5432'),
}
# Password validation

Binary file not shown.

View File

@@ -916,6 +916,8 @@ class OrderViewSet(viewsets.ModelViewSet):
def get_current_wechat_user(request):
"""
根据 Authorization 头获取当前微信用户
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
@@ -925,7 +927,27 @@ def get_current_wechat_user(request):
try:
# 签名包含 openid
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
return WeChatUser.objects.filter(openid=openid).first()
user = WeChatUser.objects.filter(openid=openid).first()
if user:
return user
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
# 场景Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
if openid.startswith('web_'):
try:
# 格式: web_13800138000
parts = openid.split('_', 1)
if len(parts) == 2:
phone = parts[1]
# 尝试通过手机号查找(查找合并后的主账号)
user = WeChatUser.objects.filter(phone_number=phone).first()
if user:
return user
except Exception:
pass
return None
except (BadSignature, SignatureExpired):
return None

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@uiw/react-md-editor": "^4.0.11",
"antd": "^6.2.2",
"axios": "^1.13.4",
"framer-motion": "^12.29.2",
@@ -19,7 +20,13 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"react-syntax-highlighter": "^16.1.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"three": "^0.182.0"
},
"devDependencies": {

View File

@@ -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) => {

View File

@@ -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![${file.name}](${url})\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;

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;