From 9e81eaaaab64e1ef15cd53637f69e322cf252267 Mon Sep 17 00:00:00 2001 From: jeremygan2021 Date: Thu, 12 Feb 2026 15:02:53 +0800 Subject: [PATCH] forum --- backend/community/admin.py | 73 +++++- ...picmedia_file_url_alter_topicmedia_file.py | 23 ++ .../community/migrations/0007_announcement.py | 36 +++ backend/community/models.py | 47 +++- backend/community/serializers.py | 36 ++- backend/community/urls.py | 5 +- backend/community/views.py | 81 ++++++- .../__pycache__/settings.cpython-312.pyc | Bin 6264 -> 6377 bytes backend/config/settings.py | 5 + backend/db.sqlite3 | Bin 397312 -> 397312 bytes .../shop/__pycache__/admin.cpython-312.pyc | Bin 16615 -> 16764 bytes backend/shop/__pycache__/urls.cpython-312.pyc | Bin 1848 -> 2018 bytes .../shop/__pycache__/views.cpython-312.pyc | Bin 57889 -> 62472 bytes backend/shop/admin.py | 8 +- backend/shop/urls.py | 4 +- backend/shop/views.py | 97 +++++++- .../474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG | Bin 0 -> 36020 bytes frontend/src/api.js | 34 +-- frontend/src/components/CreateTopicModal.jsx | 212 +++++++++++++++--- frontend/src/components/Layout.jsx | 25 ++- frontend/src/components/ProfileModal.jsx | 124 ++++++++++ frontend/src/pages/ForumDetail.jsx | 45 +++- frontend/src/pages/ForumList.jsx | 93 ++++++-- 23 files changed, 844 insertions(+), 104 deletions(-) create mode 100644 backend/community/migrations/0006_topicmedia_file_url_alter_topicmedia_file.py create mode 100644 backend/community/migrations/0007_announcement.py create mode 100644 backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG create mode 100644 frontend/src/components/ProfileModal.jsx 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 43a7600410e5fa71b4eb7f88669e4d4ee302f5a7..95c1aec154f35a8b8c960d868843e6702f5c97ab 100644 GIT binary patch delta 339 zcmexi@X}EIG%qg~0}$-2>dnm6U|@I*;=q6il=0bRqxw;%`jpI+EGC9jxs>eHj1ZX= z+d0hXtSK&Ym{W36SW|LS^1$->$nq%#DTQFsB4p8YCXgr}%;3zkZtNdUeKZ9M&lL6xJw(l%f=il;V_h4TjObu)w!X~ekuwbbG GY6SpcXFU`E 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 974d747cfcedc67fbeeda3a96786914d041d813e..534af8ca6635f206162bdce44c80b105e1fd49bd 100644 GIT binary patch delta 3526 zcmb7H3v5%@8NTP<sp-V-M=d3X>f8CxM>-}l39fv8hiw-gu>f%2&9*f+$9^B{4c zEvpg_3h6^_f?F<7w82QBlW3t$6$2H7PDNTftt!(Vq8chyF-ayE)zo!DRNcS;wfB)Niu(bDJX@7Yks38a_7j7Ml89?by1;H*k!UE`7>yah?#@^+6pI_3Q)eb{izk7FfF${JUJ!Y4 zsK>RPb*v|fSXYwv&UHS_9$@3_Le@3Jd%s}3Wjv)jCmqgmwv?k}U>-7IaXBoip=!Y0 zWwv9G?IX$tYTfr0`ly-=j83B?_7r=QD8^~a`Xmqdb^v#>(jNt}OCazA&z7jMH7lf883ZEmM2wgbH?n40CT7A-p|(xgGtILhSOh$wNF+-k zI#jpjNDKa*eTpaAg1Y}sPVW?M#w-n_Kt*FHDeL7;ch zKhO>IXLJEad37tnH=m6S-`qA^~E%XNZCE79MTDl4?u=U-RbQRmO$d-k+ zbXl^jz?R5->o};Vunm(w0p_qa$Q{TGFv{`FA3g_5}R0X`-o_S`Yhsr!GYJd~)&nKyxE~$}XtX~GU zVSQnI@5tyYd(9_41#=#e3|`#OEjKhMSn8s*pjVu`_D8u>ngX(uk!j zXdAi}K~!hL7F8e5tEws{2p0yg00}E!!s;%3{3S`!?2sQ<6ak~jl7K^26wa_Dk4F?i z2;Bon|ZRD$s3dYqnRjJx&UbqN~%k_Z7`81W2 zWN|f`Mne`j?2j5&apr7|S!zI*e4MHYy0&ZhB1jEiBv;nw)D!4YwjPt>N9=+`oGc`2 z2&ODyI2neitp(N26P_YIT;YTJ7VpjNSD7g)Y)Rh{h=+PczdVc zA`K_-LXcA=UEqT%{wIm5;Ol7HWRgp$>ctbK_xASlq9#$926nwXHgc-=k#yf{>7y@P zIepqccINo#p<`Fx8%_@%9ew@h=A%ofRK8XE(3_}^O|yOYd_KwctcMKYt^~Q*ZCvvz zaqi>@_BOrurLp7hlJ`(q(0#h7$tpL~j4qLpXjizqt1H^xW>5@Kq22*^Pac`E-WRV9 zAG!M8z}232PKCT(-iUXTw<#|ZQ^6b!@Ul)kbDy6rYu3zqE^EcYBjcZcDDv? z)eL^&y%VG$AL5io4c7t8uS6MT8zQDNWIyg@&4+hglZ5}njv~vl7L0`SkgP}42|MI} zv?E#PSEa(w~Q)SD}dXQ`7jUc-3Nt+T7ar2`3FsBq%oqQ1Z z?PkT_AT{U>5*N~WUe|QlEk$HW;lf5pY7jKOL2C4^T)u4h1O-pzLv)=$-=Ke@zoYAT zb1xwTDFQ%CP?jK5-?T%A*Fz5wx#UfPt!8%Rf0a+qT9%h|Tp({+-_&>dVK)ugQxu6e z{97`W+z&T9asc{>Kp$bub4ZZ@P4M_+I@4#8>6ZsaU-<<-L!-xzr2G3a$)E%TEx<`W zQRH}0C*L^-3+{EWdWwS-MK~vDIwg{#X%-$?NfivAgWpzAPG{Z_xco2w@KOgX@UqO) z1o}OGR&PgZ(IS+?{)s)vHnSR=&wR?f!90xvt7m!^#vBCjtM~Rb4~5#iRa`jU;Dw4egu01Cl@OungK;-Y9_4zC@?`L$H zew{YZIm9ttIcIZ9@Ra=_e|4Dm!UwlU6DnPz>tJVVJk$^~CXY>4V>$ikW9IcLSel>N zX_*Nx{DJwvPSRzri%_NJNQCm3pGT+)7>l;GnD4(tmM5E>9+RnrvzfZOh2E-;&S-ll zelIasgekXa?t!z-xA(wu^KdiukYj$LI-!_f?t_(P^FBBatTp@h!61;Wp@+d5>tD6G zZa>ti2NS&6-%CD_8caEMI!NNj_~mEWtnGS2qDy2M>~xz;y|CCkVZt(SzwNiVGzlGM z?|$eszce9lF4+senu{H}^Hx*k-4g1=?CQKv=D7LZ<*V!J%(;7EiP`@g=}7S-jz4;H nzP5PqHQwL9B8SBRDs}6IBfr2+;uNpL*ZSL+b^jP%b_M+#?$GdS delta 890 zcmYLHTWC~A7@j#Zvy;8ezni2JKBtgWm~&e=<{Ce>^n)IKQY!5Xz-5|S-l(L|es zUMRTRv?>-#^ne2)4-4J}lhjkr(SjxvgccFB6pIfbTT-P3TYZQRUUA}u%<#d_H{bvN z-~2P#d_^{2F|xE6%^`%kUxOlqy+>9>H8l5Q*9jm|yx-!XwB2TuV0(XfSg=PZBo0|z zU&aoJG0|s-vpYp;afC#?J^g$34E1FOzw8|B-<#>N#_GSt!m4e^;7_gZ8$QHKtv?zf zZ$*6~hn55j2>ypzxCWQST2X74852P(8>+SoYnB(2UcS-d?Z2t6UwiD09T&eKy}|Pq zzp=o~1nl!O9lz)T#u5Ak58*!i2{%=tOE3pd;9r=5t1t!Q%GhC$;}du*QBuh#arJ9z zN>NLKvyxBam>upq8FXi_<99S7ypH5Il&sA->OJV(#;$W)Yv1Y_MX*VXs^lL8$r+k# zZzbg=$wV??Hpb)eqd8S2x9DOsz5`hx=6~_vjtG|)Gs`sq0@Almy-{7Y0?6St;Hw z|9hKO$dpCdOLEd-)LIuX$tRiJ(mbG5N^*tGm#q|+P*}NMT)Dd3GFj=c_4s3X{byGF zGP}iL#qyZLBDi|ttX8gaSy;|Hs?m-`Rm5e@t~E;cvEy`7cj*KAxJEwj)I2sVJnM9E zijh!-Pb3li4rgE(w!!=05w}EM>=H>e%PQd}cd}ox3h%?4(U=#eX-E}#T61iY(G^~v zMuDULzx*-ZH~bDi$*>;-gYu|6g^9}BrqH3#qVTSA@DIEI%x7h#G-TWM{fsTiV(MiX5q#-OHQTCIkf+(HN}v>4(m z(3l*JEEOAU^(kvTc8gAK<|P&v)O_O2m0toL>z1ZwBREj8_u6O3;o zt)(24)6XmEWF<4rv6872GTuiHg@wD7^inC87v^Kad_s5-72+B3QA)V?Vt;LLiV~?)KW|T+k)%p zf(i;Hymy|dXJ}a=ei(tx)oDnQa)$Y6n2eq*7;Vv01^sataSb$?ZH2a>uX5RTO_h#HhAxO}WtpIvhqkfoWpa56kYeLhocJ(P*Mo1xL&hIFvceZE~sgYyl1hI?~&o;esj&ql19c(Tq(SnjA=|Md@1}X0x5!X zn1F1d6s{CukZ2l96j!B)rsysH@KooF#FEhB)FQ`}+{`>pwp$!|naSCCiMgqhR~Wg< zu-sxxPt8k7Eh=JUWMC*_0ukjv8#P&rm?!fY|7H5mFqzlnGb77nMpHFLhRGVHLkAVL60_-XP?wilI} ztYjq*G}Fn-fJ+D@1tOv**I3PG6xu9f&CLk4Rc^AgjS6GU0~;qh`JfYeMprpOGUUV*8}e2j|f*mZKI3Zv?r9L5OIQ^lxK zkHb3NR564mpaYpx_)>XO_}2)mW`;NdEGC%Bmm-8B#+V|U%AX>FCMKFHkRpaACY~yi zBC#eN*+9k=$yCu4DKv%Bse&moXkxOd;wf@yV)Ch+DGF#}ia=*F0ljE4*@sD)ohwSZ zQb|*Jav75uqu}JVOkd>%!&99z5=%mhQ;WhfQ_F)>OK$O$7Ubk7ro?CFCZ zi?tLeT|AkI<)Sn%L{c9lnW|p}RjUhDdyz$pQD*WVmTvYUabOq~Nlb2GZRFAcG8uum z*k&?6n=0ei$);?T{5Kfb+W8y#CvaSl(!Ro=GkG0bJZFde44oBmS6FN&bFnLPi*oZb LHF6he0d)cZy*GF- delta 407 zcmaFFzk^TxG%qg~0}y2B_GCJ+GB7*_abSQC%J}RxQ9WIRk%5UJm31{#EJ`d@EK7gl zDn(`xbxu(oNWt_W=oI!;uGLHsJ|hE2$7C@^u$IYIP&y7uH!-Tz11(@q;Z5aD;akJM zni--EEGCf3mm-KF#+V|M%AX>PCMJ?9kRpmECYCCaB90~|kt&)Zi6$nMsxO!#jV36Q z%9$dICME}T5);rb29tZ3l-W6>BrD}L6((p2w!j_;KQuY1>T)E6c)?kvDQ0K_lpVCdtg3 zr4&AeJ?niI$;w+<+u%!;()cviHu};f8*gK6lg}=V;772w*_R<@@|moi;>(h<`E1s< z_;RFNK3B@)^Q3$}Un<}Wpx-K_`bJ8HyuDB=;)|qWzE~>ZOC$$>Fj^F5<7vK1$;msV zD!xh@%a4`D@#CcN{CKIFua;`~8fgMQL7K=tk33~ zB2DF|vbNnfO`6V6mu}^6WzQpgw@EYj8LXY*tCwbaw46qo#n0x#$c_x(9DWY_KbN1& z{@>2u&i>Ei=du6u`T6Ys0)7Gf&-5*n7V(SNa#_ARq{aN=_|i-GB@AyVzm)xN;2Yq7 zwr`oVoL|mxa(r{875oZLBhJ|JtVRVxdHE9MyU`p{SV?N{{Ni9AFd(Wx^<-+{>LBeNwB=XT24|7@=}-ui!kY zJr%AxsZSsogt5)sNltwh#_iIajPF12pI^gB7%x;a5^97ANPtiyOavj{a#RN-O=3O8 zLT&tiUHt!KVTv#nFs2e#X4bqazVBQFKkHm4BKKOEOqCQEjF<&?t*^T;4K<>x3CG$vY9RE2GMT@ zL3>cJpnID^_XO4wg(jg{D26^2wqP$v&U;cfSjA|Yp(N~KYqg5ir+i5Ui$fJqN=huD zfC8K67XnNH;}!tav@tci88a!b*XR@7L-bH`%y}iR)evR~W?g@qp^t`4%?N1p_+= z$=4L-y^PfRg!_eE;6Zi+-}VR(Fxl-M@E~yl%Y<%W?_kFKPt?JxM$s&$G;0&iW8Zh_ zJvrd(_$}KroDY79I^onR=B-|Fn=A%p#V7~e!I13KDMq&^=neQ4v%Agf!f_?N%}r#H zxI8|$EQ>PCvn>tzg5KqB5`;e(QY`ZWL{i&MPBE_(<+gxd7NKnu#3pyhmm74+!2oeL zW1n?yz#kO-!TDa_E2a%PLp@Ma9@%91Q3{twOdGLc5JiX7{)xdH)OC}rq;(m>O*=Wva5MYf}K z1}>Q0ECyZML{E!5=xTFshl6^7Iue4hs>nZN|6Rv*&;$7gxqBj|1tzVv9ar9o;J(PD z!k=?oJN?h1I&LeSRD26}kTw;!bF1hV#kociad`Uo;=J)kpsAQioG+veM-E}1p{*t0 z7iAN)9r)|&A~Wdhl3MOC-CA-X?_NYi;W_P!UT_E9iVlb+7V34BjM@bKih;QO%_8Bk zZG^TEkQ5XC_rekC=t;+@rITUIX(W3Pg>xw>9=E4ObhV1x$#c-f=pYaeHL?)^5;1EQ zVT?(WMZe&ZCE4W(2qJkEhPOvXmi~w{cHqQ&=!a##f#$SP6M+$}qsr{3aqLY5j8kvX zCr9n(?v0EpKbSU-jp1a)=n43nyv^hdM9dyG1s~7Q-%YINcG8ktO1XV>_AQm%I=bbS zebWkI(8)2XzZ9UYnA(6!ufN%8Vv8x(U?2$U(nh?XJ%i~OKS?RO2bX>iry1Q+AOt)@ zog()mIDp_?>a6t}-@)ct`ef}&px}D#0)7>ADEgpR5*4!?Bu&^NC{=O-!Sgt*1C}c~ zx6cP_w;@pkQ$ex`0Y-ZA69lMMih0ePxq%Sc&T4wFt|FX+odz%wPpe{-fZ0Bo;6ce6 z1Z&x-dZ1iv8$CpH2Sr6MySIvD9UBgMgFfi-yMZuvKr2{G(A%`#6(T-G2Z~L8zz}@_ zw;&UA>xvOJ5`!WcfvT#c1c}=tZgzWGL2DOGp2qE=J16HaWfB_1M?fWK5I zjE1-+nFQG)A|V0dlH_I(3<(G!Pmufyr?BJvl3txWYT~E(j5HD^4kiJ#27>AiS}e-o zU&y=I!z|^`^wufmHdIOShXL?P#6Kmg(D(s9|BQAwmT}$mWMdJi%Fi3S3;vGX|B2vR z1pkFV1KAZ>NLyER8fU|EsE(Fo6j96Shd{9&U){v*qTjCmDfeFF{WX;1W=7Vo8_#8Y zj1zy2;O79I=g52XvGrqfnZBNb9;bn@!>|*6Li^WGPC1I$KL$Y0@_Dp|FUUg-#VU9~ zZ@imB;0DPjh?7Cx{AuofTCidC7*sEE0l`H?HcDc!B_I$N_Sg|HRePQ`ZJ6lE!{^lq z63W3;fUL(pR4*oI@<(i`J_uXr3&{omirE7?%EbIRK3dkeeIaokicQgP4g`E;68+nT z%J83Y0<%@K%Y|y?a*?lag1*TMy8Rgrqc$?#R%~$x3OXc+=&P^d5aY{-ILYP-NK9V> zeis4VB_HAVFA)?WxPkx!lVSvQ0{=^oZ_MJd=xZCNbC>9ijU{39Un(C)f+Q6kIB-4gWKCwOCwORX-GN(CTdgA1q>3~$@@43S%987D7q#> zRAo-bHIa;?pWm5d%z{aBIh{47nA-138L7Hm2)NrN;D8JG?3G){EI?|C+;-Pbtj68A z;ysZ+dL0~BNz+=#SPQU+X~XAqW^3Mr(YT#m4SC*}Y-JeNI8+wc0|Ww&3*wZgm1QAh8H{eG??@xTG%rX~x&M*E zd?Z9=3bv9ImgD2iD1@pIas~h`v^7wtG6Wy#op*~gz3tY;H)F)eq1!MXRD9nKjkp5> znI38zr}CgN^0T(Z+)dZcxyjmP-?#Q^TpK|L-ALkVua4{|nL2JBeI`^po!Jg^7sv~z zo=nH37{L>uLrHO|>7C0ZW2Ax{0P+w-*+}hMi@E)@X6yQN6x%BD6u{@Tq(Ac5);@!A z1n#_(20HT!_Q8OXy4vp!f$t)qd%~9oVRRLlO^V8}(KUnIfirb08)wt{ z9km`@ER+G@^4l+7dgu71(+^&L_|ePHzI^S~=SU2?$u0m5&T_?qDPdsq77?Ns3*8nR z{Rx6Q@f*_}QVf(ngJP71ITxvMv&T+6o0_)5t%QQ=;!y=aBVa_T+Gd&$b$8Ow?k`T;2Tcf}?btJqns*i5!4Ah*%b3jg%R&mX z-Gsg>=_rn*Ph&0~6oe@;Q|)JIU{_fgYOG@P1p=+1wvNnY=t9;&1=A=7tJThHG_vb~ zG}L1-fqtZ8Q)I#Jb2>GrX9wR&i}qHHKvKziWD`2VRiulq+dBr#plfe=A>5m3oVH}% zr)mT{m4%+&Thwrq6Oj9HEq1;-T>4K)LdxI+U@s$p31=6g??w=zWA~K+cNg!g;QkUJ z`+S(2eDUDF=yFuk!+Dz#5lKc{>pw8r)`A^q3yOAIJMq$@M;3+`!lToy7$7xWB#>6b z@F9>8_z?sEC?@840RwH$nMc}iWS9x$!O-vwG#~w|x+!*#=lx zqMDjo+;WT0yIIjmLLDi`v6L3EUGO%8rh%SA3Qg3+28{?fo)q^sToYrhs>N*EcI?}Z z05z5!OG!aPaABZUsOR7!VB-r1Yq`?M?+?C$YPs0Lk%QpW*f}u?RqH#Z1@F=K z9=~tA9oI}&2^j1{>413U>F2J!^vu<_=#{6APInNvuhoz=diUW%D_e%?*g1Oma8a1$ z2iO@(P0SnQdK#P=!+FiT$PJ{1+2wt>HnSL}N2_2!G29&ziJI6C@)=c#$-Gk{{c|o) zy(#~Kxct8+_Fx*wD91cW&1oa08aCu)A6`=TrZ%NB-a811Hp;ebwbbsVJV&;KsE{0BayIPmqc3m zq=VZ_7d^Ql%xs%$aAw{#b4gm}=STvJf1|L)Qi6?0tN|Z)U~3P8hY>u9;D-oK08rB6 z!O|o8d@dJcFLyyN%U)*L)u_1^aS`mHlb-ss8td0a#ytHVXZ#ObKbwAaEN2n=d}nGr zwa0D;PHV>2H~@+<z5BacJ&9lu)cR{Nhgev9SE4Jv&Rm2grS#^Hb$|I>_!YG zp9dnZKJ$Ri$kb*ho&BR6=Pn$J`yyy|WUm$H!o`Y1E%zPql96aR+Vvw+_z0jVCa`mM z!Jx$_BRoO%NFeR{abfr$aog7bfOX>Tidnr-3d*1-37Qn6S9bZuZDb;#fFUJna+b~^a2IB(8 zbw$TYZm2p`O3;EB^(1i|f^J?hsp|=(2iL;m$mmoOR^}+erkWC`#tSSaIPwnQJ{y^L z;#*Fw3HC%5{bUpu-ibSTa4`V^lLH8kl|&|}tAcQm#*!SkEMdxl7DvV)U^r&r<5suN zD=_!LWDy{6EvOz4X=YJL)~QsglnwAFB&R{n?)5hX$WHLTHS442w9Jt;1vgX z9XjBeYuNf9<1{wm4IKZDZX^?rI@}TiGr5VuDQPStWEv30@)a#qRRL5>^(=|KoEDW~ z&mt02iwL$JLQKZQx1pt2wuzfvYPLH_0Z<}I1yDI`Ww(SbRrFCkO#Wk}{FSW`!|0L7 zqj0%4@zo24wL5W-p~6>B&M5dfZjKx!zrfaq2r!jlmoCgJTz>tPt8X2={NfL;z4Y9b zy$@bF`sU>Wuak4s^X5iwJw5wog&k>!3tXd`;*%`;Z*Nww>cd5T17NmW4f(H+WW- z^`?441n(1EAxI$EJ?AiqC~0Cl+|A%c7DK^p$*_&|czYwK(s^g>IV{VxY=#ow#l%7` z!Gvj%)-$8@rl*jSq=FUZW&l%81VKMmCFJIzTeBa5TDr7_RU`r^r0D|=n zJ%Wl*lpuBuwieN^KF(cJ1x+Of{d1cek{a;Nf-CW=g{j4N)EakF^x#j~t0Z`TvKu;v zYBSyV>niRFec;!x!3pmEC_l2cx1YQH`%VB^AexFwJd*~5U51666yBV^gW(+Yl5cau)@Zaba zT;L;E&8$k%_Pp=k~IU9467MX|H&V4 z+Mf_yNAL}T|3IJzVocLG1DXLgI((m81}T?mc%sHs=ze4^^U+Lu2DxX&5XKc` zRbkL~A`PlQGC)!tx!>ViHJeZcQjnJbewXGOi)O^gn7u4&FN@ioQM)cnJ6srrG_l3E2cEP|T3hfstpBb7u`1^XQj^6lXAw74tl+)AnZH4sK zce8RjwSqw~HfupS;*)mhfccM9Wb4eQQmFffg=WDbm`_keHXI&PyLOuPfc64Q3=K8V5}@1+xYQw4GYIDK*P37*5#|1erU{G)l6}f_>0} zkEEvPQi6E{6Vp3W2CZnJv){`$8326*(4C%MkP$>}X1`9;;O^2yH=Z(%9TqZ{>rsiU%2!}57fn99)IKNkr%%@(|z^$sY`FY_AgyK zuf6p4)e{dw=$B)53s&P#y6f!E851#}O-$v}^a29T~+(C{exL-39{CH@Sxni1WM z8Gwq0@0F8iVTcge0axl;wH!drht);072c3Tzv`-WP>r%z@-jAXiVUy1RV3LtVo>6h zyhXtv4CQ;f#}|UTRx3~d^*Ll$UO(KM=itc-@H0EYUX-vRjJ=sD!x6MjG8#cS0C?3j zG;>p|Xc$m)!@{y+NgNj5i{m!X5kHkoim)Sn;5_je)`91+7v6Rk1B#3e2`xVUQ%fsm ze=cT!9fjYue?|lworF#RFH6A*q-yMAj&19+)pYAWwPjw+Dm>hL?B3p@$*1M>S#x{S z=6++;dSV=vbRLDxAbNEz;k32#WF@mGe*ZU#ziy6_1w{$F|IFTX1D3OQIk{p zzpN7;vQR-Rqb! zMl+o~BYQKQeVH@5&6jN9+>5#6POLp$dp>toEO%ZscizQZ=ZP_=_2+YE#B%3EbLU*l zbDpR?opnC1K9)B(nm6}`NuO`|TBEmHuWR%vR^}%goM|_W&dE|e8J*d6#$u2ZyUYs{ z^zEbq!LTT7!#)I~P_}o0dhF8N&}t24;H$kLW-E=_N@KRsQQPP~+nBC}{kda%^;!RH zus%|EtnS#D^M>6XLI@q)1ykYhYZIQtm#_>5EYpWRm?#M2I zFP+|Z>C7<*!B_V_5JD>$+;Qcx2f*Web>^XK?;N^(47N?Lsj056u8~B+>#hm4v2Q_Y z+S}W!x3o25$&>h*pv59hftLRInT`5RZi|twn3+j$dpC>zsUn4r{*}#Sf)5V$rwsJP z`(|()z5Y-Zop92eBbd?3cW8x_VCKMjTe9FAxdO`(O{eyV=1JWS9j$+~Buy7Y&%l12 z9G(4K7K9}8KkPTBCc=e2m`iWWDum#4GEUtNL#F{U4#-F7#rhPZJOUDsQcOU?h9pW5 z1gic7Yp21R__7xKe`*66gL2;q;~n;ffCD+_P+1YL!P&i8htVs+_i8mAS@4~j=L}` zLcQ-rwPPQ8fVXY)fvaf`s_zAp7v$);)cbr^>k0UXB2_Xrhak6ixi`xJUnmTh1|i}d z_|nUa=fZcGiVl`k*dzNm$c2QcqmW}HKKdG{;8WEzxzuCU0;?L)I`CqwOLM(KW6wRX zWABcb%@MUZj^#iq)3xwZYbGQ&;lsJTWwpI|b*DY&ZF73fbHIvHvtm|9)ar;?$40GV zPb@rdtpiL%oZ6c=?d+)Yw)ws0`QR?CX$Qva9TT$@L@fn}r#?O7i5Yzs=ZVs&Wn90mmjAfwc-0Tb9V_n9oe1^X>be%fq|}iyYf02va?E^cY){MD*QJ-GNT9E3exG$g z*X`e!HTII2tt@IQJ66lcjMGybwH6=Vdfr;mpIH#g93Rabf1;%?a|-w>Yvv=X4=?P^ z8r^5D=+##+Yjw8LEM;anL3>bgpsa+?GT>_`aa(}2R)Y~jn2`ylWh8|zSBmPHnDd1# z@Ks)*+7lpRHOfJTM3Q~}NKlqa&UeQ*yJ4kN>Q{2Obh=w9%d{b)HIB%X%1+-@MrMz| zz3?pyLE|D>2rg0Gg^CLGLmK80F+ox*`%-8r7!2X&7rzIQ>9Py?)72~AO2l#^z!I8$ zzM*6eCJkRdz`dAy*{QyApnESk@@m+k>V-7Cf!R8VU|J8KU%q=Ghr5OLUzk+Dw3odA zJOrAmUaT`!ub`tZP7F_CDJ9sw%An! zUb(Z2Z4$-SB>+nL0?`kw^$aY{>N*xy&~S#u8^wGZeA*ZCiS=Ym0}R6g8o3FK>$;BP zxGywgztGH&YUY2TssBtf?lVo@XPWAW_}R<)@Hd)9E$94Zi@ut(9yZ7F>Y{md-)P|B z29yd~TYqXnzkTxW%tifKg}<}s9Lwuj7tO1`uG5Z82bC{y95&yu8XcDFI&+%wdX^?7 zb?;q=7oJ$%Tf3%r+}iVL>j2Yc{3hR~)!uMuw3hD4-IaY@=BJ$Pk&20xd*bF6B+jIo`FRf`+R%GjZ7*o&84{wpP8v<^4Mc! znb~SKkG)2Y=~w+c_89}rTs4=+nZ`gfPt7y))qHc1I>;QX4kmn-mTe3%hpI!(0=2*_ zR13{v>M*lNEi#MMVzWdoF-z4_bGSO(EK|$Oa<$y7P_rs{VUDrq{j#JvP90|k)POl& z9nXCH#sqVsI?`+SWqZPtOF0@rA>wK>{c zZJsuNpQn%53$#&5ZQ=hz?nT{lcggQk&(r@GYpF|Hc9}~%TURz8kYDBwk>`2x^J*D?4&j#*{vO8Xd9#+*X)DNOYq}Yo ztF26L%dF4SR;4k_D9z`5+OM`3pS8?qHR0=e@>#sDo5LDyZF)cEuufZ_#`NK^fp$`R zaahkBHWL1Vo*a@Jv|{2nAP*Tbt6ctHxh5RW!#VNKIY3Fkl^SaMs#LAMh{ zw7z$MHKrZX9bX%nOOrn4fk>BAl0RCwxu1m>@WL&c!GYPC86>F5;TNSKEg@kudSyvW zYu2`ELbHApbjX!>XUtLd(ceEvmP9`yUC1J}Xs!Q@NZKZ%M(S>(JZYoE>`va1B*&?< zbUPT!Ri1+Ei^*|2$Z^khh2Z@9w!FY4Cm?+w!_I1n=&hC>w``vk55*G};e4TRJQ9oA zS)t}g5c2`W&Z^a|=2+B9P0HHn_RgloBU0D;@5xq%$QcFml;v`3K~dqkgtjwcmhEW{ z#hYx;=2#>uPLul!Huxscl$a?0UQkw9hv5o<<)F{DbP)*|!Dviiy*OLWEj(RWDR&lD zDm8L{VX3#7&|_2275=wdNyw*5_9!i>m8F>(ehcI~04_<@mmO9VOOCIoR<_FeifPI< zQdVpWpGUK{-_a}>j%m8>iR;_qq8_W(5C~-2nNTw!3_G(q)Mms&nz$LOvV^0tF1CR9 zI^ev`O|htMWzx73f9>s}Mn)@VC^yMFD?cb|1L1Z8fqdJeh2kOG-KfV!ro4GXMb$>$ zK!l==I&-#tG}dCKor(XEXk1K@M@LkhI~D7Fq66eg(9RBr!cBT`i{2(~Clt#e_S4An z5r7bj7Ks`f+PRh<)q<93v7+K(!nLH9jl4(kC1@yGXSSnBQj!KA(`e(-$Z^^@G#ZnUNy1gJr&u8B36j zI7D^=y&K?Z0*NuQqN-9}Tz$6hSHL|bU#qSqQOag6RoVZZc*NB0EGsS=Fk<(LJ3)FE z0ozTsx7{JbASTU_PQbqc{t`F^#oYi1LOW~i>1V_ef+BX6e0JuTSRxfqfPSDEu`KdH zusI|`rX^zX%UMPKS254Bzaod$RLy)B-w;T!6WH#^0f8W=*ww9s5oh~5`GJCBmC8l! zMY6rdm(L+7KEeWkhf_bR89LneYkWU1AFms!Tq=*&l~JgCTX$v2e*yhhfbRgl2lyMn z8FKHM9lm)qOjOGiL(1f=wbxK!9a`IbhU#ydgGADJ}hDplc!&vhgz##(n zDdHLVqB>y&$Lq_433vso#kw9LgzYu-7Tu70HkQeW8}ocW#o7aM@y6;R5ZZn%LQxah zl!(WKzygsg|7GKo%1*iTg3)6UT;g{CzX!3;)Z50Z>Q8T~b~!b;S@2R{?Z~ z1_yws0}jE(ZWjNEQ9ZyK08~h^mVliVrU+#xzc2GIEL0}Tu@{a@!s>!EG%FZH39-t9;ATLbQIh_Vv4Qt0v6?kVl?E-yc61iy$qz4_qa2gv7gcOR6?4o`NDZXzMj5*pxCVe2 zz!w0>M%%a9BF4INEaW&579{(NuQBVjv@JAA9lA@n0XzU+x&NZ!23Kg2jl zt=UN@kp(Kcp19ccc4jQHk@=N2_>lYDnWxy}0K(QF$bO5?&xWsTQ zb$DTPCU9o~jKSv;jQa4QyN62vte4r*PCm=zlIXS}S+s=eb_e~lXx;^7k3z` zo{W}K&Sk}Fl>bwpq2Sw}KoywiPoNdFy#UbV-sb9TRve#lx6tL>t#g<6r*~m*Hmvf9 zQ*l@c4gk&a`K=Qj)2>W?wRO4D@7{y@*}Lpid#}RY0Il*Wt8?#Fsi&+VZe@{tt7XRU z4y@z?0Otfeq8bPmc!I2GE%LGV*U9;<<;vCag4PWKVSzev3&Hy`#5<`MTi@~e27t{D zx$lyql3g@m4_FfoC8+cW3MlQw9-6Ha^W>YClqY|{_AddXOXKVKd;{P&0B;hoeK9)s zA{y1l7T~z-AHjr|&L7IC-lwje-7IvP2u3tpnItYJ!tZmsQS-%FSm~}^y;yG9IYW6y z-oCTc_gn0nl+W)h3h?R5XX<(=vIc+)QQsOVGm;~XW-R17#&rmXrc-lS$dB-e+Qntb zX;130AxnG{Cb**BgAq=9r}gqA5+u79a0c~Xdb2?%s8}bjPZlk!C#pb!B99Fe6lFrha;COu}OZBEK!=Izx_02i=5kDPR*>^UZdQclI_oCD0j)7 zuN0@YTzZS*Lo-M2%-L0*ggfcjvVcLR*WIVs9y1he3;68;TNAoy>pHYq7q+Aa#B5G3 z^qg+pA!ZMx1~iy1H(Y4R%^Y{oj$29cztD7H$$NJVBQc-bRjK?*{`;;gauIzLrSE`n zV=8|6>u#s%W?gs4b9Rp(1d&A@EW-kei{-A}W0kAqgS$r!-%jX2Ub@v1>~PjDQ+~F) zY-K+Zi=Ehu^_haLKZXd|eFS#8VD<;SNV!Biz(aEV)fFWEwyVb|U#Iq8ZJ;k0aP5cg zLMLIc+l3&6$jA`yo|$=JAP~WJM(Z}A$#eEBOP&FiS+vVU22OiZU<(%RqY2x)HHj?g(MxgWj|M5%n0hRS-2(&qHCsBf`%7w;8O??TnQ{5*`g5*e zl%w*=TSqH(sXyHM*Nh|#YP;9153W3cNVhYsL^!O|asM;Ob1Q*BRu3gP96E!5=F>tW zp1KZ;4jPMYH(T%%WN`RX14q|1VN#W7Gj+Mc;)dQN0zWnf6ZjYB#O-^cEJ z9+hLnNAx-f>&53bj5+|W1lS938^E1%>i&X~4K(CtVUg3t)M5(YQdz(MbLXh7P1W80 zf}($m?eYmkT-T1JM#IiDL)(I(MxA<&dRjr=xfvwv-cszd37=yL*uF&6iZn(=F)-@!i-JO-wTCsE+00%dJCa_b6 zxE}(rSZ>h{iVlFEq>kTth1=Iz3#0cI4#1mGcXhe`-qMU8T=Kzt1s&y;KQB@0<=meS zPyQ?RdV~Na8Md^uG`%52cPWcJ+|9`LMXX>{Zxs`XgfgM~w(8tX`ztHRbvuV@7L|Lt zXU4@|NOKcFJpe~nF+{ZU6V3EGp$Dl^c5b9>Sg{qNF~fn5hO8P+SKE#E5jVBujwIY~ zLweDzjzBfGGoAf3aS)SG#~MiM+4c;|06UYV^EP6{8tIwnS44Y9O5FE%#n(T?k`3j&}H)saW|zCiCaB2G_70!q<)0-r8idq-jirpY2ZwuFp`#_4bS_`pN1_ce@f=Yem7^bQQRc{f58mvvu>3k%^U#X?Zl|&B z#AB%&AF6T>aR@*K6}}lkKvwQ9kjEa$ErJ%}Dgw5zhX2Ga2(n#z9$lW~h42D7vxVM} z1Res!qlBPWp+3A0V;L`a4D&yV6RzTSgIo7m+F#n(x#$v{Xh`DThdgt_ft?I#L^rQM zj$=ITTR21>0{ImnVuSvYMs`lCzA5Oml|6zXhbYO+oqtXo`1w&S=!C%=xYeCVou6u< zV@baH*iG~nb2Dr^=O=<@HZppw!9Fo*I)6 z5vkz#oR(SS$*QL(*S!ha-w;?5DDM$R>9qSNE$Q6p*=VKm?x#m4kI;d6~z(qWm zpdOEVIj1%1@<=T`3+mhG5d-f%Oap;LA5I=TbDZKx%h^QtsU=RfN=M_K0WgBKh0;H9-y3cZmy=t9>xS8abJ+7h7xgcn(=_#e`~Cvf8wXc?#u zav0$j&Qshk1P1o-%Lkb1a!%4EO0c%iQPAf!Jr0s8Y@gZV;P1_dp2p}>lNQan@uQ;j z3Rrp>cGTHkdM2qQA2>A3cLN28^?O-CgqjmyQ?o7r1%y_Ls{Sr|T8# z1K}KBLo+H-)j)IrK+N7H%7dyTx4v88#o$3%{E9|x+|E}NrGUewFIRcvtFvY^L*~U* zk_kVdt$XzrY!XcM=q;q@@EGMuIpMG%Cbu09WR&#F1fm|1$6qg|*SWvFUNVNg>2`EN zlgy4hf!0YJ02oYMC`0GIF)GW4SHC_A{C4S(}tC3z|>>5j%zi-={NxR@X% zWN#H5f}(C=&&LS2etS@Jhz>lQzkH*kA78|1nzRd1guCXnARV>~@r#SjR`_w$LIB%C zRg-^ip&u3636cJ30;jCJ>h1Bq4-uIU$|;BE$gkdRCGBoIT%5Y>$Wi6YQ)x#TiQID( zV^7hE%fz{acCI^$cnb=>C2xOcK_BNiq9>KHTX{`QE6EhEu64 zzJykMX~Lsm9UTsy1|t;5AraAnR1o-T>$o}ZAGukU9UU=+8)`UP`~`q}>8~*2%)%)wd?)BS*^>zkK`X=Sg(*xFhxVAeOd$x}RqUa6gX} z5Fdfh#{i!K{2Aa|fd2ttQQiX`*f`z%aZCVQjewH#?eF|N2~;2?Pkypc)_z)2gvw&) zYn$nJuGnOY9;M>pMBEbD^7&6kF2V6nkLu23Q#{_x@8os?-LivLvmTCz!U-cJCMV(% z`Xz-6MJ@zD4=M5h5G^wQWPws3$DACKOTX>$uZMU_wd9JE#f5@79h367nYGC-d)nPJcEP6deFF0A>R;13V1y z5x`vHY7ecYQ}w*4(RMC=a#~Bj(5|K;vQTj)sUzrx!`rSn2VeyNKYsEnWi39rVxjTo z_Ex-s(J=z{z@>VW>>BRcntk6p9_*}n)V3rHeSs*;aJgue%X(=Lg~k_dMNy8qCLD9k X{5RLc)DxdS;_-juTAiT;IGFwyj8`7s 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 0000000000000000000000000000000000000000..c44d06ec927b7966adc714bda312d9316af73699 GIT binary patch literal 36020 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%0Ae|BvA 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 literal 0 HcmV?d00001 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字' }]} > - + - - - +
+ + + + + + + +
-