This commit is contained in:
jeremygan2021
2026-02-12 15:02:53 +08:00
parent b4ac97c3c2
commit 9e81eaaaab
23 changed files with 844 additions and 104 deletions

View File

@@ -2,7 +2,7 @@ from django.contrib import admin
from django.utils.html import format_html
from unfold.admin import ModelAdmin, TabularInline
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):
model = ActivitySignup
@@ -24,7 +24,7 @@ class ReplyInline(TabularInline):
class TopicMediaInline(TabularInline):
model = TopicMedia
extra = 0
fields = ('file', 'media_type', 'created_at')
fields = ('file', 'file_url', 'media_type', 'created_at')
readonly_fields = ('created_at',)
can_delete = True
@@ -91,15 +91,15 @@ class ActivitySignupAdmin(ModelAdmin):
@admin.register(Topic)
class TopicAdmin(ModelAdmin):
list_display = ('title', '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_display = ('title', 'category', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at')
list_filter = ('category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
search_fields = ('title', 'content', 'author__nickname')
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
inlines = [TopicMediaInline, ReplyInline]
fieldsets = (
('帖子内容', {
'fields': ('title', 'content', 'is_pinned')
'fields': ('title', 'category', 'content', 'is_pinned')
}),
('关联信息', {
'fields': ('author', 'related_product', 'related_service', 'related_course'),
@@ -157,6 +157,63 @@ class TopicMediaAdmin(ModelAdmin):
@display(description="预览")
def file_preview(self, obj):
if obj.media_type == 'image':
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.file.url)
return obj.file.name
url = ""
if obj.file:
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

View File

@@ -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='文件'),
),
]

View 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'],
},
),
]

View File

@@ -121,15 +121,6 @@ class Topic(models.Model):
# 3. 验证服务
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
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)
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="媒体类型")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
@@ -181,3 +173,38 @@ class TopicMedia(models.Model):
class Meta:
verbose_name = "论坛媒体资源"
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']

View File

@@ -1,5 +1,5 @@
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
class ActivitySerializer(serializers.ModelSerializer):
@@ -16,9 +16,16 @@ class ActivitySignupSerializer(serializers.ModelSerializer):
read_only_fields = ['signup_time', 'status']
class TopicMediaSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField()
class Meta:
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):
author_info = WeChatUserSerializer(source='author', read_only=True)
@@ -27,6 +34,7 @@ class ReplySerializer(serializers.ModelSerializer):
class Meta:
model = Reply
fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at']
read_only_fields = ['author', 'created_at']
class TopicSerializer(serializers.ModelSerializer):
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)
course_info = VCCourseSerializer(source='related_course', read_only=True)
media_ids = serializers.ListField(
child=serializers.IntegerField(),
write_only=True,
required=False
)
class Meta:
model = Topic
fields = [
@@ -46,6 +60,20 @@ class TopicSerializer(serializers.ModelSerializer):
'related_service', 'service_info',
'related_course', 'course_info',
'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__'

View File

@@ -1,12 +1,13 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet
from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet, AnnouncementViewSet
router = DefaultRouter()
router.register(r'activities', ActivityViewSet)
router.register(r'topics', TopicViewSet)
router.register(r'replies', ReplyViewSet)
router.register(r'media', TopicMediaViewSet)
router.register(r'media', TopicMediaViewSet, basename='media')
router.register(r'announcements', AnnouncementViewSet)
urlpatterns = [
path('', include(router.urls)),

View File

@@ -2,13 +2,15 @@ from rest_framework import viewsets, status, mixins, parsers, filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.decorators import action
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.utils import timezone
from django.db import models
from drf_spectacular.utils import extend_schema
from shop.models import WeChatUser
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer
def get_current_wechat_user(request):
"""
@@ -87,6 +89,13 @@ class TopicViewSet(viewsets.ModelViewSet):
return Response({'error': '请先登录'}, status=401)
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):
"""
帖子回复接口
@@ -105,12 +114,13 @@ class ReplyViewSet(viewsets.ModelViewSet):
return Response({'error': '请先登录'}, status=401)
return super().create(request, *args, **kwargs)
class TopicMediaViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin):
import requests
class TopicMediaViewSet(viewsets.ViewSet):
"""
论坛多媒体资源上传接口
论坛多媒体资源上传接口 (代理到外部OSS服务)
"""
queryset = TopicMedia.objects.all()
serializer_class = TopicMediaSerializer
permission_classes = [] # 内部鉴权
parser_classes = [parsers.MultiPartParser, parsers.FormParser]
@extend_schema(summary="上传媒体文件 (返回URL用于Markdown)")
@@ -119,6 +129,57 @@ class TopicMediaViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin):
if not user:
return Response({'error': '请先登录'}, status=401)
# 允许上传时不关联 Topic (发帖前上传),或后续关联
# 主要是返回 url
return super().create(request, *args, **kwargs)
file_obj = request.FILES.get('file')
if not file_obj:
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-Typerequests 会自动设置 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')

View File

@@ -279,6 +279,11 @@ UNFOLD = {
"icon": "how_to_reg",
"link": reverse_lazy("admin:community_activitysignup_changelist"),
},
{
"title": "社区公告",
"icon": "campaign",
"link": reverse_lazy("admin:community_announcement_changelist"),
},
{
"title": "技术论坛帖子",
"icon": "forum",

Binary file not shown.

View File

@@ -311,9 +311,9 @@ class OrderAdmin(ModelAdmin):
@admin.register(WeChatUser)
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')
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')
def avatar_display(self, obj):
@@ -331,6 +331,10 @@ class WeChatUserAdmin(ModelAdmin):
('基本信息', {
'fields': ('user', 'nickname', 'avatar_url', 'gender')
}),
('专家认证', {
'fields': ('is_star', 'title'),
'description': '标记为明星技术用户/专家,将在社区中展示'
}),
('位置信息', {
'fields': ('country', 'province', 'city')
}),

View File

@@ -4,7 +4,7 @@ from .views import (
ESP32ConfigViewSet, OrderViewSet, order_check_view,
ServiceViewSet, VCCourseViewSet, ServiceOrderViewSet,
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()
@@ -15,6 +15,7 @@ router.register(r'courses', VCCourseViewSet)
router.register(r'course-enrollments', CourseEnrollmentViewSet)
router.register(r'service-orders', ServiceOrderViewSet)
router.register(r'distributor', DistributorViewSet, basename='distributor')
router.register(r'users', WeChatUserViewSet)
urlpatterns = [
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/bind-phone/', bind_phone, name='bind-phone'),
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('', include(router.urls)),
]

View File

@@ -1,6 +1,9 @@
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 django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
@@ -1309,3 +1312,95 @@ class DistributorViewSet(viewsets.GenericViewSet):
serializer = OrderSerializer(orders, many=True)
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})

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -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 phoneLogin = (data) => api.post('/auth/phone-login/', data);
export const getUserInfo = () => {
const token = localStorage.getItem('token');
// 如果没有获取用户信息的接口,可以暂时从本地解析或依赖 update_user_info 的返回
// 但后端有 /wechat/update/ 可以返回用户信息,或者我们可以加一个 /auth/me/
// 目前 phone_login 返回了用户信息,前端可以保存。
@@ -47,22 +46,31 @@ export const getUserInfo = () => {
return api.post('/wechat/update/', {});
};
// Community / Forum API
export const getTopics = (params) => api.get('/topics/', { params });
export const getTopicDetail = (id) => api.get(`/topics/${id}/`);
export const createTopic = (data) => api.post('/topics/', 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, {
export const updateUserInfo = (data) => api.post('/wechat/update/', data);
export const uploadUserAvatar = (data) => {
// 使用 axios 直接请求外部接口,避免 base URL 和拦截器干扰
return axios.post('https://data.tangledup-ai.com/upload?folder=uploads/market/avator', data, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
};
// 获取明星技术用户 (目前暂无专门接口,通过 /wechat/login 返回的 token 获取当前用户信息,或者通过 filter 获取用户列表如果后端开放)
// 由于没有专门的用户列表接口,我们暂时不实现 getStarUsers API或者在 ForumList 中模拟或请求特定的 Top 榜单接口。
// 为了演示,我们假设后端开放一个 user list 接口,或者我们修改 Topic 列表返回 author_info 时前端自行筛选。
// 最好的方式是后端提供一个 star_users 接口。我们暂时跳过,只在 ForumList 中处理。
// Community / Forum API
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 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;

View File

@@ -1,19 +1,116 @@
import React, { useState } from 'react';
import { Modal, Form, Input, Button, message, Upload, Select } from 'antd';
import { InboxOutlined } from '@ant-design/icons';
import { createTopic } from '../api';
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';
const { TextArea } = Input;
const { Option } = Select;
const { Dragger } = Upload;
const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
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([]);
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![${file.name}](${url})\n`;
form.setFieldsValue({
content: currentContent + insertText
});
message.success('上传成功');
} catch (error) {
console.error(error);
message.error('上传失败');
} finally {
setUploading(false);
}
return false; // 阻止默认上传行为
};
const handleSubmit = async (values) => {
setLoading(true);
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('发布成功');
form.resetFields();
if (onSuccess) onSuccess();
@@ -33,12 +130,13 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
onCancel={onClose}
footer={null}
destroyOnClose
width={800}
style={{ top: 20 }}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
style={{ marginTop: 20 }}
initialValues={{ category: 'discussion' }}
>
<Form.Item
@@ -46,38 +144,100 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
label="标题"
rules={[{ required: true, message: '请输入标题' }, { max: 100, message: '标题最多100字' }]}
>
<Input placeholder="请输入清晰的问题或讨论标题" />
<Input placeholder="请输入清晰的问题或讨论标题" size="large" />
</Form.Item>
<Form.Item
name="category"
label="分类"
rules={[{ required: true, message: '请选择分类' }]}
>
<Select>
<Option value="discussion">技术讨论</Option>
<Option value="help">求助问答</Option>
<Option value="share">经验分享</Option>
</Select>
</Form.Item>
<div style={{ display: 'flex', gap: 20 }}>
<Form.Item
name="category"
label="分类"
style={{ width: 200 }}
rules={[{ required: true, message: '请选择分类' }]}
>
<Select>
<Option value="discussion">技术讨论</Option>
<Option value="help">求助问答</Option>
<Option value="share">经验分享</Option>
</Select>
</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
name="content"
label="内容"
label="内容 (支持 Markdown)"
rules={[{ required: true, message: '请输入内容' }]}
>
<TextArea
rows={6}
placeholder="请详细描述您的问题,支持 Markdown 格式"
showCount
maxLength={5000}
/>
<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
rows={12}
placeholder="请详细描述您的问题...
支持 Markdown 语法:
**加粗**
# 标题
- 列表
[链接](url)
"
showCount
maxLength={10000}
style={{ fontFamily: 'monospace' }}
/>
</div>
</Form.Item>
<Form.Item>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit" loading={loading}>
<Button type="primary" htmlType="submit" loading={loading} size="large">
立即发布
</Button>
</div>

View File

@@ -4,6 +4,7 @@ import { RobotOutlined, MenuOutlined, AppstoreOutlined, EyeOutlined, SearchOutli
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import ParticleBackground from './ParticleBackground';
import LoginModal from './LoginModal';
import ProfileModal from './ProfileModal';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../context/AuthContext';
@@ -15,6 +16,7 @@ const Layout = ({ children }) => {
const [searchParams] = useSearchParams();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [loginVisible, setLoginVisible] = useState(false);
const [profileVisible, setProfileVisible] = useState(false);
const { user, login, logout } = useAuth();
@@ -34,6 +36,12 @@ const Layout = ({ children }) => {
const userMenu = {
items: [
{
key: 'profile',
label: '个人设置',
icon: <UserOutlined />,
onClick: () => setProfileVisible(true)
},
{
key: 'logout',
label: '退出登录',
@@ -201,8 +209,16 @@ const Layout = ({ children }) => {
<div style={{ padding: '20px', textAlign: 'center', borderBottom: '1px solid #333' }}>
{user ? (
<div style={{ color: '#fff' }}>
<Avatar src={user.avatar_url} icon={<UserOutlined />} size="large" style={{ marginBottom: 10 }} />
<div>{user.nickname}</div>
<Avatar
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>
</div>
) : (
@@ -225,6 +241,11 @@ const Layout = ({ children }) => {
onLoginSuccess={(userData) => login(userData)}
/>
<ProfileModal
visible={profileVisible}
onClose={() => setProfileVisible(false)}
/>
<Content style={{ marginTop: 72, padding: '40px 20px', overflowX: 'hidden' }}>
<div style={{
maxWidth: '1200px',

View 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;

View File

@@ -32,8 +32,12 @@ const ForumDetail = () => {
}
};
const hasFetched = React.useRef(false);
useEffect(() => {
fetchTopic();
if (!hasFetched.current) {
fetchTopic();
hasFetched.current = true;
}
}, [id]);
const handleSubmitReply = async () => {
@@ -122,8 +126,45 @@ const ForumDetail = () => {
minHeight: 200,
whiteSpace: 'pre-wrap' // Preserve formatting
}}>
{topic.content}
{topic.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]')}
</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>
{/* Replies List */}

View File

@@ -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 { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { getTopics } from '../api';
import { getTopics, getStarUsers, getAnnouncements } from '../api';
import { useAuth } from '../context/AuthContext';
import CreateTopicModal from '../components/CreateTopicModal';
import LoginModal from '../components/LoginModal';
@@ -16,6 +16,8 @@ const ForumList = () => {
const [loading, setLoading] = useState(true);
const [topics, setTopics] = useState([]);
const [starUsers, setStarUsers] = useState([]);
const [announcements, setAnnouncements] = useState([]);
const [searchText, setSearchText] = useState('');
const [category, setCategory] = useState('all');
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(() => {
fetchTopics(searchText, category);
fetchStarUsers();
fetchAnnouncements();
}, [category]);
const handleSearch = (value) => {
@@ -176,8 +198,18 @@ const ForumList = () => {
ellipsis={{ rows: 2 }}
style={{ color: '#aaa', marginBottom: 12, fontSize: 14 }}
>
{item.content.replace(/[#*`]/g, '')} {/* Simple markdown strip */}
{item.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]').replace(/[#*`]/g, '')} {/* Simple markdown strip */}
</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>
@@ -224,21 +256,21 @@ const ForumList = () => {
headStyle={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}
>
<div style={{ textAlign: 'center', padding: '20px 0', color: '#666' }}>
{/* 这里可以通过 API 获取专家列表,目前先做静态展示或从帖子中提取 */}
<div style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar size="large" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Expert1" />
<div style={{ textAlign: 'left' }}>
<div style={{ color: '#fff', fontWeight: 'bold' }}>QuantMaster <StarFilled style={{ color: '#ffd700', fontSize: 12 }} /></div>
<div style={{ color: '#666', fontSize: 12 }}>官方技术支持</div>
</div>
</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>
{starUsers.length > 0 ? (
starUsers.map(u => (
<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={{ color: '#fff', fontWeight: 'bold' }}>
{u.nickname} <StarFilled style={{ color: '#ffd700', fontSize: 12 }} />
</div>
<div style={{ color: '#666', fontSize: 12 }}>{u.title || '技术专家'}</div>
</div>
</div>
))
) : (
<div style={{ color: '#888' }}>暂无上榜专家</div>
)}
</div>
</Card>
@@ -249,12 +281,27 @@ const ForumList = () => {
>
<List
size="small"
dataSource={[
'欢迎来到 Quant Speed 开发者社区',
'发帖前请阅读社区规范',
'如何获取“认证用户”标识?'
]}
renderItem={item => <List.Item style={{ color: '#aaa', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>{item}</List.Item>}
dataSource={announcements}
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 }}>
<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>
</Col>