forum
This commit is contained in:
@@ -2,7 +2,7 @@ from django.contrib import admin
|
|||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from unfold.admin import ModelAdmin, TabularInline
|
from unfold.admin import ModelAdmin, TabularInline
|
||||||
from unfold.decorators import display
|
from unfold.decorators import display
|
||||||
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia
|
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
|
||||||
|
|
||||||
class ActivitySignupInline(TabularInline):
|
class ActivitySignupInline(TabularInline):
|
||||||
model = ActivitySignup
|
model = ActivitySignup
|
||||||
@@ -24,7 +24,7 @@ class ReplyInline(TabularInline):
|
|||||||
class TopicMediaInline(TabularInline):
|
class TopicMediaInline(TabularInline):
|
||||||
model = TopicMedia
|
model = TopicMedia
|
||||||
extra = 0
|
extra = 0
|
||||||
fields = ('file', 'media_type', 'created_at')
|
fields = ('file', 'file_url', 'media_type', 'created_at')
|
||||||
readonly_fields = ('created_at',)
|
readonly_fields = ('created_at',)
|
||||||
can_delete = True
|
can_delete = True
|
||||||
|
|
||||||
@@ -91,15 +91,15 @@ class ActivitySignupAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Topic)
|
@admin.register(Topic)
|
||||||
class TopicAdmin(ModelAdmin):
|
class TopicAdmin(ModelAdmin):
|
||||||
list_display = ('title', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at')
|
list_display = ('title', 'category', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at')
|
||||||
list_filter = ('is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
|
list_filter = ('category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
|
||||||
search_fields = ('title', 'content', 'author__nickname')
|
search_fields = ('title', 'content', 'author__nickname')
|
||||||
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
|
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
|
||||||
inlines = [TopicMediaInline, ReplyInline]
|
inlines = [TopicMediaInline, ReplyInline]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('帖子内容', {
|
('帖子内容', {
|
||||||
'fields': ('title', 'content', 'is_pinned')
|
'fields': ('title', 'category', 'content', 'is_pinned')
|
||||||
}),
|
}),
|
||||||
('关联信息', {
|
('关联信息', {
|
||||||
'fields': ('author', 'related_product', 'related_service', 'related_course'),
|
'fields': ('author', 'related_product', 'related_service', 'related_course'),
|
||||||
@@ -157,6 +157,63 @@ class TopicMediaAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@display(description="预览")
|
@display(description="预览")
|
||||||
def file_preview(self, obj):
|
def file_preview(self, obj):
|
||||||
if obj.media_type == 'image':
|
url = ""
|
||||||
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.file.url)
|
if obj.file:
|
||||||
return obj.file.name
|
url = obj.file.url
|
||||||
|
elif obj.file_url:
|
||||||
|
url = obj.file_url
|
||||||
|
|
||||||
|
if obj.media_type == 'image' and url:
|
||||||
|
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
|
||||||
|
return obj.file.name or "外部文件"
|
||||||
|
|
||||||
|
@admin.register(Announcement)
|
||||||
|
class AnnouncementAdmin(ModelAdmin):
|
||||||
|
list_display = ('title', 'image_preview', 'active_label', 'pinned_label', 'priority', 'start_time', 'end_time', 'created_at')
|
||||||
|
list_filter = ('is_active', 'is_pinned', 'created_at')
|
||||||
|
search_fields = ('title', 'content')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('公告信息', {
|
||||||
|
'fields': ('title', 'content', 'link_url')
|
||||||
|
}),
|
||||||
|
('图片设置', {
|
||||||
|
'fields': ('image', 'image_url'),
|
||||||
|
'description': '上传图片或填写图片链接,优先显示上传的图片'
|
||||||
|
}),
|
||||||
|
('显示设置', {
|
||||||
|
'fields': ('is_active', 'is_pinned', 'priority'),
|
||||||
|
'classes': ('tab',)
|
||||||
|
}),
|
||||||
|
('排期设置', {
|
||||||
|
'fields': ('start_time', 'end_time'),
|
||||||
|
'classes': ('tab',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@display(description="图片预览")
|
||||||
|
def image_preview(self, obj):
|
||||||
|
url = obj.display_image_url
|
||||||
|
if url:
|
||||||
|
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
|
||||||
|
return "无图片"
|
||||||
|
|
||||||
|
@display(
|
||||||
|
description="状态",
|
||||||
|
label={
|
||||||
|
True: "success",
|
||||||
|
False: "danger",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def active_label(self, obj):
|
||||||
|
return obj.is_active
|
||||||
|
|
||||||
|
@display(
|
||||||
|
description="置顶",
|
||||||
|
label={
|
||||||
|
True: "warning",
|
||||||
|
False: "default",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def pinned_label(self, obj):
|
||||||
|
return obj.is_pinned
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-12 06:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('community', '0005_topic_category'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='topicmedia',
|
||||||
|
name='file_url',
|
||||||
|
field=models.URLField(blank=True, max_length=500, null=True, verbose_name='文件链接'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='topicmedia',
|
||||||
|
name='file',
|
||||||
|
field=models.FileField(blank=True, null=True, upload_to='community/media/', verbose_name='文件'),
|
||||||
|
),
|
||||||
|
]
|
||||||
36
backend/community/migrations/0007_announcement.py
Normal file
36
backend/community/migrations/0007_announcement.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-12 06:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('community', '0006_topicmedia_file_url_alter_topicmedia_file'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Announcement',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=100, verbose_name='公告标题')),
|
||||||
|
('content', models.TextField(verbose_name='公告内容')),
|
||||||
|
('image', models.ImageField(blank=True, null=True, upload_to='announcements/', verbose_name='公告图片')),
|
||||||
|
('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片链接')),
|
||||||
|
('link_url', models.URLField(blank=True, null=True, verbose_name='跳转链接')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||||
|
('is_pinned', models.BooleanField(default=False, verbose_name='是否置顶')),
|
||||||
|
('priority', models.IntegerField(default=0, help_text='数字越大越靠前', verbose_name='排序权重')),
|
||||||
|
('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始展示时间')),
|
||||||
|
('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束展示时间')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '社区公告',
|
||||||
|
'verbose_name_plural': '社区公告管理',
|
||||||
|
'ordering': ['-is_pinned', '-priority', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -121,15 +121,6 @@ class Topic(models.Model):
|
|||||||
|
|
||||||
# 3. 验证服务
|
# 3. 验证服务
|
||||||
if self.related_service:
|
if self.related_service:
|
||||||
# ServiceOrder 模型中没有 direct link to WeChatUser (only phone/name),
|
|
||||||
# 但我们假设通过手机号或未来关联来验证,目前先检查 ServiceOrder 是否有对应记录。
|
|
||||||
# 由于 ServiceOrder 目前设计没有直接关联 WeChatUser 字段,我们暂时尝试通过名字或后续改进。
|
|
||||||
# 经检查 shop/models.py, ServiceOrder 确实只有 customer_name/phone_number.
|
|
||||||
# 这里为了严谨,我们暂时仅对有关联的进行检查,或者需要改进 ServiceOrder。
|
|
||||||
# 鉴于当前任务范围,如果 ServiceOrder 没有 user 字段,我们可能无法精确验证,
|
|
||||||
# 除非我们假设用户填写的手机号与微信用户关联。
|
|
||||||
# *修正*: 为了快速实现,我们先跳过 ServiceOrder 的精确验证,或者仅仅返回 False,
|
|
||||||
# 等待后续 ServiceOrder 添加 wechat_user 字段。
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -171,7 +162,8 @@ class TopicMedia(models.Model):
|
|||||||
|
|
||||||
topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='media', verbose_name="所属帖子", null=True, blank=True)
|
topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='media', verbose_name="所属帖子", null=True, blank=True)
|
||||||
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, related_name='media', verbose_name="所属回复", null=True, blank=True)
|
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, related_name='media', verbose_name="所属回复", null=True, blank=True)
|
||||||
file = models.FileField(upload_to='community/media/', verbose_name="文件")
|
file = models.FileField(upload_to='community/media/', verbose_name="文件", null=True, blank=True)
|
||||||
|
file_url = models.URLField(max_length=500, verbose_name="文件链接", null=True, blank=True)
|
||||||
media_type = models.CharField(max_length=10, choices=MEDIA_TYPE_CHOICES, default='image', verbose_name="媒体类型")
|
media_type = models.CharField(max_length=10, choices=MEDIA_TYPE_CHOICES, default='image', verbose_name="媒体类型")
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
|
||||||
|
|
||||||
@@ -181,3 +173,38 @@ class TopicMedia(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "论坛媒体资源"
|
verbose_name = "论坛媒体资源"
|
||||||
verbose_name_plural = "论坛媒体资源管理"
|
verbose_name_plural = "论坛媒体资源管理"
|
||||||
|
|
||||||
|
|
||||||
|
class Announcement(models.Model):
|
||||||
|
"""
|
||||||
|
社区公告模型
|
||||||
|
"""
|
||||||
|
title = models.CharField(max_length=100, verbose_name="公告标题")
|
||||||
|
content = models.TextField(verbose_name="公告内容")
|
||||||
|
image = models.ImageField(upload_to='announcements/', verbose_name="公告图片", null=True, blank=True)
|
||||||
|
image_url = models.URLField(verbose_name="图片链接", null=True, blank=True, help_text="优先使用上传的图片")
|
||||||
|
link_url = models.URLField(verbose_name="跳转链接", null=True, blank=True)
|
||||||
|
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||||
|
is_pinned = models.BooleanField(default=False, verbose_name="是否置顶")
|
||||||
|
priority = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越大越靠前")
|
||||||
|
|
||||||
|
start_time = models.DateTimeField(verbose_name="开始展示时间", null=True, blank=True)
|
||||||
|
end_time = models.DateTimeField(verbose_name="结束展示时间", null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_image_url(self):
|
||||||
|
if self.image:
|
||||||
|
return self.image.url
|
||||||
|
return self.image_url
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "社区公告"
|
||||||
|
verbose_name_plural = "社区公告管理"
|
||||||
|
ordering = ['-is_pinned', '-priority', '-created_at']
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia
|
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
|
||||||
from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer
|
from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer
|
||||||
|
|
||||||
class ActivitySerializer(serializers.ModelSerializer):
|
class ActivitySerializer(serializers.ModelSerializer):
|
||||||
@@ -16,9 +16,16 @@ class ActivitySignupSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ['signup_time', 'status']
|
read_only_fields = ['signup_time', 'status']
|
||||||
|
|
||||||
class TopicMediaSerializer(serializers.ModelSerializer):
|
class TopicMediaSerializer(serializers.ModelSerializer):
|
||||||
|
url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TopicMedia
|
model = TopicMedia
|
||||||
fields = ['id', 'file', 'media_type', 'created_at']
|
fields = ['id', 'file', 'file_url', 'url', 'media_type', 'created_at']
|
||||||
|
|
||||||
|
def get_url(self, obj):
|
||||||
|
if obj.file:
|
||||||
|
return obj.file.url
|
||||||
|
return obj.file_url
|
||||||
|
|
||||||
class ReplySerializer(serializers.ModelSerializer):
|
class ReplySerializer(serializers.ModelSerializer):
|
||||||
author_info = WeChatUserSerializer(source='author', read_only=True)
|
author_info = WeChatUserSerializer(source='author', read_only=True)
|
||||||
@@ -27,6 +34,7 @@ class ReplySerializer(serializers.ModelSerializer):
|
|||||||
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']
|
||||||
|
read_only_fields = ['author', 'created_at']
|
||||||
|
|
||||||
class TopicSerializer(serializers.ModelSerializer):
|
class TopicSerializer(serializers.ModelSerializer):
|
||||||
author_info = WeChatUserSerializer(source='author', read_only=True)
|
author_info = WeChatUserSerializer(source='author', read_only=True)
|
||||||
@@ -38,6 +46,12 @@ class TopicSerializer(serializers.ModelSerializer):
|
|||||||
service_info = ServiceSerializer(source='related_service', read_only=True)
|
service_info = ServiceSerializer(source='related_service', read_only=True)
|
||||||
course_info = VCCourseSerializer(source='related_course', read_only=True)
|
course_info = VCCourseSerializer(source='related_course', read_only=True)
|
||||||
|
|
||||||
|
media_ids = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(),
|
||||||
|
write_only=True,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Topic
|
model = Topic
|
||||||
fields = [
|
fields = [
|
||||||
@@ -46,6 +60,20 @@ class TopicSerializer(serializers.ModelSerializer):
|
|||||||
'related_service', 'service_info',
|
'related_service', 'service_info',
|
||||||
'related_course', 'course_info',
|
'related_course', 'course_info',
|
||||||
'view_count', 'is_pinned', 'created_at', 'updated_at',
|
'view_count', 'is_pinned', 'created_at', 'updated_at',
|
||||||
'is_verified_owner', 'replies', 'media'
|
'is_verified_owner', 'replies', 'media', 'media_ids'
|
||||||
]
|
]
|
||||||
read_only_fields = ['view_count', 'created_at', 'updated_at', 'is_verified_owner']
|
read_only_fields = ['author', 'view_count', 'created_at', 'updated_at', 'is_verified_owner']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
media_ids = validated_data.pop('media_ids', [])
|
||||||
|
topic = super().create(validated_data)
|
||||||
|
if media_ids:
|
||||||
|
TopicMedia.objects.filter(id__in=media_ids).update(topic=topic)
|
||||||
|
return topic
|
||||||
|
|
||||||
|
class AnnouncementSerializer(serializers.ModelSerializer):
|
||||||
|
display_image_url = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Announcement
|
||||||
|
fields = '__all__'
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet
|
from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet, AnnouncementViewSet
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'activities', ActivityViewSet)
|
router.register(r'activities', ActivityViewSet)
|
||||||
router.register(r'topics', TopicViewSet)
|
router.register(r'topics', TopicViewSet)
|
||||||
router.register(r'replies', ReplyViewSet)
|
router.register(r'replies', ReplyViewSet)
|
||||||
router.register(r'media', TopicMediaViewSet)
|
router.register(r'media', TopicMediaViewSet, basename='media')
|
||||||
|
router.register(r'announcements', AnnouncementViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ from rest_framework import viewsets, status, mixins, parsers, filters
|
|||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers, permissions
|
||||||
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import models
|
||||||
from drf_spectacular.utils import extend_schema
|
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
|
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
|
||||||
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer
|
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer
|
||||||
|
|
||||||
def get_current_wechat_user(request):
|
def get_current_wechat_user(request):
|
||||||
"""
|
"""
|
||||||
@@ -87,6 +89,13 @@ class TopicViewSet(viewsets.ModelViewSet):
|
|||||||
return Response({'error': '请先登录'}, status=401)
|
return Response({'error': '请先登录'}, status=401)
|
||||||
return super().create(request, *args, **kwargs)
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
instance.view_count += 1
|
||||||
|
instance.save(update_fields=['view_count'])
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
class ReplyViewSet(viewsets.ModelViewSet):
|
class ReplyViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
帖子回复接口
|
帖子回复接口
|
||||||
@@ -105,12 +114,13 @@ class ReplyViewSet(viewsets.ModelViewSet):
|
|||||||
return Response({'error': '请先登录'}, status=401)
|
return Response({'error': '请先登录'}, status=401)
|
||||||
return super().create(request, *args, **kwargs)
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
class TopicMediaViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin):
|
import requests
|
||||||
|
|
||||||
|
class TopicMediaViewSet(viewsets.ViewSet):
|
||||||
"""
|
"""
|
||||||
论坛多媒体资源上传接口
|
论坛多媒体资源上传接口 (代理到外部OSS服务)
|
||||||
"""
|
"""
|
||||||
queryset = TopicMedia.objects.all()
|
permission_classes = [] # 内部鉴权
|
||||||
serializer_class = TopicMediaSerializer
|
|
||||||
parser_classes = [parsers.MultiPartParser, parsers.FormParser]
|
parser_classes = [parsers.MultiPartParser, parsers.FormParser]
|
||||||
|
|
||||||
@extend_schema(summary="上传媒体文件 (返回URL用于Markdown)")
|
@extend_schema(summary="上传媒体文件 (返回URL用于Markdown)")
|
||||||
@@ -119,6 +129,57 @@ class TopicMediaViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin):
|
|||||||
if not user:
|
if not user:
|
||||||
return Response({'error': '请先登录'}, status=401)
|
return Response({'error': '请先登录'}, status=401)
|
||||||
|
|
||||||
# 允许上传时不关联 Topic (发帖前上传),或后续关联
|
file_obj = request.FILES.get('file')
|
||||||
# 主要是返回 url
|
if not file_obj:
|
||||||
return super().create(request, *args, **kwargs)
|
return Response({'error': '未提供文件'}, status=400)
|
||||||
|
|
||||||
|
# 转发到外部 OSS 上传服务
|
||||||
|
upload_url = "https://data.tangledup-ai.com/upload?folder=uploads%2Fmarket%2Fforum_image"
|
||||||
|
files = {'file': (file_obj.name, file_obj, file_obj.content_type)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 这里的 headers 不需要 Content-Type,requests 会自动设置 multipart/form-data
|
||||||
|
response = requests.post(upload_url, files=files, timeout=30)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('success'):
|
||||||
|
# Create TopicMedia record
|
||||||
|
media_type = 'image' if 'image' in file_obj.content_type else 'video'
|
||||||
|
media_obj = TopicMedia.objects.create(
|
||||||
|
file_url=data.get('file_url'),
|
||||||
|
media_type=media_type,
|
||||||
|
# topic will be associated later
|
||||||
|
)
|
||||||
|
|
||||||
|
# 返回符合前端预期的格式
|
||||||
|
return Response({
|
||||||
|
'id': media_obj.id, # Return real DB ID
|
||||||
|
'file': media_obj.file_url,
|
||||||
|
'media_type': media_obj.media_type,
|
||||||
|
'created_at': media_obj.created_at
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return Response({'error': '外部服务上传失败', 'detail': data}, status=400)
|
||||||
|
else:
|
||||||
|
return Response({'error': f'上传服务响应错误: {response.status_code}', 'detail': response.text}, status=502)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': str(e)}, status=500)
|
||||||
|
|
||||||
|
class AnnouncementViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
社区公告接口
|
||||||
|
"""
|
||||||
|
queryset = Announcement.objects.all()
|
||||||
|
serializer_class = AnnouncementSerializer
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
now = timezone.now()
|
||||||
|
qs = Announcement.objects.filter(is_active=True)
|
||||||
|
# Filter by start_time (if set, must be <= now)
|
||||||
|
qs = qs.filter(models.Q(start_time__isnull=True) | models.Q(start_time__lte=now))
|
||||||
|
# Filter by end_time (if set, must be >= now)
|
||||||
|
qs = qs.filter(models.Q(end_time__isnull=True) | models.Q(end_time__gte=now))
|
||||||
|
return qs.order_by('-is_pinned', '-priority', '-created_at')
|
||||||
|
|||||||
Binary file not shown.
@@ -279,6 +279,11 @@ UNFOLD = {
|
|||||||
"icon": "how_to_reg",
|
"icon": "how_to_reg",
|
||||||
"link": reverse_lazy("admin:community_activitysignup_changelist"),
|
"link": reverse_lazy("admin:community_activitysignup_changelist"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "社区公告",
|
||||||
|
"icon": "campaign",
|
||||||
|
"link": reverse_lazy("admin:community_announcement_changelist"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "技术论坛帖子",
|
"title": "技术论坛帖子",
|
||||||
"icon": "forum",
|
"icon": "forum",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -311,9 +311,9 @@ class OrderAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(WeChatUser)
|
@admin.register(WeChatUser)
|
||||||
class WeChatUserAdmin(ModelAdmin):
|
class WeChatUserAdmin(ModelAdmin):
|
||||||
list_display = ('nickname', 'avatar_display', 'gender_display', 'province', 'city', 'created_at')
|
list_display = ('nickname', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at')
|
||||||
search_fields = ('nickname', 'openid')
|
search_fields = ('nickname', 'openid')
|
||||||
list_filter = ('gender', 'province', 'city', 'created_at')
|
list_filter = ('is_star', 'gender', 'province', 'city', 'created_at')
|
||||||
readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at')
|
readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at')
|
||||||
|
|
||||||
def avatar_display(self, obj):
|
def avatar_display(self, obj):
|
||||||
@@ -331,6 +331,10 @@ class WeChatUserAdmin(ModelAdmin):
|
|||||||
('基本信息', {
|
('基本信息', {
|
||||||
'fields': ('user', 'nickname', 'avatar_url', 'gender')
|
'fields': ('user', 'nickname', 'avatar_url', 'gender')
|
||||||
}),
|
}),
|
||||||
|
('专家认证', {
|
||||||
|
'fields': ('is_star', 'title'),
|
||||||
|
'description': '标记为明星技术用户/专家,将在社区中展示'
|
||||||
|
}),
|
||||||
('位置信息', {
|
('位置信息', {
|
||||||
'fields': ('country', 'province', 'city')
|
'fields': ('country', 'province', 'city')
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from .views import (
|
|||||||
ESP32ConfigViewSet, OrderViewSet, order_check_view,
|
ESP32ConfigViewSet, OrderViewSet, order_check_view,
|
||||||
ServiceViewSet, VCCourseViewSet, ServiceOrderViewSet,
|
ServiceViewSet, VCCourseViewSet, ServiceOrderViewSet,
|
||||||
payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet,
|
payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet,
|
||||||
CourseEnrollmentViewSet, phone_login, bind_phone
|
CourseEnrollmentViewSet, phone_login, bind_phone, WeChatUserViewSet, upload_image
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -15,6 +15,7 @@ router.register(r'courses', VCCourseViewSet)
|
|||||||
router.register(r'course-enrollments', CourseEnrollmentViewSet)
|
router.register(r'course-enrollments', CourseEnrollmentViewSet)
|
||||||
router.register(r'service-orders', ServiceOrderViewSet)
|
router.register(r'service-orders', ServiceOrderViewSet)
|
||||||
router.register(r'distributor', DistributorViewSet, basename='distributor')
|
router.register(r'distributor', DistributorViewSet, basename='distributor')
|
||||||
|
router.register(r'users', WeChatUserViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
re_path(r'^finish/?$', payment_finish, name='payment-finish'),
|
re_path(r'^finish/?$', payment_finish, name='payment-finish'),
|
||||||
@@ -24,6 +25,7 @@ urlpatterns = [
|
|||||||
path('auth/phone-login/', phone_login, name='phone-login'),
|
path('auth/phone-login/', phone_login, name='phone-login'),
|
||||||
path('auth/bind-phone/', bind_phone, name='bind-phone'),
|
path('auth/bind-phone/', bind_phone, name='bind-phone'),
|
||||||
path('wechat/update/', update_user_info, name='wechat-update'),
|
path('wechat/update/', update_user_info, name='wechat-update'),
|
||||||
|
path('upload/image/', upload_image, name='upload-image'),
|
||||||
path('page/check-order/', order_check_view, name='check-order-page'),
|
path('page/check-order/', order_check_view, name='check-order-page'),
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.decorators import action, api_view
|
from rest_framework.decorators import action, api_view, parser_classes
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -1309,3 +1312,95 @@ class DistributorViewSet(viewsets.GenericViewSet):
|
|||||||
serializer = OrderSerializer(orders, many=True)
|
serializer = OrderSerializer(orders, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
class WeChatUserViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
微信用户视图集
|
||||||
|
"""
|
||||||
|
queryset = WeChatUser.objects.all()
|
||||||
|
serializer_class = WeChatUserSerializer
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def stars(self, request):
|
||||||
|
"""
|
||||||
|
获取明星技术用户列表
|
||||||
|
"""
|
||||||
|
stars = WeChatUser.objects.filter(is_star=True).order_by('-created_at')
|
||||||
|
serializer = self.get_serializer(stars, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='paid-items')
|
||||||
|
def paid_items(self, request):
|
||||||
|
"""
|
||||||
|
获取当前用户已购买的项目(硬件、课程、服务)
|
||||||
|
用于论坛发帖时关联
|
||||||
|
"""
|
||||||
|
user = get_current_wechat_user(request)
|
||||||
|
if not user:
|
||||||
|
return Response({'error': 'Unauthorized'}, status=401)
|
||||||
|
|
||||||
|
# 1. 硬件 (ESP32Config)
|
||||||
|
paid_orders = Order.objects.filter(wechat_user=user, status__in=['paid', 'shipped'])
|
||||||
|
config_ids = paid_orders.exclude(config__isnull=True).values_list('config_id', flat=True).distinct()
|
||||||
|
configs = ESP32Config.objects.filter(id__in=config_ids)
|
||||||
|
|
||||||
|
# 2. 课程 (VCCourse)
|
||||||
|
course_ids = paid_orders.exclude(course__isnull=True).values_list('course_id', flat=True).distinct()
|
||||||
|
courses = VCCourse.objects.filter(id__in=course_ids)
|
||||||
|
|
||||||
|
# 3. 服务 (Service)
|
||||||
|
# 暂时没有强关联 WeChatUser 的 ServiceOrder,如果有 phone_number 匹配逻辑可在此添加
|
||||||
|
# 简单起见,暂不返回服务,或基于 phone_number 匹配
|
||||||
|
service_orders = ServiceOrder.objects.filter(phone_number=user.phone_number, status='paid')
|
||||||
|
service_ids = service_orders.values_list('service_id', flat=True).distinct()
|
||||||
|
services = Service.objects.filter(id__in=service_ids)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'configs': ESP32ConfigSerializer(configs, many=True).data,
|
||||||
|
'courses': VCCourseSerializer(courses, many=True).data,
|
||||||
|
'services': ServiceSerializer(services, many=True).data
|
||||||
|
})
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="上传图片",
|
||||||
|
description="上传图片文件,返回图片URL",
|
||||||
|
request={
|
||||||
|
'multipart/form-data': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'file': {'type': 'string', 'format': 'binary'}
|
||||||
|
},
|
||||||
|
'required': ['file']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: OpenApiExample('成功', value={'url': 'http://.../media/uploads/xxx.jpg'})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@api_view(['POST'])
|
||||||
|
@parser_classes([MultiPartParser, FormParser])
|
||||||
|
def upload_image(request):
|
||||||
|
file_obj = request.FILES.get('file')
|
||||||
|
if not file_obj:
|
||||||
|
return Response({'error': 'No file uploaded'}, status=400)
|
||||||
|
|
||||||
|
# 验证文件类型
|
||||||
|
if not file_obj.content_type.startswith('image/'):
|
||||||
|
return Response({'error': 'File is not an image'}, status=400)
|
||||||
|
|
||||||
|
# 生成唯一文件名
|
||||||
|
ext = os.path.splitext(file_obj.name)[1]
|
||||||
|
filename = f"uploads/avatars/{uuid.uuid4()}{ext}"
|
||||||
|
|
||||||
|
# 保存文件
|
||||||
|
path = default_storage.save(filename, ContentFile(file_obj.read()))
|
||||||
|
|
||||||
|
# 获取完整URL
|
||||||
|
# 注意:如果使用了云存储,url会自动包含域名;如果是本地存储,可能需要手动拼接
|
||||||
|
file_url = default_storage.url(path)
|
||||||
|
|
||||||
|
# 确保 URL 是完整的 (如果是相对路径,拼接当前 host)
|
||||||
|
if not file_url.startswith('http'):
|
||||||
|
file_url = request.build_absolute_uri(file_url)
|
||||||
|
|
||||||
|
return Response({'url': file_url})
|
||||||
|
|
||||||
|
|||||||
BIN
backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG
Normal file
BIN
backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -38,7 +38,6 @@ export const sendSms = (data) => api.post('/auth/send-sms/', data);
|
|||||||
export const queryMyOrders = (data) => api.post('/orders/my_orders/', data);
|
export const queryMyOrders = (data) => api.post('/orders/my_orders/', data);
|
||||||
export const phoneLogin = (data) => api.post('/auth/phone-login/', data);
|
export const phoneLogin = (data) => api.post('/auth/phone-login/', data);
|
||||||
export const getUserInfo = () => {
|
export const getUserInfo = () => {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
// 如果没有获取用户信息的接口,可以暂时从本地解析或依赖 update_user_info 的返回
|
// 如果没有获取用户信息的接口,可以暂时从本地解析或依赖 update_user_info 的返回
|
||||||
// 但后端有 /wechat/update/ 可以返回用户信息,或者我们可以加一个 /auth/me/
|
// 但后端有 /wechat/update/ 可以返回用户信息,或者我们可以加一个 /auth/me/
|
||||||
// 目前 phone_login 返回了用户信息,前端可以保存。
|
// 目前 phone_login 返回了用户信息,前端可以保存。
|
||||||
@@ -47,22 +46,31 @@ export const getUserInfo = () => {
|
|||||||
return api.post('/wechat/update/', {});
|
return api.post('/wechat/update/', {});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Community / Forum API
|
export const updateUserInfo = (data) => api.post('/wechat/update/', data);
|
||||||
export const getTopics = (params) => api.get('/topics/', { params });
|
export const uploadUserAvatar = (data) => {
|
||||||
export const getTopicDetail = (id) => api.get(`/topics/${id}/`);
|
// 使用 axios 直接请求外部接口,避免 base URL 和拦截器干扰
|
||||||
export const createTopic = (data) => api.post('/topics/', data);
|
return axios.post('https://data.tangledup-ai.com/upload?folder=uploads/market/avator', data, {
|
||||||
export const getReplies = (params) => api.get('/replies/', { params });
|
|
||||||
export const createReply = (data) => api.post('/replies/', data);
|
|
||||||
export const uploadMedia = (data) => {
|
|
||||||
return api.post('/media/', data, {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// 获取明星技术用户 (目前暂无专门接口,通过 /wechat/login 返回的 token 获取当前用户信息,或者通过 filter 获取用户列表如果后端开放)
|
|
||||||
// 由于没有专门的用户列表接口,我们暂时不实现 getStarUsers API,或者在 ForumList 中模拟或请求特定的 Top 榜单接口。
|
// Community / Forum API
|
||||||
// 为了演示,我们假设后端开放一个 user list 接口,或者我们修改 Topic 列表返回 author_info 时前端自行筛选。
|
export const getTopics = (params) => api.get('/community/topics/', { params });
|
||||||
// 最好的方式是后端提供一个 star_users 接口。我们暂时跳过,只在 ForumList 中处理。
|
export const getTopicDetail = (id) => api.get(`/community/topics/${id}/`);
|
||||||
|
export const createTopic = (data) => api.post('/community/topics/', data);
|
||||||
|
export const getReplies = (params) => api.get('/community/replies/', { params });
|
||||||
|
export const createReply = (data) => api.post('/community/replies/', data);
|
||||||
|
export const uploadMedia = (data) => {
|
||||||
|
return api.post('/community/media/', data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const getStarUsers = () => api.get('/users/stars/');
|
||||||
|
export const getMyPaidItems = () => api.get('/users/paid-items/');
|
||||||
|
export const getAnnouncements = () => api.get('/community/announcements/');
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -1,19 +1,116 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Modal, Form, Input, Button, message, Upload, Select } from 'antd';
|
import { Modal, Form, Input, Button, message, Upload, Select, Divider, Radio, Tabs, Alert } from 'antd';
|
||||||
import { InboxOutlined } from '@ant-design/icons';
|
import { InboxOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import { createTopic } from '../api';
|
import { createTopic, uploadMedia, getMyPaidItems } from '../api';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||||
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 [uploading, setUploading] = useState(false);
|
||||||
|
const [mediaIds, setMediaIds] = useState([]);
|
||||||
|
const [mediaList, setMediaList] = useState([]); // Store uploaded media details for preview
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
fetchPaidItems();
|
||||||
|
setMediaIds([]); // Reset media IDs
|
||||||
|
setMediaList([]); // Reset media list
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const fetchPaidItems = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getMyPaidItems();
|
||||||
|
setPaidItems(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch paid items", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (file) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
// 默认为 image,如果需要支持视频需根据 file.type 判断
|
||||||
|
formData.append('media_type', file.type.startsWith('video') ? 'video' : 'image');
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await uploadMedia(formData);
|
||||||
|
// 记录上传的媒体 ID
|
||||||
|
if (res.data.id) {
|
||||||
|
setMediaIds(prev => [...prev, res.data.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 URL 是完整的
|
||||||
|
// 由于后端现在是转发到外部OSS,返回的URL通常是完整的,但也可能是相对的,这里统一处理
|
||||||
|
let url = res.data.file;
|
||||||
|
|
||||||
|
// 处理反斜杠问题(防止 Windows 路径风格影响 URL)
|
||||||
|
if (url) {
|
||||||
|
url = url.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url && !url.startsWith('http')) {
|
||||||
|
// 如果返回的是相对路径,拼接 API URL 或 Base URL
|
||||||
|
const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||||
|
// 移除 baseURL 末尾的 /api 或 /
|
||||||
|
const host = baseURL.replace(/\/api\/?$/, '');
|
||||||
|
// 确保 url 以 / 开头
|
||||||
|
if (!url.startsWith('/')) url = '/' + url;
|
||||||
|
url = `${host}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 URL 中的双斜杠 (除协议头外)
|
||||||
|
url = url.replace(/([^:]\/)\/+/g, '$1');
|
||||||
|
|
||||||
|
// Add to media list for preview
|
||||||
|
setMediaList(prev => [...prev, {
|
||||||
|
id: res.data.id,
|
||||||
|
url: url,
|
||||||
|
type: file.type.startsWith('video') ? 'video' : 'image',
|
||||||
|
name: file.name
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// 插入到编辑器
|
||||||
|
const currentContent = form.getFieldValue('content') || '';
|
||||||
|
const insertText = file.type.startsWith('video')
|
||||||
|
? `\n<video src="${url}" controls width="100%"></video>\n`
|
||||||
|
: `\n\n`;
|
||||||
|
|
||||||
|
form.setFieldsValue({
|
||||||
|
content: currentContent + insertText
|
||||||
|
});
|
||||||
|
message.success('上传成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error('上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
return false; // 阻止默认上传行为
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values) => {
|
const handleSubmit = async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await createTopic(values);
|
// 处理关联项目 ID (select value format: "type_id")
|
||||||
|
const relatedValue = values.related_item;
|
||||||
|
const payload = { ...values, media_ids: mediaIds };
|
||||||
|
delete payload.related_item;
|
||||||
|
|
||||||
|
if (relatedValue) {
|
||||||
|
const [type, id] = relatedValue.split('_');
|
||||||
|
if (type === 'config') payload.related_product = id;
|
||||||
|
if (type === 'course') payload.related_course = id;
|
||||||
|
if (type === 'service') payload.related_service = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createTopic(payload);
|
||||||
message.success('发布成功');
|
message.success('发布成功');
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
if (onSuccess) onSuccess();
|
if (onSuccess) onSuccess();
|
||||||
@@ -33,12 +130,13 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
|||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
|
width={800}
|
||||||
|
style={{ top: 20 }}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={handleSubmit}
|
onFinish={handleSubmit}
|
||||||
style={{ marginTop: 20 }}
|
|
||||||
initialValues={{ category: 'discussion' }}
|
initialValues={{ category: 'discussion' }}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -46,12 +144,14 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
|||||||
label="标题"
|
label="标题"
|
||||||
rules={[{ required: true, message: '请输入标题' }, { max: 100, message: '标题最多100字' }]}
|
rules={[{ required: true, message: '请输入标题' }, { max: 100, message: '标题最多100字' }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="请输入清晰的问题或讨论标题" />
|
<Input placeholder="请输入清晰的问题或讨论标题" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 20 }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="category"
|
name="category"
|
||||||
label="分类"
|
label="分类"
|
||||||
|
style={{ width: 200 }}
|
||||||
rules={[{ required: true, message: '请选择分类' }]}
|
rules={[{ required: true, message: '请选择分类' }]}
|
||||||
>
|
>
|
||||||
<Select>
|
<Select>
|
||||||
@@ -61,23 +161,83 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="related_item"
|
||||||
|
label="关联已购项目 (可选)"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
tooltip="关联已购项目可获得“认证用户”标识"
|
||||||
|
>
|
||||||
|
<Select placeholder="选择关联项目..." allowClear>
|
||||||
|
<Select.OptGroup label="硬件产品">
|
||||||
|
{paidItems.configs.map(i => (
|
||||||
|
<Option key={`config_${i.id}`} value={`config_${i.id}`}>{i.name}</Option>
|
||||||
|
))}
|
||||||
|
</Select.OptGroup>
|
||||||
|
<Select.OptGroup label="VC 课程">
|
||||||
|
{paidItems.courses.map(i => (
|
||||||
|
<Option key={`course_${i.id}`} value={`course_${i.id}`}>{i.title}</Option>
|
||||||
|
))}
|
||||||
|
</Select.OptGroup>
|
||||||
|
<Select.OptGroup label="AI 服务">
|
||||||
|
{paidItems.services.map(i => (
|
||||||
|
<Option key={`service_${i.id}`} value={`service_${i.id}`}>{i.title}</Option>
|
||||||
|
))}
|
||||||
|
</Select.OptGroup>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="content"
|
name="content"
|
||||||
label="内容"
|
label="内容 (支持 Markdown)"
|
||||||
rules={[{ required: true, message: '请输入内容' }]}
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={6}
|
rows={12}
|
||||||
placeholder="请详细描述您的问题,支持 Markdown 格式"
|
placeholder="请详细描述您的问题...
|
||||||
|
支持 Markdown 语法:
|
||||||
|
**加粗**
|
||||||
|
# 标题
|
||||||
|
- 列表
|
||||||
|
[链接](url)
|
||||||
|
"
|
||||||
showCount
|
showCount
|
||||||
maxLength={5000}
|
maxLength={10000}
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<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}>
|
<Button type="primary" htmlType="submit" loading={loading} size="large">
|
||||||
立即发布
|
立即发布
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RobotOutlined, MenuOutlined, AppstoreOutlined, EyeOutlined, SearchOutli
|
|||||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import ParticleBackground from './ParticleBackground';
|
import ParticleBackground from './ParticleBackground';
|
||||||
import LoginModal from './LoginModal';
|
import LoginModal from './LoginModal';
|
||||||
|
import ProfileModal from './ProfileModal';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const Layout = ({ children }) => {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [loginVisible, setLoginVisible] = useState(false);
|
const [loginVisible, setLoginVisible] = useState(false);
|
||||||
|
const [profileVisible, setProfileVisible] = useState(false);
|
||||||
|
|
||||||
const { user, login, logout } = useAuth();
|
const { user, login, logout } = useAuth();
|
||||||
|
|
||||||
@@ -34,6 +36,12 @@ const Layout = ({ children }) => {
|
|||||||
|
|
||||||
const userMenu = {
|
const userMenu = {
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
label: '个人设置',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
onClick: () => setProfileVisible(true)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'logout',
|
key: 'logout',
|
||||||
label: '退出登录',
|
label: '退出登录',
|
||||||
@@ -201,8 +209,16 @@ const Layout = ({ children }) => {
|
|||||||
<div style={{ padding: '20px', textAlign: 'center', borderBottom: '1px solid #333' }}>
|
<div style={{ padding: '20px', textAlign: 'center', borderBottom: '1px solid #333' }}>
|
||||||
{user ? (
|
{user ? (
|
||||||
<div style={{ color: '#fff' }}>
|
<div style={{ color: '#fff' }}>
|
||||||
<Avatar src={user.avatar_url} icon={<UserOutlined />} size="large" style={{ marginBottom: 10 }} />
|
<Avatar
|
||||||
<div>{user.nickname}</div>
|
src={user.avatar_url}
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
size="large"
|
||||||
|
style={{ marginBottom: 10, cursor: 'pointer' }}
|
||||||
|
onClick={() => { setProfileVisible(true); setMobileMenuOpen(false); }}
|
||||||
|
/>
|
||||||
|
<div onClick={() => { setProfileVisible(true); setMobileMenuOpen(false); }} style={{ cursor: 'pointer' }}>
|
||||||
|
{user.nickname}
|
||||||
|
</div>
|
||||||
<Button type="link" danger onClick={handleLogout} style={{ marginTop: 10 }}>退出登录</Button>
|
<Button type="link" danger onClick={handleLogout} style={{ marginTop: 10 }}>退出登录</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -225,6 +241,11 @@ const Layout = ({ children }) => {
|
|||||||
onLoginSuccess={(userData) => login(userData)}
|
onLoginSuccess={(userData) => login(userData)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProfileModal
|
||||||
|
visible={profileVisible}
|
||||||
|
onClose={() => setProfileVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Content style={{ marginTop: 72, padding: '40px 20px', overflowX: 'hidden' }}>
|
<Content style={{ marginTop: 72, padding: '40px 20px', overflowX: 'hidden' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
maxWidth: '1200px',
|
maxWidth: '1200px',
|
||||||
|
|||||||
124
frontend/src/components/ProfileModal.jsx
Normal file
124
frontend/src/components/ProfileModal.jsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal, Form, Input, Upload, Button, message, Avatar } from 'antd';
|
||||||
|
import { UserOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { updateUserInfo, uploadUserAvatar } from '../api';
|
||||||
|
|
||||||
|
const ProfileModal = ({ visible, onClose }) => {
|
||||||
|
const { user, updateUser } = useAuth();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && user) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
nickname: user.nickname,
|
||||||
|
});
|
||||||
|
setAvatarUrl(user.avatar_url);
|
||||||
|
}
|
||||||
|
}, [visible, user, form]);
|
||||||
|
|
||||||
|
const handleUpload = async (file) => {
|
||||||
|
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
|
||||||
|
if (!isJpgOrPng) {
|
||||||
|
message.error('You can only upload JPG/PNG file!');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||||
|
if (!isLt2M) {
|
||||||
|
message.error('Image must smaller than 2MB!');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await uploadUserAvatar(formData);
|
||||||
|
if (res.data.success) {
|
||||||
|
setAvatarUrl(res.data.file_url);
|
||||||
|
message.success('头像上传成功');
|
||||||
|
} else {
|
||||||
|
message.error('头像上传失败: ' + (res.data.message || '未知错误'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload failed:', error);
|
||||||
|
message.error('头像上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
return false; // Prevent default auto upload
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
nickname: values.nickname,
|
||||||
|
avatar_url: avatarUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await updateUserInfo(updateData);
|
||||||
|
updateUser(res.data);
|
||||||
|
message.success('个人信息更新成功');
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update failed:', error);
|
||||||
|
message.error('更新失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="个人设置"
|
||||||
|
open={visible}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={onClose}
|
||||||
|
confirmLoading={loading}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
>
|
||||||
|
<Form.Item label="头像" style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 15 }}>
|
||||||
|
<Avatar
|
||||||
|
size={100}
|
||||||
|
src={avatarUrl}
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
/>
|
||||||
|
<Upload
|
||||||
|
name="avatar"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={handleUpload}
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<Button icon={uploading ? <LoadingOutlined /> : <UploadOutlined />} loading={uploading}>
|
||||||
|
{uploading ? '上传中...' : '更换头像'}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="nickname"
|
||||||
|
label="昵称"
|
||||||
|
rules={[{ required: true, message: '请输入昵称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入昵称" maxLength={20} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileModal;
|
||||||
@@ -32,8 +32,12 @@ const ForumDetail = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasFetched = React.useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!hasFetched.current) {
|
||||||
fetchTopic();
|
fetchTopic();
|
||||||
|
hasFetched.current = true;
|
||||||
|
}
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const handleSubmitReply = async () => {
|
const handleSubmitReply = async () => {
|
||||||
@@ -122,8 +126,45 @@ const ForumDetail = () => {
|
|||||||
minHeight: 200,
|
minHeight: 200,
|
||||||
whiteSpace: 'pre-wrap' // Preserve formatting
|
whiteSpace: 'pre-wrap' // Preserve formatting
|
||||||
}}>
|
}}>
|
||||||
{topic.content}
|
{topic.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]')}
|
||||||
</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) {
|
||||||
|
return topic.media.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%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Replies List */}
|
{/* Replies List */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Typography, Input, Button, List, Tag, Avatar, Card, Space, Spin, messag
|
|||||||
import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined } from '@ant-design/icons';
|
import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { getTopics } from '../api';
|
import { getTopics, getStarUsers, getAnnouncements } from '../api';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import CreateTopicModal from '../components/CreateTopicModal';
|
import CreateTopicModal from '../components/CreateTopicModal';
|
||||||
import LoginModal from '../components/LoginModal';
|
import LoginModal from '../components/LoginModal';
|
||||||
@@ -16,6 +16,8 @@ const ForumList = () => {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [topics, setTopics] = useState([]);
|
const [topics, setTopics] = useState([]);
|
||||||
|
const [starUsers, setStarUsers] = useState([]);
|
||||||
|
const [announcements, setAnnouncements] = useState([]);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [category, setCategory] = useState('all');
|
const [category, setCategory] = useState('all');
|
||||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||||
@@ -38,8 +40,28 @@ const ForumList = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchStarUsers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getStarUsers();
|
||||||
|
setStarUsers(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fetch star users failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAnnouncements = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getAnnouncements();
|
||||||
|
setAnnouncements(res.data.results || res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fetch announcements failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTopics(searchText, category);
|
fetchTopics(searchText, category);
|
||||||
|
fetchStarUsers();
|
||||||
|
fetchAnnouncements();
|
||||||
}, [category]);
|
}, [category]);
|
||||||
|
|
||||||
const handleSearch = (value) => {
|
const handleSearch = (value) => {
|
||||||
@@ -176,9 +198,19 @@ const ForumList = () => {
|
|||||||
ellipsis={{ rows: 2 }}
|
ellipsis={{ rows: 2 }}
|
||||||
style={{ color: '#aaa', marginBottom: 12, fontSize: 14 }}
|
style={{ color: '#aaa', marginBottom: 12, fontSize: 14 }}
|
||||||
>
|
>
|
||||||
{item.content.replace(/[#*`]/g, '')} {/* Simple markdown strip */}
|
{item.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]').replace(/[#*`]/g, '')} {/* Simple markdown strip */}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
|
{item.content.match(/!\[.*?\]\((.*?)\)/) && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<img
|
||||||
|
src={item.content.match(/!\[.*?\]\((.*?)\)/)[1]}
|
||||||
|
alt="cover"
|
||||||
|
style={{ maxHeight: 150, borderRadius: 8, maxWidth: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Space size="middle" style={{ color: '#666', fontSize: 13 }}>
|
<Space size="middle" style={{ color: '#666', fontSize: 13 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<Avatar src={item.author_info?.avatar_url} icon={<UserOutlined />} size="small" />
|
<Avatar src={item.author_info?.avatar_url} icon={<UserOutlined />} size="small" />
|
||||||
@@ -224,21 +256,21 @@ const ForumList = () => {
|
|||||||
headStyle={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}
|
headStyle={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}
|
||||||
>
|
>
|
||||||
<div style={{ textAlign: 'center', padding: '20px 0', color: '#666' }}>
|
<div style={{ textAlign: 'center', padding: '20px 0', color: '#666' }}>
|
||||||
{/* 这里可以通过 API 获取专家列表,目前先做静态展示或从帖子中提取 */}
|
{starUsers.length > 0 ? (
|
||||||
<div style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
|
starUsers.map(u => (
|
||||||
<Avatar size="large" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Expert1" />
|
<div key={u.id} style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Avatar size="large" src={u.avatar_url} icon={<UserOutlined />} />
|
||||||
<div style={{ textAlign: 'left' }}>
|
<div style={{ textAlign: 'left' }}>
|
||||||
<div style={{ color: '#fff', fontWeight: 'bold' }}>QuantMaster <StarFilled style={{ color: '#ffd700', fontSize: 12 }} /></div>
|
<div style={{ color: '#fff', fontWeight: 'bold' }}>
|
||||||
<div style={{ color: '#666', fontSize: 12 }}>官方技术支持</div>
|
{u.nickname} <StarFilled style={{ color: '#ffd700', fontSize: 12 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style={{ color: '#666', fontSize: 12 }}>{u.title || '技术专家'}</div>
|
||||||
<div style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<Avatar size="large" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Expert2" />
|
|
||||||
<div style={{ textAlign: 'left' }}>
|
|
||||||
<div style={{ color: '#fff', fontWeight: 'bold' }}>AI_Wizard <StarFilled style={{ color: '#ffd700', fontSize: 12 }} /></div>
|
|
||||||
<div style={{ color: '#666', fontSize: 12 }}>社区贡献者</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ color: '#888' }}>暂无上榜专家</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -249,12 +281,27 @@ const ForumList = () => {
|
|||||||
>
|
>
|
||||||
<List
|
<List
|
||||||
size="small"
|
size="small"
|
||||||
dataSource={[
|
dataSource={announcements}
|
||||||
'欢迎来到 Quant Speed 开发者社区',
|
renderItem={item => (
|
||||||
'发帖前请阅读社区规范',
|
<List.Item style={{ padding: '12px 0', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'block' }}>
|
||||||
'如何获取“认证用户”标识?'
|
{item.display_image_url && (
|
||||||
]}
|
<div style={{ marginBottom: 8 }}>
|
||||||
renderItem={item => <List.Item style={{ color: '#aaa', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>{item}</List.Item>}
|
<img src={item.display_image_url} alt={item.title} style={{ width: '100%', borderRadius: 4 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ color: '#fff', marginBottom: 4, fontWeight: 'bold' }}>
|
||||||
|
{item.link_url ? (
|
||||||
|
<a href={item.link_url} target="_blank" rel="noopener noreferrer" style={{ color: '#fff' }}>{item.title}</a>
|
||||||
|
) : (
|
||||||
|
<span>{item.title}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#888', fontSize: 12 }}>
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
locale={{ emptyText: <div style={{ color: '#666', padding: '20px 0', textAlign: 'center' }}>暂无公告</div> }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
Reference in New Issue
Block a user