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): class ReplySerializer(serializers.ModelSerializer):
author_info = WeChatUserSerializer(source='author', read_only=True) author_info = WeChatUserSerializer(source='author', read_only=True)
media = TopicMediaSerializer(many=True, read_only=True) media = TopicMediaSerializer(many=True, read_only=True)
media_ids = serializers.ListField(
child=serializers.IntegerField(),
write_only=True,
required=False
)
class Meta: class Meta:
model = Reply 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'] 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): class TopicSerializer(serializers.ModelSerializer):
author_info = WeChatUserSerializer(source='author', read_only=True) author_info = WeChatUserSerializer(source='author', read_only=True)
replies = ReplySerializer(many=True, 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 shop.models import WeChatUser
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer
from .utils import get_current_wechat_user
def get_current_wechat_user(request): from .permissions import IsAuthorOrReadOnly
"""
根据 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
class ActivityViewSet(viewsets.ReadOnlyModelViewSet): class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
""" """
@@ -71,6 +57,7 @@ class TopicViewSet(viewsets.ModelViewSet):
""" """
queryset = Topic.objects.all() queryset = Topic.objects.all()
serializer_class = TopicSerializer serializer_class = TopicSerializer
permission_classes = [IsAuthorOrReadOnly]
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend] filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['title', 'content'] search_fields = ['title', 'content']
filterset_fields = ['category', 'is_pinned'] filterset_fields = ['category', 'is_pinned']
@@ -102,6 +89,7 @@ class ReplyViewSet(viewsets.ModelViewSet):
""" """
queryset = Reply.objects.all() queryset = Reply.objects.all()
serializer_class = ReplySerializer serializer_class = ReplySerializer
permission_classes = [IsAuthorOrReadOnly]
def perform_create(self, serializer): def perform_create(self, serializer):
user = get_current_wechat_user(self.request) user = get_current_wechat_user(self.request)

View File

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

Binary file not shown.

View File

@@ -916,6 +916,8 @@ class OrderViewSet(viewsets.ModelViewSet):
def get_current_wechat_user(request): def get_current_wechat_user(request):
""" """
根据 Authorization 头获取当前微信用户 根据 Authorization 头获取当前微信用户
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
""" """
auth_header = request.headers.get('Authorization') auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '): if not auth_header or not auth_header.startswith('Bearer '):
@@ -925,7 +927,27 @@ def get_current_wechat_user(request):
try: try:
# 签名包含 openid # 签名包含 openid
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效 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): except (BadSignature, SignatureExpired):
return None return None

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@uiw/react-md-editor": "^4.0.11",
"antd": "^6.2.2", "antd": "^6.2.2",
"axios": "^1.13.4", "axios": "^1.13.4",
"framer-motion": "^12.29.2", "framer-motion": "^12.29.2",
@@ -19,7 +20,13 @@
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.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" "three": "^0.182.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -60,6 +60,7 @@ export const uploadUserAvatar = (data) => {
export const getTopics = (params) => api.get('/community/topics/', { params }); export const getTopics = (params) => api.get('/community/topics/', { params });
export const getTopicDetail = (id) => api.get(`/community/topics/${id}/`); export const getTopicDetail = (id) => api.get(`/community/topics/${id}/`);
export const createTopic = (data) => api.post('/community/topics/', data); 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 getReplies = (params) => api.get('/community/replies/', { params });
export const createReply = (data) => api.post('/community/replies/', data); export const createReply = (data) => api.post('/community/replies/', data);
export const uploadMedia = (data) => { export const uploadMedia = (data) => {

View File

@@ -1,27 +1,59 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, Button, message, Upload, Select, Divider, Radio, Tabs, Alert } from 'antd'; import { Modal, Form, Input, Button, message, Upload, Select } from 'antd';
import { InboxOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
import { createTopic, uploadMedia, getMyPaidItems } from '../api'; 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 { Option } = Select;
const { Dragger } = Upload;
const CreateTopicModal = ({ visible, onClose, onSuccess }) => { const CreateTopicModal = ({ visible, onClose, onSuccess, initialValues, isEditMode, topicId }) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [paidItems, setPaidItems] = useState({ configs: [], courses: [], services: [] }); const [paidItems, setPaidItems] = useState({ configs: [], courses: [], services: [] });
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [mediaIds, setMediaIds] = useState([]); const [mediaIds, setMediaIds] = useState([]);
// eslint-disable-next-line no-unused-vars
const [mediaList, setMediaList] = useState([]); // Store uploaded media details for preview const [mediaList, setMediaList] = useState([]); // Store uploaded media details for preview
const [content, setContent] = useState("");
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
fetchPaidItems(); fetchPaidItems();
setMediaIds([]); // Reset media IDs if (isEditMode && initialValues) {
setMediaList([]); // Reset media list // 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 () => { const fetchPaidItems = async () => {
try { try {
@@ -77,14 +109,14 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
}]); }]);
// 插入到编辑器 // 插入到编辑器
const currentContent = form.getFieldValue('content') || '';
const insertText = file.type.startsWith('video') const insertText = file.type.startsWith('video')
? `\n<video src="${url}" controls width="100%"></video>\n` ? `\n<video src="${url}" controls width="100%"></video>\n`
: `\n![${file.name}](${url})\n`; : `\n![${file.name}](${url})\n`;
form.setFieldsValue({ const newContent = content + insertText;
content: currentContent + insertText setContent(newContent);
}); form.setFieldsValue({ content: newContent });
message.success('上传成功'); message.success('上传成功');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -100,7 +132,8 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
try { try {
// 处理关联项目 ID (select value format: "type_id") // 处理关联项目 ID (select value format: "type_id")
const relatedValue = values.related_item; 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; delete payload.related_item;
if (relatedValue) { if (relatedValue) {
@@ -108,16 +141,27 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
if (type === 'config') payload.related_product = id; if (type === 'config') payload.related_product = id;
if (type === 'course') payload.related_course = id; if (type === 'course') payload.related_course = id;
if (type === 'service') payload.related_service = 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;
} }
if (isEditMode && topicId) {
await updateTopic(topicId, payload);
message.success('修改成功');
} else {
await createTopic(payload); await createTopic(payload);
message.success('发布成功'); message.success('发布成功');
}
form.resetFields(); form.resetFields();
if (onSuccess) onSuccess(); if (onSuccess) onSuccess();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
message.error('发布失败: ' + (error.response?.data?.detail || '网络错误')); message.error((isEditMode ? '修改' : '发布') + '失败: ' + (error.response?.data?.detail || '网络错误'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -125,12 +169,12 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
return ( return (
<Modal <Modal
title="发布新帖" title={isEditMode ? "编辑帖子" : "发布新帖"}
open={visible} open={visible}
onCancel={onClose} onCancel={onClose}
footer={null} footer={null}
destroyOnClose destroyOnClose
width={800} width={1000}
style={{ top: 20 }} style={{ top: 20 }}
> >
<Form <Form
@@ -189,47 +233,33 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
<Form.Item <Form.Item
name="content" name="content"
label="内容 (支持 Markdown)" label="内容 (支持 Markdown 与 LaTeX 公式)"
rules={[{ required: true, message: '请输入内容' }]} rules={[{ required: true, message: '请输入内容' }]}
> >
<div> <div data-color-mode="light">
<div style={{ marginBottom: 10 }}>
<Upload <Upload
beforeUpload={handleUpload} beforeUpload={handleUpload}
showUploadList={false} showUploadList={false}
accept="image/*,video/*" accept="image/*,video/*"
> >
<Button icon={<UploadOutlined />} loading={uploading} size="small" style={{ marginBottom: 8 }}> <Button icon={<UploadOutlined />} loading={uploading} size="small">
插入图片/视频 插入图片/视频
</Button> </Button>
</Upload> </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>
)}
<TextArea <MDEditor
rows={12} value={content}
placeholder="请详细描述您的问题... onChange={(val) => {
支持 Markdown 语法: setContent(val);
**加粗** form.setFieldsValue({ content: val });
# 标题 }}
- 列表 height={400}
[链接](url) previewOptions={{
" rehypePlugins: [[rehypeKatex]],
showCount remarkPlugins: [[remarkMath]],
maxLength={10000} }}
style={{ fontFamily: 'monospace' }}
/> />
</div> </div>
</Form.Item> </Form.Item>
@@ -238,7 +268,7 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<Button onClick={onClose}>取消</Button> <Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit" loading={loading} size="large"> <Button type="primary" htmlType="submit" loading={loading} size="large">
立即发布 {isEditMode ? "保存修改" : "立即发布"}
</Button> </Button>
</div> </div>
</Form.Item> </Form.Item>

View File

@@ -1,12 +1,21 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Typography, Card, Avatar, Tag, Space, Button, Divider, Input, message, Breadcrumb, Tooltip } from 'antd'; import { Typography, Card, Avatar, Tag, Space, Button, Divider, Input, message, Upload, Tooltip } from 'antd';
import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, LikeOutlined } from '@ant-design/icons'; import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, UploadOutlined, EditOutlined } from '@ant-design/icons';
import { getTopicDetail, createReply } from '../api'; import { getTopicDetail, createReply, uploadMedia } from '../api';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import LoginModal from '../components/LoginModal'; 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 { TextArea } = Input;
const ForumDetail = () => { const ForumDetail = () => {
@@ -20,6 +29,13 @@ const ForumDetail = () => {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loginModalVisible, setLoginModalVisible] = 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 () => { const fetchTopic = async () => {
try { try {
const res = await getTopicDetail(id); const res = await getTopicDetail(id);
@@ -54,10 +70,12 @@ const ForumDetail = () => {
try { try {
await createReply({ await createReply({
topic: id, topic: id,
content: replyContent content: replyContent,
media_ids: replyMediaIds // Send uploaded media IDs
}); });
message.success('回复成功'); message.success('回复成功');
setReplyContent(''); setReplyContent('');
setReplyMediaIds([]); // Reset media IDs
fetchTopic(); // Refresh to show new reply fetchTopic(); // Refresh to show new reply
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -67,20 +85,100 @@ 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 (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>; 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 ( return (
<div style={{ padding: '40px 20px', minHeight: '100vh', maxWidth: 1000, margin: '0 auto' }}> <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 <Button
type="text" type="text"
icon={<LeftOutlined />} icon={<LeftOutlined />}
style={{ color: '#fff', marginBottom: 20 }} style={{ color: '#fff' }}
onClick={() => navigate('/forum')} onClick={() => navigate('/forum')}
> >
返回列表 返回列表
</Button> </Button>
{user && topic.author === user.id && (
<Button
type="primary"
ghost
icon={<EditOutlined />}
onClick={() => setEditModalVisible(true)}
>
编辑帖子
</Button>
)}
</div>
{/* Topic Content */} {/* Topic Content */}
<Card <Card
style={{ style={{
@@ -124,45 +222,24 @@ const ForumDetail = () => {
fontSize: 16, fontSize: 16,
lineHeight: 1.8, lineHeight: 1.8,
minHeight: 200, minHeight: 200,
whiteSpace: 'pre-wrap' // Preserve formatting }} className="markdown-body">
}}> <ReactMarkdown
{topic.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]')} remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={markdownComponents}
>
{topic.content}
</ReactMarkdown>
</div> </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) { 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 }}> <div key={`media-${media.id}`} style={{ marginTop: 12 }}>
{media.media_type === 'video' ? (
<video src={media.url} controls style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }} /> <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%' }}
/>
)}
</div> </div>
)); ));
} }
return null; return null;
})()} })()}
</Card> </Card>
@@ -193,7 +270,15 @@ const ForumDetail = () => {
</Space> </Space>
<Text style={{ color: '#444' }}>#{index + 1}</Text> <Text style={{ color: '#444' }}>#{index + 1}</Text>
</div> </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>
</div> </div>
</Card> </Card>
@@ -214,10 +299,20 @@ const ForumDetail = () => {
rows={4} rows={4}
value={replyContent} value={replyContent}
onChange={e => setReplyContent(e.target.value)} onChange={e => setReplyContent(e.target.value)}
placeholder="友善回复,分享你的见解..." placeholder="友善回复,分享你的见解... (支持 Markdown)"
style={{ marginBottom: 16, background: '#111', border: '1px solid #333', color: '#fff' }} 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 type="primary" onClick={handleSubmitReply} loading={submitting}>
提交回复 提交回复
</Button> </Button>
@@ -239,6 +334,20 @@ const ForumDetail = () => {
onClose={() => setLoginModalVisible(false)} onClose={() => setLoginModalVisible(false)}
onLoginSuccess={() => {}} 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> </div>
); );
}; };