forum
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-12 06:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('community', '0005_topic_category'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='topicmedia',
|
||||
name='file_url',
|
||||
field=models.URLField(blank=True, max_length=500, null=True, verbose_name='文件链接'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='topicmedia',
|
||||
name='file',
|
||||
field=models.FileField(blank=True, null=True, upload_to='community/media/', verbose_name='文件'),
|
||||
),
|
||||
]
|
||||
36
backend/community/migrations/0007_announcement.py
Normal file
36
backend/community/migrations/0007_announcement.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-12 06:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('community', '0006_topicmedia_file_url_alter_topicmedia_file'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Announcement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='公告标题')),
|
||||
('content', models.TextField(verbose_name='公告内容')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='announcements/', verbose_name='公告图片')),
|
||||
('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片链接')),
|
||||
('link_url', models.URLField(blank=True, null=True, verbose_name='跳转链接')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||
('is_pinned', models.BooleanField(default=False, verbose_name='是否置顶')),
|
||||
('priority', models.IntegerField(default=0, help_text='数字越大越靠前', verbose_name='排序权重')),
|
||||
('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始展示时间')),
|
||||
('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束展示时间')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '社区公告',
|
||||
'verbose_name_plural': '社区公告管理',
|
||||
'ordering': ['-is_pinned', '-priority', '-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -121,15 +121,6 @@ class Topic(models.Model):
|
||||
|
||||
# 3. 验证服务
|
||||
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']
|
||||
|
||||
@@ -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__'
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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')
|
||||
|
||||
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||
}),
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
BIN
backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG
Normal file
BIN
backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Reference in New Issue
Block a user