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