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 @@
+
+
+
+
+ 搜索
+ 新增
+
+
+
+
+ {{ row.id }}
+
+
+ {{ row.title }}
+
+
+ {{ row.student }}
+
+
+
+ {{ row.notification_type | typeLabel }}
+
+
+
+
+ {{ row.is_read ? '已读' : '未读' }}
+
+
+
+ {{ row.created_at | parseTime('{y}-{m}-{d} {h}:{i}') }}
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ 搜索
+
+
+
+
+
+
+ {{ (listQuery.page - 1) * listQuery.limit + $index + 1 }}
+
+
+
+
+ {{ row.title }}
+
+
+
+
+ {{ row.content }}
+
+
+
+
+ {{ row.send_mode_display }}
+
+
+
+
+ {{ row.notification_type_display }}
+
+
+
+
+ {{ row.recipient_count }}
+
+
+
+
+ {{ row.created_at | parseTime('{y}-{m}-{d} {h}:{i}') }}
+
+
+
+
+
+ 查看详情
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.is_read ? '已读' : '未读' }}
+
+
+
+
+
+
+
+
+
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 @@
-
+
+
+
+
+
+ {{ item.name }}
+
+
+
+
+
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.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}}