diff --git a/backend/community/admin.py b/backend/community/admin.py
index 1a6d3af..cc7fb12 100644
--- a/backend/community/admin.py
+++ b/backend/community/admin.py
@@ -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('', 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('
', 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('
', 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
diff --git a/backend/community/migrations/0006_topicmedia_file_url_alter_topicmedia_file.py b/backend/community/migrations/0006_topicmedia_file_url_alter_topicmedia_file.py
new file mode 100644
index 0000000..606c557
--- /dev/null
+++ b/backend/community/migrations/0006_topicmedia_file_url_alter_topicmedia_file.py
@@ -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='文件'),
+ ),
+ ]
diff --git a/backend/community/migrations/0007_announcement.py b/backend/community/migrations/0007_announcement.py
new file mode 100644
index 0000000..5b97855
--- /dev/null
+++ b/backend/community/migrations/0007_announcement.py
@@ -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'],
+ },
+ ),
+ ]
diff --git a/backend/community/models.py b/backend/community/models.py
index bbdd21d..64bc2b6 100644
--- a/backend/community/models.py
+++ b/backend/community/models.py
@@ -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']
diff --git a/backend/community/serializers.py b/backend/community/serializers.py
index 78d4eba..f16dd5d 100644
--- a/backend/community/serializers.py
+++ b/backend/community/serializers.py
@@ -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__'
diff --git a/backend/community/urls.py b/backend/community/urls.py
index dc2d25f..13e12cd 100644
--- a/backend/community/urls.py
+++ b/backend/community/urls.py
@@ -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)),
diff --git a/backend/community/views.py b/backend/community/views.py
index d23380d..004b4df 100644
--- a/backend/community/views.py
+++ b/backend/community/views.py
@@ -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-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')
diff --git a/backend/config/__pycache__/settings.cpython-312.pyc b/backend/config/__pycache__/settings.cpython-312.pyc
index 43a7600..95c1aec 100644
Binary files a/backend/config/__pycache__/settings.cpython-312.pyc and b/backend/config/__pycache__/settings.cpython-312.pyc differ
diff --git a/backend/config/settings.py b/backend/config/settings.py
index a86129e..4306e8b 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -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",
diff --git a/backend/db.sqlite3 b/backend/db.sqlite3
index 974d747..534af8c 100644
Binary files a/backend/db.sqlite3 and b/backend/db.sqlite3 differ
diff --git a/backend/shop/__pycache__/admin.cpython-312.pyc b/backend/shop/__pycache__/admin.cpython-312.pyc
index 8285aa9..5bcffbb 100644
Binary files a/backend/shop/__pycache__/admin.cpython-312.pyc and b/backend/shop/__pycache__/admin.cpython-312.pyc differ
diff --git a/backend/shop/__pycache__/urls.cpython-312.pyc b/backend/shop/__pycache__/urls.cpython-312.pyc
index 395bf69..ffa483a 100644
Binary files a/backend/shop/__pycache__/urls.cpython-312.pyc and b/backend/shop/__pycache__/urls.cpython-312.pyc differ
diff --git a/backend/shop/__pycache__/views.cpython-312.pyc b/backend/shop/__pycache__/views.cpython-312.pyc
index 603a911..2424f20 100644
Binary files a/backend/shop/__pycache__/views.cpython-312.pyc and b/backend/shop/__pycache__/views.cpython-312.pyc differ
diff --git a/backend/shop/admin.py b/backend/shop/admin.py
index 9117603..624e39c 100644
--- a/backend/shop/admin.py
+++ b/backend/shop/admin.py
@@ -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')
}),
diff --git a/backend/shop/urls.py b/backend/shop/urls.py
index 43b9cf1..3b32104 100644
--- a/backend/shop/urls.py
+++ b/backend/shop/urls.py
@@ -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)),
]
diff --git a/backend/shop/views.py b/backend/shop/views.py
index 10cf409..07e8330 100644
--- a/backend/shop/views.py
+++ b/backend/shop/views.py
@@ -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})
+
diff --git a/backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG b/backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG
new file mode 100644
index 0000000..c44d06e
Binary files /dev/null and b/backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG differ
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 3892199..8f90659 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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;
diff --git a/frontend/src/components/CreateTopicModal.jsx b/frontend/src/components/CreateTopicModal.jsx
index 1a8d36d..f4abaf5 100644
--- a/frontend/src/components/CreateTopicModal.jsx
+++ b/frontend/src/components/CreateTopicModal.jsx
@@ -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\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) => {
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 }}
>