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![${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 }} >
{ label="标题" rules={[{ required: true, message: '请输入标题' }, { max: 100, message: '标题最多100字' }]} > - + - - - +
+ + + + + + + +
-