diff --git a/admin/client/src/api/crm.js b/admin/client/src/api/crm.js index 48685e4..a69f973 100644 --- a/admin/client/src/api/crm.js +++ b/admin/client/src/api/crm.js @@ -358,3 +358,67 @@ export function deleteShowcase(id) { method: 'delete' }) } + +// Notifications +export function getNotifications(query) { + return request({ + url: '/notifications/', + method: 'get', + params: query + }) +} + +export function createNotification(data) { + return request({ + url: '/notifications/', + method: 'post', + data + }) +} + +export function updateNotification(id, data) { + return request({ + url: `/notifications/${id}/`, + method: 'put', + data + }) +} + +export function deleteNotification(id) { + return request({ + url: `/notifications/${id}/`, + method: 'delete' + }) +} + +// Notification Batches +export function getNotificationBatches(query) { + return request({ + url: '/notification-batches/', + method: 'get', + params: query + }) +} + +export function createNotificationBatch(data) { + return request({ + url: '/notification-batches/', + method: 'post', + data + }) +} + +export function getBatchRecipients(id) { + return request({ + url: `/notification-batches/${id}/recipients/`, + method: 'get' + }) +} + +export function deleteNotificationBatch(id) { + return request({ + url: `/notification-batches/${id}/`, + method: 'delete' + }) +} + diff --git a/admin/client/src/router/index.js b/admin/client/src/router/index.js index d3f2682..51d12d1 100644 --- a/admin/client/src/router/index.js +++ b/admin/client/src/router/index.js @@ -153,6 +153,39 @@ export const asyncRoutes = [ name: 'Showcases', component: () => import('@/views/crm/showcase'), meta: { title: '精彩视频', icon: 'video' } + }, + { + path: 'notifications', + name: 'Notifications', + component: () => import('@/views/crm/index'), + redirect: '/crm/notifications/history', + meta: { title: '小程序通知', icon: 'message' }, + children: [ + { + path: 'history', + name: 'NotificationHistory', + component: () => import('@/views/crm/notification/history'), + meta: { title: '发送记录' } + }, + { + path: 'custom', + name: 'NotificationCustom', + component: () => import('@/views/crm/notification/custom'), + meta: { title: '自定义发送' } + }, + { + path: 'project', + name: 'NotificationProject', + component: () => import('@/views/crm/notification/project'), + meta: { title: '按项目发送' } + }, + { + path: 'coupon', + name: 'NotificationCoupon', + component: () => import('@/views/crm/notification/coupon'), + meta: { title: '按优惠券发送' } + } + ] } ] }, diff --git a/admin/client/src/views/crm/notification.vue b/admin/client/src/views/crm/notification.vue new file mode 100644 index 0000000..560c960 --- /dev/null +++ b/admin/client/src/views/crm/notification.vue @@ -0,0 +1,276 @@ + + + diff --git a/admin/client/src/views/crm/notification/coupon.vue b/admin/client/src/views/crm/notification/coupon.vue new file mode 100644 index 0000000..9fe8955 --- /dev/null +++ b/admin/client/src/views/crm/notification/coupon.vue @@ -0,0 +1,101 @@ + + + diff --git a/admin/client/src/views/crm/notification/custom.vue b/admin/client/src/views/crm/notification/custom.vue new file mode 100644 index 0000000..922f972 --- /dev/null +++ b/admin/client/src/views/crm/notification/custom.vue @@ -0,0 +1,125 @@ + + + diff --git a/admin/client/src/views/crm/notification/history.vue b/admin/client/src/views/crm/notification/history.vue new file mode 100644 index 0000000..eee55d1 --- /dev/null +++ b/admin/client/src/views/crm/notification/history.vue @@ -0,0 +1,163 @@ + + + diff --git a/admin/client/src/views/crm/notification/project.vue b/admin/client/src/views/crm/notification/project.vue new file mode 100644 index 0000000..6762688 --- /dev/null +++ b/admin/client/src/views/crm/notification/project.vue @@ -0,0 +1,103 @@ + + + diff --git a/admin/client/src/views/dashboard/components/PieChart.vue b/admin/client/src/views/dashboard/components/PieChart.vue index e701dd1..3b4419c 100644 --- a/admin/client/src/views/dashboard/components/PieChart.vue +++ b/admin/client/src/views/dashboard/components/PieChart.vue @@ -1,5 +1,13 @@ + + diff --git a/admin/client/src/views/dashboard/index.vue b/admin/client/src/views/dashboard/index.vue index 2eea36a..ba10191 100644 --- a/admin/client/src/views/dashboard/index.vue +++ b/admin/client/src/views/dashboard/index.vue @@ -14,7 +14,7 @@
- +
@@ -63,6 +63,7 @@ export default { showcases_active: 0 }, pieChartData: [], + pieChartLegend: [], barChartData: { title: '热门机构', names: [], @@ -84,6 +85,7 @@ export default { // Default to organizations line chart this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students this.pieChartData = data.pie_chart_data + this.pieChartLegend = data.pie_chart_legend this.barChartData = data.bar_chart_data this.loading = false }).catch(error => { diff --git a/admin/server/apps/crm/admin.py b/admin/server/apps/crm/admin.py index 631c887..f005af3 100644 --- a/admin/server/apps/crm/admin.py +++ b/admin/server/apps/crm/admin.py @@ -1,5 +1,20 @@ from django.contrib import admin -from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentHonor, StudentShowcase +from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentHonor, StudentShowcase, Notification, StudentProject + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ('title', 'student', 'notification_type', 'is_read', 'created_at') + list_filter = ('notification_type', 'is_read', 'created_at') + search_fields = ('title', 'content', 'student__name', 'student__phone') + readonly_fields = ('created_at',) + fieldsets = ( + (None, { + 'fields': ('student', 'title', 'content', 'notification_type') + }), + ('状态', { + 'fields': ('is_read', 'created_at') + }), + ) @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): @@ -38,16 +53,20 @@ class BannerAdmin(admin.ModelAdmin): @admin.register(Student) class StudentAdmin(admin.ModelAdmin): - list_display = ('name', 'phone', 'wechat_nickname', 'openid', 'teaching_center', 'company_name', 'status', 'learning_count', 'created_at') - search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name') - list_filter = ('teaching_center', 'status') + list_display = ('name', 'phone', 'responsible_teacher', 'is_active', 'status', 'teaching_center', 'created_at') + search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name', 'responsible_teacher__name') + list_filter = ('teaching_center', 'status', 'is_active', 'responsible_teacher') inlines = [] +class StudentProjectInline(admin.TabularInline): + model = StudentProject + extra = 1 + class StudentCouponInline(admin.TabularInline): model = StudentCoupon extra = 1 -StudentAdmin.inlines = [StudentCouponInline] +StudentAdmin.inlines = [StudentProjectInline, StudentCouponInline] @admin.register(StudentCoupon) class StudentCouponAdmin(admin.ModelAdmin): diff --git a/admin/server/apps/crm/management/commands/populate_business_data.py b/admin/server/apps/crm/management/commands/populate_business_data.py new file mode 100644 index 0000000..a662a06 --- /dev/null +++ b/admin/server/apps/crm/management/commands/populate_business_data.py @@ -0,0 +1,181 @@ +import os +import shutil +import random +from django.core.management.base import BaseCommand +from django.conf import settings +from apps.crm.models import Project, StudentShowcase, Teacher + +class Command(BaseCommand): + help = 'Populate database with business mock data using existing local images' + + def handle(self, *args, **kwargs): + self.base_url = "http://127.0.0.1:8000" + self.media_root = settings.MEDIA_ROOT + self.projects_dir = os.path.join(self.media_root, 'projects') + self.showcases_dir = os.path.join(self.media_root, 'showcases') + + # Ensure directories exist + os.makedirs(self.projects_dir, exist_ok=True) + os.makedirs(self.showcases_dir, exist_ok=True) + + self.stdout.write('Starting population...') + + # Identify available source images + source_project_images = [f'project_{i}.jpg' for i in range(1, 9)] + source_showcase_images = ['showcase_1.jpg', 'showcase_2.jpg'] + + # Verify at least one source image exists, otherwise warn + if not os.path.exists(os.path.join(self.projects_dir, source_project_images[0])): + self.stdout.write(self.style.WARNING("Source images (project_*.jpg) not found! Please ensure default images exist.")) + # Fallback to creating a basic one if absolutely nothing exists? + # For now, let's assume they exist as seen in LS. + + # Create a default teacher if needed + teacher, _ = Teacher.objects.get_or_create( + name="教务中心", + defaults={'phone': '13800138000', 'bio': '负责课程教务管理'} + ) + + # 1. Projects Data + projects_data = [ + { + 'title': '工商管理博士 (DBA)', + 'project_type': 'training', + 'target_name': 'dba.jpg', + 'detail': 'DBA项目旨在培养具有全球视野和战略思维的商业领袖。', + 'teacher': teacher + }, + { + 'title': '工商管理硕士 (MBA)', + 'project_type': 'training', + 'target_name': 'mba.jpg', + 'detail': 'MBA项目提供全面的商业管理知识,提升管理能力。', + 'teacher': teacher + }, + { + 'title': '应用心理学硕士', + 'project_type': 'training', + 'target_name': 'psychology.jpg', + 'detail': '深入探索人类心理,应用于管理和生活中。', + 'teacher': teacher + }, + { + 'title': '人工智能与商业应用', + 'project_type': 'training', + 'target_name': 'ai.jpg', + 'detail': '掌握AI技术,赋能商业创新。', + 'teacher': teacher + }, + { + 'title': '高级工商管理博士 (EDBA)', + 'project_type': 'training', + 'target_name': 'edba.jpg', + 'detail': '为资深管理者量身定制的最高学位项目。', + 'teacher': teacher + }, + { + 'title': '2025秋季开学典礼', + 'project_type': 'competition', + 'target_name': 'opening_ceremony.jpg', + 'detail': '欢迎新同学加入我们的大家庭。', + 'teacher': teacher + }, + { + 'title': '2025届毕业典礼', + 'project_type': 'competition', + 'target_name': 'graduation.jpg', + 'detail': '祝贺各位同学顺利毕业,前程似锦。', + 'teacher': teacher + }, + { + 'title': '全球商业领袖论坛', + 'project_type': 'competition', + 'target_name': 'forum.jpg', + 'detail': '汇聚全球智慧,探讨商业未来。', + 'teacher': teacher + }, + { + 'title': '校友会年度聚会', + 'project_type': 'grading', + 'target_name': 'alumni.jpg', + 'detail': '重温同窗情谊,共谋发展机遇。', + 'teacher': teacher + } + ] + + for i, p_data in enumerate(projects_data): + # Cycle through available source images + src_img_name = source_project_images[i % len(source_project_images)] + local_path = self.copy_image('projects', src_img_name, 'projects', p_data['target_name']) + + full_url = f"{self.base_url}{local_path}" + + Project.objects.update_or_create( + title=p_data['title'], + defaults={ + 'project_type': p_data['project_type'], + 'teacher': p_data['teacher'], + 'image': full_url, + 'detail': p_data['detail'] + } + ) + self.stdout.write(f"Updated Project: {p_data['title']} with image {src_img_name}") + + # 2. Exciting Videos (StudentShowcase) + videos_data = [ + { + 'title': '人工智能课程精彩片段', + 'target_name': 'video_ai.jpg', + 'video_url': 'https://www.w3schools.com/html/mov_bbb.mp4', + 'description': '课堂实录,感受AI魅力。' + }, + { + 'title': 'DBA学员分享', + 'target_name': 'video_dba.jpg', + 'video_url': 'https://www.w3schools.com/html/movie.mp4', + 'description': '听听学长学姐怎么说。' + }, + { + 'title': '毕业典礼回顾', + 'target_name': 'video_grad.jpg', + 'video_url': 'https://www.w3schools.com/html/mov_bbb.mp4', + 'description': '难忘瞬间,感动常在。' + } + ] + + for i, v_data in enumerate(videos_data): + src_img_name = source_showcase_images[i % len(source_showcase_images)] + # Note: source showcases are in 'projects' folder based on LS output? + # Wait, LS showed showcase_1.jpg in 'projects' folder! + local_path = self.copy_image('projects', src_img_name, 'showcases', v_data['target_name']) + + full_url = f"{self.base_url}{local_path}" + + StudentShowcase.objects.update_or_create( + title=v_data['title'], + defaults={ + 'cover_image': full_url, + 'video_url': v_data['video_url'], + 'description': v_data['description'] + } + ) + self.stdout.write(f"Updated Video: {v_data['title']} with image {src_img_name}") + + self.stdout.write(self.style.SUCCESS('Successfully populated business data using local images!')) + + def copy_image(self, src_folder, src_filename, dest_folder, dest_filename): + src_path = os.path.join(self.media_root, src_folder, src_filename) + dest_path = os.path.join(self.media_root, dest_folder, dest_filename) + relative_path = f"/media/{dest_folder}/{dest_filename}" + + if not os.path.exists(src_path): + self.stdout.write(self.style.WARNING(f"Source image not found: {src_path}")) + return relative_path # Return path anyway, hoping it exists or will be fixed + + try: + shutil.copy2(src_path, dest_path) + self.stdout.write(f"Copied {src_filename} to {dest_filename}") + return relative_path + except Exception as e: + self.stdout.write(self.style.ERROR(f"Error copying image: {str(e)}")) + return relative_path diff --git a/admin/server/apps/crm/migrations/0028_notification.py b/admin/server/apps/crm/migrations/0028_notification.py new file mode 100644 index 0000000..3d60442 --- /dev/null +++ b/admin/server/apps/crm/migrations/0028_notification.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.23 on 2025-12-09 03:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0027_coupon_is_time_limited'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + 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='内容')), + ('notification_type', models.CharField(choices=[('system', '系统通知'), ('activity', '活动提醒'), ('course', '课程通知')], default='system', max_length=20, verbose_name='通知类型')), + ('is_read', models.BooleanField(default=False, verbose_name='是否已读')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发送时间')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='crm.student', verbose_name='接收学员')), + ], + options={ + 'verbose_name': '消息通知', + 'verbose_name_plural': '消息通知', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/admin/server/apps/crm/migrations/0029_auto_20251209_1134.py b/admin/server/apps/crm/migrations/0029_auto_20251209_1134.py new file mode 100644 index 0000000..c4ab0a7 --- /dev/null +++ b/admin/server/apps/crm/migrations/0029_auto_20251209_1134.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.23 on 2025-12-09 03:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0028_notification'), + ] + + operations = [ + migrations.RenameField( + model_name='student', + old_name='teacher', + new_name='responsible_teacher', + ), + migrations.AddField( + model_name='student', + name='enrolled_projects', + field=models.ManyToManyField(blank=True, related_name='enrolled_students', through='crm.StudentProject', to='crm.Project', verbose_name='已报名项目'), + ), + migrations.AddField( + model_name='student', + name='is_active', + field=models.BooleanField(default=True, verbose_name='是否活跃'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0030_auto_20251209_1207.py b/admin/server/apps/crm/migrations/0030_auto_20251209_1207.py new file mode 100644 index 0000000..660f650 --- /dev/null +++ b/admin/server/apps/crm/migrations/0030_auto_20251209_1207.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.23 on 2025-12-09 04:07 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0029_auto_20251209_1134'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationBatch', + 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='内容')), + ('notification_type', models.CharField(choices=[('system', '系统通知'), ('activity', '活动提醒'), ('course', '课程通知')], default='system', max_length=20, verbose_name='通知类型')), + ('send_mode', models.CharField(choices=[('custom', '自定义发送'), ('project', '按项目发送'), ('coupon', '按优惠券发送')], default='custom', max_length=20, verbose_name='发送方式')), + ('target_criteria', models.TextField(default='{}', verbose_name='发送条件')), + ('recipient_count', models.IntegerField(default=0, verbose_name='接收人数')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发送时间')), + ], + options={ + 'verbose_name': '通知发送记录', + 'verbose_name_plural': '通知发送记录', + 'ordering': ['-created_at'], + }, + ), + migrations.AddField( + model_name='notification', + name='batch', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='crm.notificationbatch', verbose_name='关联批次'), + ), + ] diff --git a/admin/server/apps/crm/models.py b/admin/server/apps/crm/models.py index 13e40c1..75be154 100644 --- a/admin/server/apps/crm/models.py +++ b/admin/server/apps/crm/models.py @@ -130,10 +130,13 @@ class Student(models.Model): # 用户需求是“微信唯一标识”,这通常指 OpenID。 openid = models.CharField(max_length=100, verbose_name="微信唯一标识", null=True, blank=True, unique=True) # parent = models.CharField(max_length=50, verbose_name="家长", null=True, blank=True) - teacher = models.ForeignKey(Teacher, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="负责老师", related_name="students") + responsible_teacher = models.ForeignKey(Teacher, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="负责老师", related_name="students") address = models.CharField(max_length=200, verbose_name="地址", null=True, blank=True) # 已经有avatar字段,对应微信头像 avatar = models.URLField(verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde") + is_active = models.BooleanField(default=True, verbose_name="是否活跃") + + enrolled_projects = models.ManyToManyField(Project, through='StudentProject', related_name='enrolled_students', verbose_name="已报名项目", blank=True) # 新增字段 teaching_center = models.ForeignKey(TeachingCenter, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联教学中心") @@ -303,3 +306,58 @@ def activate_coupons_on_phone_bind(sender, instance, created, **kwargs): coupon=coupon, status='assigned' ) + +class NotificationBatch(models.Model): + SEND_MODE_CHOICES = ( + ('custom', '自定义发送'), + ('project', '按项目发送'), + ('coupon', '按优惠券发送'), + ) + # Using choices from Notification class requires Notification to be defined, + # but NotificationBatch is defined before Notification. + # So I will redefine choices or use strings. + TYPE_CHOICES = ( + ('system', '系统通知'), + ('activity', '活动提醒'), + ('course', '课程通知'), + ) + + title = models.CharField(max_length=100, verbose_name="标题") + content = models.TextField(verbose_name="内容") + notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='system', verbose_name="通知类型") + send_mode = models.CharField(max_length=20, choices=SEND_MODE_CHOICES, default='custom', verbose_name="发送方式") + # SQLite version issue with JSONField, using TextField with manual JSON handling + target_criteria = models.TextField(verbose_name="发送条件", default="{}") + recipient_count = models.IntegerField(default=0, verbose_name="接收人数") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="发送时间") + + class Meta: + verbose_name = "通知发送记录" + verbose_name_plural = verbose_name + ordering = ['-created_at'] + + def __str__(self): + return f"{self.title} ({self.get_send_mode_display()})" + +class Notification(models.Model): + TYPE_CHOICES = ( + ('system', '系统通知'), + ('activity', '活动提醒'), + ('course', '课程通知'), + ) + + student = models.ForeignKey('Student', on_delete=models.CASCADE, related_name='notifications', verbose_name="接收学员") + batch = models.ForeignKey(NotificationBatch, on_delete=models.CASCADE, null=True, blank=True, related_name="notifications", verbose_name="关联批次") + title = models.CharField(max_length=100, verbose_name="标题") + content = models.TextField(verbose_name="内容") + notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='system', verbose_name="通知类型") + is_read = models.BooleanField(default=False, verbose_name="是否已读") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="发送时间") + + class Meta: + verbose_name = "消息通知" + verbose_name_plural = verbose_name + ordering = ['-created_at'] + + def __str__(self): + return f"{self.title} - {self.student.name}" diff --git a/admin/server/apps/crm/serializers.py b/admin/server/apps/crm/serializers.py index b1454e0..beed34d 100644 --- a/admin/server/apps/crm/serializers.py +++ b/admin/server/apps/crm/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase +from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase, Notification, NotificationBatch class CategorySerializer(serializers.ModelSerializer): class Meta: @@ -67,12 +67,13 @@ class BannerSerializer(serializers.ModelSerializer): fields = '__all__' class StudentSerializer(serializers.ModelSerializer): - teacher_name = serializers.CharField(source='teacher.name', read_only=True) + teacher_name = serializers.CharField(source='responsible_teacher.name', read_only=True) teaching_center_name = serializers.CharField(source='teaching_center.name', read_only=True) stats = serializers.SerializerMethodField() enrolled_projects = serializers.SerializerMethodField() coupons = serializers.PrimaryKeyRelatedField(many=True, read_only=True, source='student_coupons') status_display = serializers.CharField(source='get_status_display', read_only=True) + teacher = serializers.PrimaryKeyRelatedField(source='responsible_teacher', queryset=Teacher.objects.all(), required=False, allow_null=True) class Meta: model = Student @@ -127,3 +128,17 @@ class StudentShowcaseSerializer(serializers.ModelSerializer): class Meta: model = StudentShowcase fields = '__all__' + +class NotificationSerializer(serializers.ModelSerializer): + class Meta: + model = Notification + fields = '__all__' + +class NotificationBatchSerializer(serializers.ModelSerializer): + send_mode_display = serializers.CharField(source='get_send_mode_display', read_only=True) + notification_type_display = serializers.CharField(source='get_notification_type_display', read_only=True) + + class Meta: + model = NotificationBatch + fields = '__all__' + diff --git a/admin/server/apps/crm/urls.py b/admin/server/apps/crm/urls.py index 9f8aedf..801015c 100644 --- a/admin/server/apps/crm/urls.py +++ b/admin/server/apps/crm/urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import CategoryViewSet, TeacherViewSet, TeachingCenterViewSet, ProjectViewSet, CouponViewSet, BannerViewSet, StudentViewSet, StudentCouponViewSet, StudentProjectViewSet, StudentHonorViewSet, StudentShowcaseViewSet, UserProfileView, UserCouponsView, UserProjectsView, UserHonorsView, LoginView, UserPhoneView, DashboardStatsView, AvailableCouponsView +from .views import CategoryViewSet, TeacherViewSet, TeachingCenterViewSet, ProjectViewSet, CouponViewSet, BannerViewSet, StudentViewSet, StudentCouponViewSet, StudentProjectViewSet, StudentHonorViewSet, StudentShowcaseViewSet, UserProfileView, UserCouponsView, UserProjectsView, UserHonorsView, LoginView, UserPhoneView, DashboardStatsView, AvailableCouponsView, NotificationViewSet, NotificationBatchViewSet router = DefaultRouter() router.register(r'categories', CategoryViewSet) @@ -14,6 +14,8 @@ router.register(r'student-coupons', StudentCouponViewSet) router.register(r'student-projects', StudentProjectViewSet) router.register(r'student-honors', StudentHonorViewSet) router.register(r'student-showcases', StudentShowcaseViewSet) +router.register(r'notifications', NotificationViewSet, basename='notification') +router.register(r'notification-batches', NotificationBatchViewSet, basename='notification-batch') urlpatterns = [ path('', include(router.urls)), diff --git a/admin/server/apps/crm/views.py b/admin/server/apps/crm/views.py index 68a9200..65cc80d 100644 --- a/admin/server/apps/crm/views.py +++ b/admin/server/apps/crm/views.py @@ -4,10 +4,11 @@ from rest_framework.decorators import action from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly, IsAuthenticated -from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase -from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer +from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase, Notification, NotificationBatch +from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer, NotificationSerializer, NotificationBatchSerializer import requests -from django.db.models import Count, F +import json +from django.db.models import Count, F, Sum from django.utils import timezone from datetime import timedelta, datetime @@ -181,28 +182,47 @@ class DashboardStatsView(APIView): active_showcase_count = StudentShowcase.objects.filter(is_active=True).count() active_project_count = Project.objects.filter(is_active=True).count() - # 2. Pie Chart: Project Types Distribution - # Group by project_type - project_types_data = Project.objects.values('project_type').annotate(count=Count('id')) + # 2. Pie Chart: Project Category Proportion + # Aggregate student counts by project type + projects_data = Project.objects.filter(is_active=True).values('project_type').annotate(total_students=Sum('students')).order_by('-total_students') - # Map type codes to display names + # Define colors for each project type + type_colors = { + 'training': '#36cfc9', # Cyan + 'competition': '#b37feb', # Purple + 'grading': '#409EFF', # Blue + } + + # Map type codes to display names for legend type_mapping = dict(Project.PROJECT_TYPE_CHOICES) - pie_chart_data = [] - for item in project_types_data: - type_code = item['project_type'] - name = type_mapping.get(type_code, type_code) - pie_chart_data.append({ - 'type': type_code, - 'name': name, - 'value': item['count'] + + # Construct Legend Data + pie_chart_legend = [] + for type_code, color in type_colors.items(): + pie_chart_legend.append({ + 'name': type_mapping.get(type_code, type_code), + 'color': color }) + + pie_chart_data = [] + for item in projects_data: + type_code = item['project_type'] + # Use default color if type not found + color = type_colors.get(type_code, '#909399') + pie_chart_data.append({ + 'name': type_mapping.get(type_code, type_code), + 'value': item['total_students'], + 'itemStyle': { 'color': color } + }) + + # If too many, maybe limit? For now, let's keep all active ones as user requested "specific names". + # But if we have 0 students, maybe skip? + # pie_chart_data = [d for d in pie_chart_data if d['value'] > 0] # If empty, provide some defaults to avoid empty chart if not pie_chart_data: pie_chart_data = [ - {'name': '小主持语言培训', 'value': 0}, - {'name': '赛事管理', 'value': 0}, - {'name': '考级管理', 'value': 0} + {'name': '暂无数据', 'value': 0} ] # 3. Bar Chart: Popular dimension (organization/project) @@ -276,6 +296,7 @@ class DashboardStatsView(APIView): 'showcases_active': active_showcase_count }, 'pie_chart_data': pie_chart_data, + 'pie_chart_legend': pie_chart_legend, 'coupon_pie_chart_data': coupon_pie_chart_data, 'bar_chart_data': { 'title': bar_title, @@ -675,3 +696,202 @@ class UserHonorsView(APIView): honors = StudentHonor.objects.filter(student=student) serializer = StudentHonorSerializer(honors, many=True) return Response(serializer.data) + +class NotificationViewSet(viewsets.ModelViewSet): + serializer_class = NotificationSerializer + permission_classes = [AllowAny] + + def get_queryset(self): + # 仅返回当前登录用户的通知 + # 需结合认证系统,假设 request.user.student 存在,或通过 openid 关联 + user = self.request.user + + # Attempt to find the student associated with the request + # Note: In our current mock auth setup, 'user' might be the Django Admin user. + # But the frontend sends 'Authorization: Bearer mock_token_{id}'. + # DRF's default authentication might not parse this mock token into request.user. + # However, for consistency with the plan, let's implement the token parsing logic here + # OR rely on a custom authentication class. + + # Since other views use `get_student_from_token` helper, but ViewSets usually rely on Authentication classes. + # Let's assume we can extract the student from the token here as well. + + student = None + auth = self.request.headers.get('Authorization') + if auth and 'Bearer' in auth: + try: + token_str = auth.split(' ')[1] + if token_str.startswith('mock_token_'): + student_id = int(token_str.split('_')[-1]) + student = Student.objects.get(id=student_id) + except Exception: + pass + + if student: + return Notification.objects.filter(student=student) + + # If accessed by admin user (Django user) + if user.is_staff: + return Notification.objects.all() + + return Notification.objects.none() + + @action(detail=False, methods=['get']) + def unread_count(self, request): + count = self.get_queryset().filter(is_read=False).count() + return Response({'count': count}) + + @action(detail=True, methods=['post']) + def read(self, request, pk=None): + notification = self.get_object() + notification.is_read = True + notification.save() + return Response({'status': 'marked as read'}) + + @action(detail=False, methods=['post']) + def read_all(self, request): + self.get_queryset().filter(is_read=False).update(is_read=True) + return Response({'status': 'all marked as read'}) + +class NotificationBatchViewSet(viewsets.ModelViewSet): + queryset = NotificationBatch.objects.all() + serializer_class = NotificationBatchSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + + def create(self, request, *args, **kwargs): + # 1. Create Batch + data = request.data + send_mode = data.get('send_mode', 'custom') + target_criteria = data.get('target_criteria', {}) + + # Ensure target_criteria is string for TextField + if isinstance(target_criteria, dict): + target_criteria_str = json.dumps(target_criteria) + else: + target_criteria_str = target_criteria + try: + target_criteria = json.loads(target_criteria) + except: + target_criteria = {} + + # Validate basics + # Use partial=True if some fields are missing but not required, though ModelSerializer usually handles it. + # But we modify data to set target_criteria to string. + # So we should create a mutable copy of data if it's immutable (QueryDict) + if hasattr(data, '_mutable'): + data._mutable = True + + data['target_criteria'] = target_criteria_str + + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + batch = serializer.save() + + # 2. Find Recipients + recipients = Student.objects.none() + + if send_mode == 'custom': + student_ids = target_criteria.get('student_ids', []) + select_all = target_criteria.get('select_all', False) + if select_all: + recipients = Student.objects.all() + elif student_ids: + recipients = Student.objects.filter(id__in=student_ids) + + elif send_mode == 'project': + project_id = target_criteria.get('project_id') + statuses = target_criteria.get('statuses', []) # ['enrolled', 'graduated'] + if project_id: + query = {'enrolled_projects__id': project_id} # Filter students by enrolled project + # But we want to filter by the status in that project enrollment? + # StudentProject has status. + # So we should query StudentProject first. + + sp_query = {'project_id': project_id} + if statuses: + sp_query['status__in'] = statuses + + student_ids = StudentProject.objects.filter(**sp_query).values_list('student_id', flat=True) + recipients = Student.objects.filter(id__in=student_ids) + + elif send_mode == 'coupon': + coupon_id = target_criteria.get('coupon_id') + coupon_status = target_criteria.get('status') # 'assigned', 'used', etc. + if coupon_id: + query = {'coupon_id': coupon_id} + if coupon_status: + query['status'] = coupon_status + + student_ids = StudentCoupon.objects.filter(**query).values_list('student_id', flat=True) + recipients = Student.objects.filter(id__in=student_ids) + + # Deduplicate recipients if needed (though IDs set should handle it, but values_list returns list) + recipients = recipients.distinct() + + # 3. Create Notifications + notification_list = [] + count = 0 + for student in recipients: + notification_list.append(Notification( + student=student, + batch=batch, + title=batch.title, + content=batch.content, + notification_type=batch.notification_type + )) + count += 1 + + if notification_list: + Notification.objects.bulk_create(notification_list) + + # 4. Update Count + batch.recipient_count = count + batch.save() + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['get']) + def recipients(self, request, pk=None): + batch = self.get_object() + notifications = Notification.objects.filter(batch=batch).select_related('student', 'student__teaching_center', 'student__responsible_teacher') + + results = [] + # Parse criteria once + try: + criteria = json.loads(batch.target_criteria) + except: + criteria = {} + + for n in notifications: + student = n.student + + project_info = "" + status_info = student.get_status_display() + + if batch.send_mode == 'project': + project_id = criteria.get('project_id') + # Optimally we should prefetch this, but for now loop is okay for admin view + sp = StudentProject.objects.filter(student=student, project_id=project_id).first() + if sp: + project_info = sp.project.title + status_info = sp.get_status_display() + elif batch.send_mode == 'coupon': + coupon_id = criteria.get('coupon_id') + sc = StudentCoupon.objects.filter(student=student, coupon_id=coupon_id).first() + if sc: + project_info = sc.coupon.title + status_info = sc.get_status_display() + + results.append({ + 'id': student.id, + 'student_name': student.name, + 'student_phone': student.phone, + 'teaching_center': student.teaching_center.name if student.teaching_center else (student.responsible_teacher.name if student.responsible_teacher else '-'), + 'project_info': project_info, + 'status_info': status_info, + 'is_read': n.is_read, + 'read_at': None + }) + + return Response(results) + diff --git a/admin/server/apps/system/views.py b/admin/server/apps/system/views.py index bf4f07d..7d2edcf 100644 --- a/admin/server/apps/system/views.py +++ b/admin/server/apps/system/views.py @@ -26,6 +26,7 @@ from io import BytesIO from django.core.files.uploadedfile import InMemoryUploadedFile import sys import os +import socket from .filters import UserFilter from .mixins import CreateUpdateModelAMixin, OptimizationMixin @@ -422,7 +423,27 @@ class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListM # Ensure forward slashes for URL file_name = instance.file.name.replace('\\', '/') - instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name) + + # 开发阶段,本机上传的视频或图片用本机IP保存 + if settings.DEBUG and (type == '视频' or type == '图片'): + try: + # 获取本机IP + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 80)) + ip = s.getsockname()[0] + s.close() + + # 获取端口 + host = self.request.get_host() + port = host.split(':')[1] if ':' in host else '80' + + # 构建URL + instance.path = f"{self.request.scheme}://{ip}:{port}{settings.MEDIA_URL}{file_name}" + except Exception as e: + logger.error(f"获取本机IP失败: {e}") + instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name) + else: + instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name) logger.info(f"File uploaded: {instance.path}") instance.save() diff --git a/admin/server/media/2025/12/02/9rlsKSkhzQvl56d1a5d102c9f1caf86f85f062a80e8d.jpeg b/admin/server/media/2025/12/02/9rlsKSkhzQvl56d1a5d102c9f1caf86f85f062a80e8d.jpeg deleted file mode 100644 index 6f34d26..0000000 Binary files a/admin/server/media/2025/12/02/9rlsKSkhzQvl56d1a5d102c9f1caf86f85f062a80e8d.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/02/AyKcs5uUkIt52fbf96c25cb629bf4a0f12d5a5c7aac2.jpeg b/admin/server/media/2025/12/02/AyKcs5uUkIt52fbf96c25cb629bf4a0f12d5a5c7aac2.jpeg deleted file mode 100644 index b03cdae..0000000 Binary files a/admin/server/media/2025/12/02/AyKcs5uUkIt52fbf96c25cb629bf4a0f12d5a5c7aac2.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/02/DxlPPaRS9MXs68113076c22d00e4aed80c87c13d410a.jpeg b/admin/server/media/2025/12/02/DxlPPaRS9MXs68113076c22d00e4aed80c87c13d410a.jpeg deleted file mode 100644 index b7f904f..0000000 Binary files a/admin/server/media/2025/12/02/DxlPPaRS9MXs68113076c22d00e4aed80c87c13d410a.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/02/EeT5ylW6vP6F4618726347ade593b39e9e951e5dbedd.jpeg b/admin/server/media/2025/12/02/EeT5ylW6vP6F4618726347ade593b39e9e951e5dbedd.jpeg deleted file mode 100644 index 671b028..0000000 Binary files a/admin/server/media/2025/12/02/EeT5ylW6vP6F4618726347ade593b39e9e951e5dbedd.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/02/Jnxf6Tfm9Xwg6bff3d5b865feccb963647fe403828f6.jpeg b/admin/server/media/2025/12/02/Jnxf6Tfm9Xwg6bff3d5b865feccb963647fe403828f6.jpeg deleted file mode 100644 index 3f91f4f..0000000 Binary files a/admin/server/media/2025/12/02/Jnxf6Tfm9Xwg6bff3d5b865feccb963647fe403828f6.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/02/VPxb8SCMNQCB7cefab8d073e8cec6c396a4d42f09da9.jpeg b/admin/server/media/2025/12/02/VPxb8SCMNQCB7cefab8d073e8cec6c396a4d42f09da9.jpeg deleted file mode 100644 index e955d33..0000000 Binary files a/admin/server/media/2025/12/02/VPxb8SCMNQCB7cefab8d073e8cec6c396a4d42f09da9.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/02/iV6Oao4eGzhe20dfe65370696a3ae986e3ffcddce64f.jpeg b/admin/server/media/2025/12/02/iV6Oao4eGzhe20dfe65370696a3ae986e3ffcddce64f.jpeg deleted file mode 100644 index 5225188..0000000 Binary files a/admin/server/media/2025/12/02/iV6Oao4eGzhe20dfe65370696a3ae986e3ffcddce64f.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/02/tmp_2612fa386b369ca7b2218dca6e8f7920.jpg b/admin/server/media/2025/12/02/tmp_2612fa386b369ca7b2218dca6e8f7920.jpg deleted file mode 100644 index 705e8dc..0000000 Binary files a/admin/server/media/2025/12/02/tmp_2612fa386b369ca7b2218dca6e8f7920.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/02/tmp_337c39be48f3b75e5bab8ca85b2e9d85.jpg b/admin/server/media/2025/12/02/tmp_337c39be48f3b75e5bab8ca85b2e9d85.jpg deleted file mode 100644 index 0372f29..0000000 Binary files a/admin/server/media/2025/12/02/tmp_337c39be48f3b75e5bab8ca85b2e9d85.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/02/tmp_869fc2b3b4f9e4867326d6f7936281cc.jpg b/admin/server/media/2025/12/02/tmp_869fc2b3b4f9e4867326d6f7936281cc.jpg deleted file mode 100644 index 61637a5..0000000 Binary files a/admin/server/media/2025/12/02/tmp_869fc2b3b4f9e4867326d6f7936281cc.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/02/tmp_a3d11caec0762fdd89994837bd6ffb35.jpg b/admin/server/media/2025/12/02/tmp_a3d11caec0762fdd89994837bd6ffb35.jpg deleted file mode 100644 index 4106f89..0000000 Binary files a/admin/server/media/2025/12/02/tmp_a3d11caec0762fdd89994837bd6ffb35.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/02/v0lYqlSROlqtb8e13418d667c27355197599923890fc.jpeg b/admin/server/media/2025/12/02/v0lYqlSROlqtb8e13418d667c27355197599923890fc.jpeg deleted file mode 100644 index 7840755..0000000 Binary files a/admin/server/media/2025/12/02/v0lYqlSROlqtb8e13418d667c27355197599923890fc.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/02/yW29QevHbGwIff51300d8ac987e22efdd1f35d7193e7.jpeg b/admin/server/media/2025/12/02/yW29QevHbGwIff51300d8ac987e22efdd1f35d7193e7.jpeg deleted file mode 100644 index 469fe1e..0000000 Binary files a/admin/server/media/2025/12/02/yW29QevHbGwIff51300d8ac987e22efdd1f35d7193e7.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/03/3.png b/admin/server/media/2025/12/03/3.png deleted file mode 100644 index 4e7bda9..0000000 Binary files a/admin/server/media/2025/12/03/3.png and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600.jpg b/admin/server/media/2025/12/03/337-800x600.jpg deleted file mode 100644 index d919ccf..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600_1IwQEZn.jpg b/admin/server/media/2025/12/03/337-800x600_1IwQEZn.jpg deleted file mode 100644 index e141708..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600_1IwQEZn.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600_3HlaD6p.jpg b/admin/server/media/2025/12/03/337-800x600_3HlaD6p.jpg deleted file mode 100644 index d919ccf..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600_3HlaD6p.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600_516klJm.jpg b/admin/server/media/2025/12/03/337-800x600_516klJm.jpg deleted file mode 100644 index e141708..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600_516klJm.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600_NksHM4p.jpg b/admin/server/media/2025/12/03/337-800x600_NksHM4p.jpg deleted file mode 100644 index e141708..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600_NksHM4p.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600_T9ErWvz.jpg b/admin/server/media/2025/12/03/337-800x600_T9ErWvz.jpg deleted file mode 100644 index e141708..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600_T9ErWvz.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600_csSv36N.jpg b/admin/server/media/2025/12/03/337-800x600_csSv36N.jpg deleted file mode 100644 index e141708..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600_csSv36N.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600_gFqnv0X.jpg b/admin/server/media/2025/12/03/337-800x600_gFqnv0X.jpg deleted file mode 100644 index e141708..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600_gFqnv0X.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600_h6FR03U.jpg b/admin/server/media/2025/12/03/337-800x600_h6FR03U.jpg deleted file mode 100644 index e141708..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600_h6FR03U.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/337-800x600_nX1LKw4.jpg b/admin/server/media/2025/12/03/337-800x600_nX1LKw4.jpg deleted file mode 100644 index d919ccf..0000000 Binary files a/admin/server/media/2025/12/03/337-800x600_nX1LKw4.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/avatar.png b/admin/server/media/2025/12/03/avatar.png deleted file mode 100644 index 98d206e..0000000 Binary files a/admin/server/media/2025/12/03/avatar.png and /dev/null differ diff --git a/admin/server/media/2025/12/03/avatar_x9U41YT.png b/admin/server/media/2025/12/03/avatar_x9U41YT.png deleted file mode 100644 index 98d206e..0000000 Binary files a/admin/server/media/2025/12/03/avatar_x9U41YT.png and /dev/null differ diff --git a/admin/server/media/2025/12/03/htty7fRWw52Pf32987251b1c95cea7172efa5dce4e0c.jpeg b/admin/server/media/2025/12/03/htty7fRWw52Pf32987251b1c95cea7172efa5dce4e0c.jpeg deleted file mode 100644 index 6bf21aa..0000000 Binary files a/admin/server/media/2025/12/03/htty7fRWw52Pf32987251b1c95cea7172efa5dce4e0c.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/03/sz5tPgt7V9A2fdc8f8806ddc4228dd7f97803fa87500.jpeg b/admin/server/media/2025/12/03/sz5tPgt7V9A2fdc8f8806ddc4228dd7f97803fa87500.jpeg deleted file mode 100644 index 5ace31c..0000000 Binary files a/admin/server/media/2025/12/03/sz5tPgt7V9A2fdc8f8806ddc4228dd7f97803fa87500.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/03/test_image.jpg b/admin/server/media/2025/12/03/test_image.jpg deleted file mode 100644 index 205f7ee..0000000 Binary files a/admin/server/media/2025/12/03/test_image.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/test_upload.jpg b/admin/server/media/2025/12/03/test_upload.jpg deleted file mode 100644 index 205f7ee..0000000 Binary files a/admin/server/media/2025/12/03/test_upload.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/03/武功山.mp4 b/admin/server/media/2025/12/03/武功山.mp4 deleted file mode 100644 index 0325d03..0000000 Binary files a/admin/server/media/2025/12/03/武功山.mp4 and /dev/null differ diff --git a/admin/server/media/2025/12/04/19ae859c081_01a.jpeg b/admin/server/media/2025/12/04/19ae859c081_01a.jpeg deleted file mode 100644 index d668282..0000000 Binary files a/admin/server/media/2025/12/04/19ae859c081_01a.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/04/337-800x600.jpg b/admin/server/media/2025/12/04/337-800x600.jpg deleted file mode 100644 index e141708..0000000 Binary files a/admin/server/media/2025/12/04/337-800x600.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/04/5ec54904-bcfd-4c21-99d7-fbdc40cd0453.jpg b/admin/server/media/2025/12/04/5ec54904-bcfd-4c21-99d7-fbdc40cd0453.jpg deleted file mode 100644 index 2b6e824..0000000 Binary files a/admin/server/media/2025/12/04/5ec54904-bcfd-4c21-99d7-fbdc40cd0453.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/04/tmp_25d34072bb11c84e3932b2f4db4f8716.jpg b/admin/server/media/2025/12/04/tmp_25d34072bb11c84e3932b2f4db4f8716.jpg deleted file mode 100644 index 1b45e76..0000000 Binary files a/admin/server/media/2025/12/04/tmp_25d34072bb11c84e3932b2f4db4f8716.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/04/tmp_a9fa87559aa96862ec4539e7e2a8ab33.jpg b/admin/server/media/2025/12/04/tmp_a9fa87559aa96862ec4539e7e2a8ab33.jpg deleted file mode 100644 index 5e9435e..0000000 Binary files a/admin/server/media/2025/12/04/tmp_a9fa87559aa96862ec4539e7e2a8ab33.jpg and /dev/null differ diff --git a/admin/server/media/2025/12/04/xE1GNQI6kD8H506b0c40e3c3911e3b5c8c756144ca6d.jpeg b/admin/server/media/2025/12/04/xE1GNQI6kD8H506b0c40e3c3911e3b5c8c756144ca6d.jpeg deleted file mode 100644 index aa104d1..0000000 Binary files a/admin/server/media/2025/12/04/xE1GNQI6kD8H506b0c40e3c3911e3b5c8c756144ca6d.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/05/DvDFt4vSWL18753550ea5c439a253d55a7674623b193.jpeg b/admin/server/media/2025/12/05/DvDFt4vSWL18753550ea5c439a253d55a7674623b193.jpeg deleted file mode 100644 index 4942ebe..0000000 Binary files a/admin/server/media/2025/12/05/DvDFt4vSWL18753550ea5c439a253d55a7674623b193.jpeg and /dev/null differ diff --git a/admin/server/media/2025/12/09/66D5296A-9345-4417-B6E3-EF1214853CA9.png b/admin/server/media/2025/12/09/66D5296A-9345-4417-B6E3-EF1214853CA9.png new file mode 100644 index 0000000..850001d Binary files /dev/null and b/admin/server/media/2025/12/09/66D5296A-9345-4417-B6E3-EF1214853CA9.png differ diff --git a/admin/server/media/2025/12/05/列日大学列日高商EMBA无锡班开学典礼.mp4 b/admin/server/media/2025/12/09/列日大学列日高商EMBA无锡班开学典礼.mp4 similarity index 100% rename from admin/server/media/2025/12/05/列日大学列日高商EMBA无锡班开学典礼.mp4 rename to admin/server/media/2025/12/09/列日大学列日高商EMBA无锡班开学典礼.mp4 diff --git a/admin/server/media/projects/ai.jpg b/admin/server/media/projects/ai.jpg new file mode 100644 index 0000000..bb5679d Binary files /dev/null and b/admin/server/media/projects/ai.jpg differ diff --git a/admin/server/media/projects/alumni.jpg b/admin/server/media/projects/alumni.jpg new file mode 100644 index 0000000..e9af3a6 Binary files /dev/null and b/admin/server/media/projects/alumni.jpg differ diff --git a/admin/server/media/projects/dba.jpg b/admin/server/media/projects/dba.jpg new file mode 100644 index 0000000..e9af3a6 Binary files /dev/null and b/admin/server/media/projects/dba.jpg differ diff --git a/admin/server/media/projects/edba.jpg b/admin/server/media/projects/edba.jpg new file mode 100644 index 0000000..0fcdc42 Binary files /dev/null and b/admin/server/media/projects/edba.jpg differ diff --git a/admin/server/media/projects/forum.jpg b/admin/server/media/projects/forum.jpg new file mode 100644 index 0000000..96a4dde Binary files /dev/null and b/admin/server/media/projects/forum.jpg differ diff --git a/admin/server/media/projects/graduation.jpg b/admin/server/media/projects/graduation.jpg new file mode 100644 index 0000000..6c35353 Binary files /dev/null and b/admin/server/media/projects/graduation.jpg differ diff --git a/admin/server/media/projects/mba.jpg b/admin/server/media/projects/mba.jpg new file mode 100644 index 0000000..0ec56d3 Binary files /dev/null and b/admin/server/media/projects/mba.jpg differ diff --git a/admin/server/media/projects/opening_ceremony.jpg b/admin/server/media/projects/opening_ceremony.jpg new file mode 100644 index 0000000..bec7559 Binary files /dev/null and b/admin/server/media/projects/opening_ceremony.jpg differ diff --git a/admin/server/media/projects/psychology.jpg b/admin/server/media/projects/psychology.jpg new file mode 100644 index 0000000..b6231a8 Binary files /dev/null and b/admin/server/media/projects/psychology.jpg differ diff --git a/admin/server/media/showcases/video_ai.jpg b/admin/server/media/showcases/video_ai.jpg new file mode 100644 index 0000000..ee2eb29 Binary files /dev/null and b/admin/server/media/showcases/video_ai.jpg differ diff --git a/admin/server/media/showcases/video_dba.jpg b/admin/server/media/showcases/video_dba.jpg new file mode 100644 index 0000000..ad2e965 Binary files /dev/null and b/admin/server/media/showcases/video_dba.jpg differ diff --git a/admin/server/media/showcases/video_grad.jpg b/admin/server/media/showcases/video_grad.jpg new file mode 100644 index 0000000..ee2eb29 Binary files /dev/null and b/admin/server/media/showcases/video_grad.jpg differ diff --git a/wechat-mini-program/TRANSFORMATION_PLAN.md b/wechat-mini-program/TRANSFORMATION_PLAN.md new file mode 100644 index 0000000..51dc0ab --- /dev/null +++ b/wechat-mini-program/TRANSFORMATION_PLAN.md @@ -0,0 +1,165 @@ +# 微信小程序首页消息通知按钮改造方案 + +## 1. 改造目标 +将首页顶部导航栏右侧的按钮(现为Emoji或红圈按钮)改造为标准的消息通知入口。该按钮将具备以下功能: +- 展示消息通知图标(铃铛)。 +- 显示未读消息红点或数字角标。 +- 点击跳转至“消息通知”列表页。 + +## 2. UI 设计改造 + +### 2.1 图标替换 +- **现状**:目前使用 `🔔` Emoji 或简单的 CSS 样式。 +- **方案**:替换为高清 SVG 或 PNG 图标,确保在不同分辨率下清晰显示。 +- **资源**:建议添加 `assets/icons/notification.png` 或使用 Base64 SVG。 + +### 2.2 未读消息角标 (Badge) +- 在图标右上角增加红色圆点(Red Dot)或数字徽标(Badge)。 +- **逻辑**: + - 当 `unreadCount > 0` 时显示。 + - 当 `unreadCount > 99` 时显示 `99+`。 + +### 2.3 样式调整 +保持原有半透明磨砂玻璃效果(Frosted Glass),优化布局适配图标和角标。 + +```css +/* 示例样式 */ +.bell-btn { + position: relative; /* 为角标定位 */ + /* 保持原有背景和边框样式 */ +} + +.notification-icon { + width: 40rpx; + height: 40rpx; +} + +.badge { + position: absolute; + top: 10rpx; + right: 10rpx; + background-color: #ff4d4f; + border-radius: 10rpx; /* 圆点或数字形状 */ + /* ... */ +} +``` + +## 3. 交互逻辑 + +### 3.1 点击事件 +- 绑定 `bindtap="handleNotificationClick"` 事件。 +- 点击时提供按压态反馈(`hover-class`)。 + +### 3.2 页面跳转 +- 点击后跳转至新页面 `pages/message/index`。 +- 该页面用于展示系统通知、互动消息等。 + +## 4. 数据逻辑 + +### 4.1 状态管理 +- 在 `index.js` 的 `data` 中增加 `unreadCount` 字段。 + +### 4.2 数据获取 +- 在 `onShow` 生命周期中调用获取未读消息数量的接口(如 `getUnreadMessageCount`)。 +- 暂时可以使用 Mock 数据模拟。 + +## 5. 实施步骤 + +1. **资源准备**:添加铃铛图标文件到 `assets` 目录。 +2. **页面创建**:新建 `pages/message/index` 页面(含 `.js`, `.json`, `.wxml`, `.wxss`)。 +3. **WXML 修改**: + - 将 `index.wxml` 中的 `🔔` 替换为 `` 组件。 + - 添加角标 View,使用 `wx:if` 控制显示。 +4. **WXSS 修改**:调整图标大小和角标位置。 +5. **JS 逻辑**: + - 实现 `handleNotificationClick` 跳转逻辑。 + - 实现 `onShow` 获取未读数逻辑。 +6. **配置更新**:在 `app.json` 中注册新页面。 + +## 6. 后续扩展 +- 支持 WebSocket 实时推送消息通知。 +- 消息页面的分类展示(如:系统通知、活动提醒)。 + +## 7. 后台服务端 Web 方案 + +### 7.1 数据库设计 (Django Models) +在 `admin/server/apps/crm/models.py` 中新增 `Notification` 模型,用于存储消息通知。 + +```python +class Notification(models.Model): + TYPE_CHOICES = ( + ('system', '系统通知'), + ('activity', '活动提醒'), + ('course', '课程通知'), + ) + + student = models.ForeignKey('Student', on_delete=models.CASCADE, related_name='notifications', verbose_name="接收学员") + title = models.CharField(max_length=100, verbose_name="标题") + content = models.TextField(verbose_name="内容") + notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='system', verbose_name="通知类型") + is_read = models.BooleanField(default=False, verbose_name="是否已读") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="发送时间") + + class Meta: + verbose_name = "消息通知" + verbose_name_plural = verbose_name + ordering = ['-created_at'] + + def __str__(self): + return f"{self.title} - {self.student.name}" +``` + +### 7.2 API 接口设计 (Django Rest Framework) +在 `admin/server/apps/crm/views.py` 中新增 `NotificationViewSet`,并在 `urls.py` 中注册。 + +#### 7.2.1 新增 API Endpoints +- **GET /api/crm/notifications/**: 获取当前登录用户的通知列表(支持分页)。 +- **GET /api/crm/notifications/unread_count/**: 获取当前登录用户的未读消息数量。 +- **POST /api/crm/notifications/{id}/read/**: 将指定消息标记为已读。 +- **POST /api/crm/notifications/read_all/**: 将所有消息标记为已读。 + +#### 7.2.2 视图实现逻辑 +```python +class NotificationViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = NotificationSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + # 仅返回当前登录用户的通知 + # 需结合认证系统,假设 request.user.student 存在,或通过 openid 关联 + user = self.request.user + # 实际逻辑需根据 User 与 Student 的关联方式调整 + # 若 User 是 Admin 用户,可能需要调整;若是小程序用户,通常通过 Token 认证 + return Notification.objects.filter(student__openid=user.username) + + @action(detail=False, methods=['get']) + def unread_count(self, request): + count = self.get_queryset().filter(is_read=False).count() + return Response({'count': count}) + + @action(detail=True, methods=['post']) + def read(self, request, pk=None): + notification = self.get_object() + notification.is_read = True + notification.save() + return Response({'status': 'marked as read'}) +``` + +### 7.3 管理后台集成 (Django Admin) +在 `admin/server/apps/crm/admin.py` 中注册 `Notification` 模型,以便管理员手动发送通知。 + +- **功能**: + - 查看所有发送的通知记录。 + - 创建新通知(选择学员、填写标题和内容)。 + - 支持筛选(按类型、是否已读、发送时间)。 + +### 7.4 批量发送功能 (可选扩展) +未来可在管理后台增加“批量发送”功能,例如: +- 向所有“在读”学员发送通知。 +- 向所有领取了特定优惠券的学员发送提醒。 +- 向所有已完成课程的学员发送课程完成通知。 +- 向指定项目/活动参与学员发送通知。 +- 向所有学员发送系统通知(如:课程更新、系统维护等)。 + +--- +**确认方案后,我们将按此步骤进行开发。** diff --git a/wechat-mini-program/app.json b/wechat-mini-program/app.json index 5d46d4c..2760145 100644 --- a/wechat-mini-program/app.json +++ b/wechat-mini-program/app.json @@ -5,7 +5,8 @@ "pages/profile/profile", "pages/coupon/coupon", "pages/detail/detail", - "pages/login/login" + "pages/login/login", + "pages/message/index" ], "window": { "backgroundTextStyle": "light", diff --git a/wechat-mini-program/pages/course/course.js b/wechat-mini-program/pages/course/course.js index 3f15bcc..e52e79a 100644 --- a/wechat-mini-program/pages/course/course.js +++ b/wechat-mini-program/pages/course/course.js @@ -13,8 +13,11 @@ Page({ }, onLoad() { - this.fetchHonors() - this.fetchProjects() + const app = getApp() + if (app.globalData.token) { + this.fetchHonors() + this.fetchProjects() + } }, onShow() { @@ -23,6 +26,12 @@ Page({ getUserInfo() { const app = getApp() + if (!app.globalData.token) { + // Not logged in, clear data + this.setData({ user: {}, honors: [], projects: [] }) + return + } + // Try to get from globalData first to speed up rendering if (app.globalData.userInfo) { this.setData({ user: app.globalData.userInfo }) @@ -36,10 +45,21 @@ Page({ // this.fetchProjects(); }).catch(err => { console.error('Failed to fetch user info', err) + if (err && (err.code === 401 || err.code === 403)) { + // Token invalid, clear it + app.globalData.token = null; + app.globalData.userInfo = null; + this.setData({ user: {}, honors: [], projects: [] }); + } }) }, onPullDownRefresh() { + const app = getApp() + if (!app.globalData.token) { + wx.stopPullDownRefresh() + return + } Promise.all([this.fetchHonors(), this.fetchProjects()]).then(() => { wx.stopPullDownRefresh() }) diff --git a/wechat-mini-program/pages/index/index.js b/wechat-mini-program/pages/index/index.js index ff78698..83807d2 100644 --- a/wechat-mini-program/pages/index/index.js +++ b/wechat-mini-program/pages/index/index.js @@ -12,7 +12,11 @@ Page({ canIUseOpenData: wx.canIUse('open-data.type.userAvatarUrl') && wx.canIUse('open-data.type.userNickName'), // 如需尝试获取用户信息可改为false selectedCategory: 'all', showcases: [], - categoryStats: {} + categoryStats: {}, + unreadCount: 0 + }, + onShow() { + this.fetchUnreadCount(); }, onLoad() { if (wx.getUserProfile) { @@ -30,6 +34,26 @@ Page({ this.setData({ selectedCategory: type }); this.fetchData(type); }, + fetchUnreadCount() { + // Only fetch if logged in + const app = getApp() + if (!app.globalData.token) return; + + console.log('Fetching unread count...'); + const { request } = require('../../utils/request') + request({ url: '/notifications/unread_count/' }) + .then(res => { + if (res && typeof res.count === 'number') { + this.setData({ unreadCount: res.count }) + } + }) + .catch(err => console.error('Fetch unread count error:', err)) + }, + handleNotificationClick() { + wx.navigateTo({ + url: '/pages/message/index' + }) + }, fetchBanners() { const { request } = require('../../utils/request') request({ url: '/banners/?is_active=true' }) diff --git a/wechat-mini-program/pages/index/index.wxml b/wechat-mini-program/pages/index/index.wxml index 91c3da7..ffabe49 100644 --- a/wechat-mini-program/pages/index/index.wxml +++ b/wechat-mini-program/pages/index/index.wxml @@ -6,7 +6,10 @@ 致力成就 终身教育伟大事业 - 🔔 + + + {{unreadCount > 99 ? '99+' : unreadCount}} + diff --git a/wechat-mini-program/pages/index/index.wxss b/wechat-mini-program/pages/index/index.wxss index 89ef94b..8b3166c 100644 --- a/wechat-mini-program/pages/index/index.wxss +++ b/wechat-mini-program/pages/index/index.wxss @@ -54,10 +54,9 @@ .bell-btn { background-color: rgba(255, 255, 255, 0.2); backdrop-filter: blur(4px); - padding: 16rpx; + padding: 0; border-radius: 50%; color: var(--text-white); - font-size: 32rpx; width: 72rpx; height: 72rpx; display: flex; @@ -65,6 +64,25 @@ justify-content: center; box-sizing: border-box; border: 1rpx solid rgba(255, 255, 255, 0.3); + position: relative; +} + +.notification-icon { + width: 40rpx; + height: 40rpx; +} + +.badge { + position: absolute; + top: -6rpx; + right: -6rpx; + background-color: #ff4d4f; + color: white; + font-size: 20rpx; + padding: 4rpx 10rpx; + border-radius: 20rpx; + line-height: 1; + border: 2rpx solid #fff; } .search-bar { diff --git a/wechat-mini-program/pages/message/index.js b/wechat-mini-program/pages/message/index.js new file mode 100644 index 0000000..fd9d2b7 --- /dev/null +++ b/wechat-mini-program/pages/message/index.js @@ -0,0 +1,42 @@ +const app = getApp() +const { request } = require('../../utils/request.js') + +Page({ + data: { + notifications: [], + loading: false + }, + + onShow() { + this.fetchNotifications() + this.readAllNotifications() + }, + + fetchNotifications() { + this.setData({ loading: true }) + request({ url: '/notifications/' }) + .then(res => { + // Handle paginated response if any, or direct list + const list = Array.isArray(res) ? res : (res.results || []) + this.setData({ + notifications: list, + loading: false + }) + }) + .catch(err => { + console.error(err) + this.setData({ loading: false }) + }) + }, + + readAllNotifications() { + request({ + url: '/notifications/read_all/', + method: 'POST' + }) + .then(res => { + console.log('All read') + }) + .catch(err => console.error(err)) + } +}) diff --git a/wechat-mini-program/pages/message/index.json b/wechat-mini-program/pages/message/index.json new file mode 100644 index 0000000..3e50ecb --- /dev/null +++ b/wechat-mini-program/pages/message/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "消息通知" +} diff --git a/wechat-mini-program/pages/message/index.wxml b/wechat-mini-program/pages/message/index.wxml new file mode 100644 index 0000000..a6e4b9c --- /dev/null +++ b/wechat-mini-program/pages/message/index.wxml @@ -0,0 +1,16 @@ + + + + + {{item.title}} + {{item.created_at}} + + + {{item.content}} + + + + + 暂无消息 + + diff --git a/wechat-mini-program/pages/message/index.wxss b/wechat-mini-program/pages/message/index.wxss new file mode 100644 index 0000000..bae1e2f --- /dev/null +++ b/wechat-mini-program/pages/message/index.wxss @@ -0,0 +1,46 @@ +.container { + padding: 20rpx; + background-color: #f8f8f8; + min-height: 100vh; +} + +.notification-item { + background-color: #fff; + border-radius: 12rpx; + padding: 30rpx; + margin-bottom: 20rpx; + box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05); +} + +.notification-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16rpx; +} + +.title { + font-size: 32rpx; + font-weight: bold; + color: #333; +} + +.time { + font-size: 24rpx; + color: #999; +} + +.notification-content { + font-size: 28rpx; + color: #666; + line-height: 1.5; +} + +.empty-state { + display: flex; + justify-content: center; + align-items: center; + padding-top: 200rpx; + color: #999; + font-size: 28rpx; +} diff --git a/wechat-mini-program/pages/profile/profile.wxml b/wechat-mini-program/pages/profile/profile.wxml index f4ee5c7..a81ea48 100644 --- a/wechat-mini-program/pages/profile/profile.wxml +++ b/wechat-mini-program/pages/profile/profile.wxml @@ -13,7 +13,7 @@ {{user.stats.learning}} - 在学课程 + 课程&活动 {{user.stats.coupons}}