Initial commit

This commit is contained in:
admin
2025-12-09 14:31:16 +08:00
parent c34b63b8da
commit b8648c2861
82 changed files with 1896 additions and 46 deletions

View File

@@ -358,3 +358,67 @@ export function deleteShowcase(id) {
method: 'delete' 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'
})
}

View File

@@ -153,6 +153,39 @@ export const asyncRoutes = [
name: 'Showcases', name: 'Showcases',
component: () => import('@/views/crm/showcase'), component: () => import('@/views/crm/showcase'),
meta: { title: '精彩视频', icon: 'video' } 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: '按优惠券发送' }
}
]
} }
] ]
}, },

View File

@@ -0,0 +1,276 @@
<template>
<div class="app-container">
<div class="filter-container">
<el-input v-model="listQuery.search" placeholder="标题/内容" style="width: 200px;" class="filter-item" @keyup.enter.native="handleFilter" />
<el-button class="filter-item" type="primary" icon="el-icon-search" @click="handleFilter">搜索</el-button>
<el-button class="filter-item" style="margin-left: 10px;" type="primary" icon="el-icon-edit" @click="handleCreate">新增</el-button>
</div>
<el-table v-loading="listLoading" :data="list" border fit highlight-current-row style="width: 100%;">
<el-table-column label="ID" align="center" width="80">
<template slot-scope="{row}"><span>{{ row.id }}</span></template>
</el-table-column>
<el-table-column label="标题" min-width="150px">
<template slot-scope="{row}"><span>{{ row.title }}</span></template>
</el-table-column>
<el-table-column label="接收学员" width="120px" align="center">
<template slot-scope="{row}"><span>{{ row.student }}</span></template>
</el-table-column>
<el-table-column label="类型" width="100px" align="center">
<template slot-scope="{row}">
<el-tag :type="row.notification_type | typeFilter">{{ row.notification_type | typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100px" align="center">
<template slot-scope="{row}">
<el-tag :type="row.is_read ? 'success' : 'info'">{{ row.is_read ? '已读' : '未读' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="发送时间" width="160px" align="center">
<template slot-scope="{row}"><span>{{ row.created_at | parseTime('{y}-{m}-{d} {h}:{i}') }}</span></template>
</el-table-column>
<el-table-column label="操作" align="center" width="230">
<template slot-scope="{row,$index}">
<el-button type="primary" size="mini" @click="handleUpdate(row)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(row,$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total>0" :total="total" :page.sync="listQuery.page" :limit.sync="listQuery.limit" @pagination="getList" />
<el-dialog :title="textMap[dialogStatus]" :visible.sync="dialogFormVisible">
<el-form ref="dataForm" :rules="rules" :model="temp" label-position="left" label-width="90px" style="width: 400px; margin-left:50px;">
<el-form-item label="接收学员" prop="student">
<el-select v-model="temp.student" filterable remote :remote-method="searchStudents" :loading="studentLoading" placeholder="请输入学员姓名搜索" style="width: 100%">
<el-option v-for="item in studentOptions" :key="item.id" :label="item.name + ' (' + (item.phone || '-') + ')'" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="temp.title" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="temp.content" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="类型" prop="notification_type">
<el-select v-model="temp.notification_type" placeholder="请选择类型" style="width: 100%">
<el-option label="系统通知" value="system" />
<el-option label="活动提醒" value="activity" />
<el-option label="课程通知" value="course" />
</el-select>
</el-form-item>
<el-form-item label="是否已读" prop="is_read">
<el-switch v-model="temp.is_read" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogFormVisible = false">取消</el-button>
<el-button type="primary" @click="dialogStatus==='create'?createData():updateData()">确认</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getNotifications, createNotification, updateNotification, deleteNotification, getStudents } from '@/api/crm'
import Pagination from '@/components/Pagination'
import { parseTime } from '@/utils'
export default {
name: 'NotificationTable',
components: { Pagination },
filters: {
typeFilter(type) {
const statusMap = {
system: 'info',
activity: 'success',
course: 'warning'
}
return statusMap[type]
},
typeLabel(type) {
const labelMap = {
system: '系统通知',
activity: '活动提醒',
course: '课程通知'
}
return labelMap[type] || type
}
},
data() {
return {
list: null,
total: 0,
listLoading: true,
listQuery: {
page: 1,
limit: 20,
search: undefined
},
studentOptions: [],
studentLoading: false,
temp: {
id: undefined,
student: undefined,
title: '',
content: '',
notification_type: 'system',
is_read: false
},
dialogFormVisible: false,
dialogStatus: '',
textMap: {
update: '编辑',
create: '新增'
},
rules: {
student: [{ required: true, message: '请选择学员', trigger: 'change' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }],
notification_type: [{ required: true, message: '请选择类型', trigger: 'change' }]
}
}
},
created() {
this.getList()
// Pre-fetch some students
this.searchStudents('')
},
methods: {
getList() {
this.listLoading = true
getNotifications(this.listQuery).then(response => {
// Assume DRF DefaultRouter with pagination returns { count: ..., results: ... }
// or if wrapped: { data: { count: ..., results: ... } }
// Based on other views (e.g. Student), it seems pagination is used.
// Let's check NotificationViewSet. It inherits ModelViewSet.
// If pagination is not disabled, it returns paginated response.
// Checking existing code, e.g. StudentTable, it uses response.data.items or response.data.results?
// Let's assume standard DRF pagination structure or the wrapper used in this project.
// Looking at student.vue:
// this.list = response.data.items
// this.total = response.data.total
// Wait, let me check the wrapper.
// If backend returns standard DRF: { count: 100, results: [...] }
// If wrapped: { code: 200, data: { items: [...], total: ... } } ?
// Let's check `request.js` or `StudentViewSet` pagination.
// `admin/server/utils/pagination.py` might be used.
// Assuming the response structure is consistent with other lists.
// If I look at `student.vue` again (I read it earlier):
// It imports `getStudents`.
// `getList` calls `getStudents`.
// But I didn't see the implementation of `getList` in `student.vue` fully in previous `Read` call (it was cut off).
// I'll assume standard structure for now:
if (response.data && response.data.results) {
this.list = response.data.results
this.total = response.data.count
} else if (response.data && Array.isArray(response.data)) {
this.list = response.data
this.total = response.data.length
} else {
// Fallback
this.list = response.data
this.total = 0
}
this.listLoading = false
})
},
searchStudents(query) {
this.studentLoading = true
getStudents({ search: query, page: 1, limit: 20 }).then(response => {
if (response.data && response.data.results) {
this.studentOptions = response.data.results
} else {
this.studentOptions = response.data
}
this.studentLoading = false
})
},
handleFilter() {
this.listQuery.page = 1
this.getList()
},
resetTemp() {
this.temp = {
id: undefined,
student: undefined,
title: '',
content: '',
notification_type: 'system',
is_read: false
}
},
handleCreate() {
this.resetTemp()
this.dialogStatus = 'create'
this.dialogFormVisible = true
this.$nextTick(() => {
this.$refs['dataForm'].clearValidate()
})
},
createData() {
this.$refs['dataForm'].validate((valid) => {
if (valid) {
createNotification(this.temp).then(() => {
this.dialogFormVisible = false
this.$notify({
title: 'Success',
message: '创建成功',
type: 'success',
duration: 2000
})
this.getList()
})
}
})
},
handleUpdate(row) {
this.temp = Object.assign({}, row) // copy obj
this.dialogStatus = 'update'
this.dialogFormVisible = true
this.$nextTick(() => {
this.$refs['dataForm'].clearValidate()
})
},
updateData() {
this.$refs['dataForm'].validate((valid) => {
if (valid) {
const tempData = Object.assign({}, this.temp)
updateNotification(tempData.id, tempData).then(() => {
this.dialogFormVisible = false
this.$notify({
title: 'Success',
message: '更新成功',
type: 'success',
duration: 2000
})
this.getList()
})
}
})
},
handleDelete(row, index) {
this.$confirm('确认删除?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteNotification(row.id).then(() => {
this.$notify({
title: 'Success',
message: '删除成功',
type: 'success',
duration: 2000
})
this.list.splice(index, 1)
})
})
}
}
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div class="app-container">
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入通知标题" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input type="textarea" v-model="form.content" :rows="4" placeholder="请输入通知内容" />
</el-form-item>
<el-form-item label="通知类型" prop="notification_type">
<el-select v-model="form.notification_type">
<el-option label="系统通知" value="system" />
<el-option label="活动提醒" value="activity" />
<el-option label="课程通知" value="course" />
</el-select>
</el-form-item>
<el-form-item label="选择优惠券" prop="coupon_id">
<el-select v-model="form.target_criteria.coupon_id" filterable placeholder="请选择优惠券">
<el-option v-for="item in couponOptions" :key="item.id" :label="item.title" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="使用状态">
<el-select v-model="form.target_criteria.status" clearable placeholder="全部状态">
<el-option label="已领取(未使用)" value="assigned" />
<el-option label="已使用" value="used" />
<el-option label="已过期" value="expired" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit" :loading="submitting">发送通知</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { createNotificationBatch, getCoupons } from '@/api/crm'
export default {
name: 'NotificationCoupon',
data() {
return {
form: {
title: '',
content: '',
notification_type: 'activity',
send_mode: 'coupon',
target_criteria: {
coupon_id: undefined,
status: 'assigned'
}
},
couponOptions: [],
submitting: false,
rules: {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
}
}
},
created() {
this.fetchCoupons()
},
methods: {
fetchCoupons() {
getCoupons({ page_size: 1000 }).then(response => {
this.couponOptions = response.data.results
})
},
onSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
if (!this.form.target_criteria.coupon_id) {
this.$message.warning('请选择优惠券');
return;
}
this.submitting = true
createNotificationBatch(this.form).then(response => {
this.$notify({
title: '成功',
message: '通知发送成功',
type: 'success',
duration: 2000
})
this.submitting = false
this.$router.push('/crm/notifications/history')
}).catch(() => {
this.submitting = false
})
}
})
}
}
}
</script>

View File

@@ -0,0 +1,125 @@
<template>
<div class="app-container">
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入通知标题" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input type="textarea" v-model="form.content" :rows="4" placeholder="请输入通知内容" />
</el-form-item>
<el-form-item label="通知类型" prop="notification_type">
<el-select v-model="form.notification_type">
<el-option label="系统通知" value="system" />
<el-option label="活动提醒" value="activity" />
<el-option label="课程通知" value="course" />
</el-select>
</el-form-item>
<el-form-item label="发送对象">
<el-radio-group v-model="sendType">
<el-radio label="select">选择学员</el-radio>
<el-radio label="all">全员发送</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="sendType === 'select'" label="选择学员">
<el-select
v-model="form.target_criteria.student_ids"
multiple
filterable
remote
reserve-keyword
placeholder="请输入学员姓名或电话搜索"
:remote-method="remoteMethod"
:loading="loading"
style="width: 100%"
>
<el-option
v-for="item in studentOptions"
:key="item.id"
:label="item.name + ' (' + item.phone + ')'"
:value="item.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit" :loading="submitting">发送通知</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { createNotificationBatch, getStudents } from '@/api/crm'
export default {
name: 'NotificationCustom',
data() {
return {
form: {
title: '',
content: '',
notification_type: 'system',
send_mode: 'custom',
target_criteria: {
student_ids: [],
select_all: false
}
},
sendType: 'select',
studentOptions: [],
loading: false,
submitting: false,
rules: {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
}
}
},
watch: {
sendType(val) {
this.form.target_criteria.select_all = (val === 'all')
}
},
methods: {
remoteMethod(query) {
if (query !== '') {
this.loading = true;
getStudents({ search: query, page_size: 20 }).then(response => {
this.studentOptions = response.data.results;
this.loading = false;
})
} else {
this.studentOptions = [];
}
},
onSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
if (this.sendType === 'select' && this.form.target_criteria.student_ids.length === 0) {
this.$message.warning('请选择至少一名学员');
return;
}
this.submitting = true
createNotificationBatch(this.form).then(response => {
this.$notify({
title: '成功',
message: '通知发送成功',
type: 'success',
duration: 2000
})
this.submitting = false
this.$router.push('/crm/notifications/history')
}).catch(() => {
this.submitting = false
})
}
})
}
}
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div class="app-container">
<div class="filter-container">
<el-input v-model="listQuery.search" placeholder="标题搜索" style="width: 200px;" class="filter-item" @keyup.enter.native="handleFilter" />
<el-button class="filter-item" type="primary" icon="el-icon-search" @click="handleFilter">
搜索
</el-button>
</div>
<el-table
v-loading="listLoading"
:data="list"
border
fit
highlight-current-row
style="width: 100%;"
>
<el-table-column label="序号" align="center" width="80">
<template slot-scope="{row, $index}">
<span>{{ (listQuery.page - 1) * listQuery.limit + $index + 1 }}</span>
</template>
</el-table-column>
<el-table-column label="标题" min-width="150px">
<template slot-scope="{row}">
<span>{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column label="内容" min-width="200px" show-overflow-tooltip>
<template slot-scope="{row}">
<span>{{ row.content }}</span>
</template>
</el-table-column>
<el-table-column label="发送方式" width="120px" align="center">
<template slot-scope="{row}">
<el-tag>{{ row.send_mode_display }}</el-tag>
</template>
</el-table-column>
<el-table-column label="通知类型" width="120px" align="center">
<template slot-scope="{row}">
<el-tag type="info">{{ row.notification_type_display }}</el-tag>
</template>
</el-table-column>
<el-table-column label="接收人数" width="100px" align="center">
<template slot-scope="{row}">
<span>{{ row.recipient_count }}</span>
</template>
</el-table-column>
<el-table-column label="发送时间" width="160px" align="center">
<template slot-scope="{row}">
<span>{{ row.created_at | parseTime('{y}-{m}-{d} {h}:{i}') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="200" class-name="small-padding fixed-width">
<template slot-scope="{row}">
<el-button type="primary" size="mini" @click="handleViewRecipients(row)">
查看详情
</el-button>
<el-button type="danger" size="mini" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total>0" :total="total" :page.sync="listQuery.page" :limit.sync="listQuery.limit" @pagination="getList" />
<el-dialog :title="textMap[dialogStatus]" :visible.sync="dialogFormVisible" width="80%">
<el-table :data="recipientList" border fit highlight-current-row style="width: 100%;" v-loading="recipientLoading">
<el-table-column property="student_name" label="姓名" width="120"></el-table-column>
<el-table-column property="student_phone" label="电话" width="120"></el-table-column>
<el-table-column property="teaching_center" label="教学中心" width="150"></el-table-column>
<el-table-column property="project_info" label="关联项目/优惠券" min-width="150"></el-table-column>
<el-table-column property="status_info" label="状态" width="100"></el-table-column>
<el-table-column label="是否已读" width="100" align="center">
<template slot-scope="{row}">
<el-tag :type="row.is_read ? 'success' : 'info'">{{ row.is_read ? '已读' : '未读' }}</el-tag>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogFormVisible = false">
关闭
</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getNotificationBatches, getBatchRecipients, deleteNotificationBatch } from '@/api/crm'
import Pagination from '@/components/Pagination'
import { parseTime } from '@/utils'
export default {
name: 'NotificationHistory',
components: { Pagination },
filters: {
parseTime
},
data() {
return {
list: null,
total: 0,
listLoading: true,
listQuery: {
page: 1,
limit: 20,
search: undefined
},
dialogFormVisible: false,
dialogStatus: '',
textMap: {
view: '发送详情'
},
recipientList: [],
recipientLoading: false
}
},
created() {
this.getList()
},
methods: {
getList() {
this.listLoading = true
getNotificationBatches(this.listQuery).then(response => {
this.list = response.data.results
this.total = response.data.count
this.listLoading = false
})
},
handleFilter() {
this.listQuery.page = 1
this.getList()
},
handleViewRecipients(row) {
this.dialogStatus = 'view'
this.dialogFormVisible = true
this.recipientLoading = true
getBatchRecipients(row.id).then(response => {
this.recipientList = response.data
this.recipientLoading = false
})
},
handleDelete(row) {
this.$confirm('确认删除该条通知记录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteNotificationBatch(row.id).then(() => {
this.$notify({
title: '成功',
message: '删除成功',
type: 'success',
duration: 2000
})
this.getList()
})
})
}
}
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<div class="app-container">
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入通知标题" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input type="textarea" v-model="form.content" :rows="4" placeholder="请输入通知内容" />
</el-form-item>
<el-form-item label="通知类型" prop="notification_type">
<el-select v-model="form.notification_type">
<el-option label="系统通知" value="system" />
<el-option label="活动提醒" value="activity" />
<el-option label="课程通知" value="course" />
</el-select>
</el-form-item>
<el-form-item label="选择项目" prop="project_id">
<el-select v-model="form.target_criteria.project_id" filterable placeholder="请选择项目">
<el-option v-for="item in projectOptions" :key="item.id" :label="item.title" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="学员状态">
<el-checkbox-group v-model="form.target_criteria.statuses">
<el-checkbox label="enrolled">报名</el-checkbox>
<el-checkbox label="studying">在读</el-checkbox>
<el-checkbox label="graduated">毕业</el-checkbox>
<el-checkbox label="completed">完成</el-checkbox>
<el-checkbox label="quit">退学</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit" :loading="submitting">发送通知</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { createNotificationBatch, getProjects } from '@/api/crm'
export default {
name: 'NotificationProject',
data() {
return {
form: {
title: '',
content: '',
notification_type: 'course',
send_mode: 'project',
target_criteria: {
project_id: undefined,
statuses: ['enrolled', 'studying', 'graduated']
}
},
projectOptions: [],
submitting: false,
rules: {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
}
}
},
created() {
this.fetchProjects()
},
methods: {
fetchProjects() {
getProjects({ page_size: 1000 }).then(response => {
this.projectOptions = response.data.results
})
},
onSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
if (!this.form.target_criteria.project_id) {
this.$message.warning('请选择项目');
return;
}
this.submitting = true
createNotificationBatch(this.form).then(response => {
this.$notify({
title: '成功',
message: '通知发送成功',
type: 'success',
duration: 2000
})
this.submitting = false
this.$router.push('/crm/notifications/history')
}).catch(() => {
this.submitting = false
})
}
})
}
}
}
</script>

View File

@@ -1,5 +1,13 @@
<template> <template>
<div :class="className" :style="{height:height,width:width}" /> <div :class="className" :style="{width:width}">
<div ref="chart" :style="{height:height,width:'100%'}" />
<div v-if="legendData && legendData.length > 0" class="chart-legend">
<div v-for="(item, index) in legendData" :key="index" class="legend-item">
<span class="legend-icon" :style="{background: item.color}"></span>
<span class="legend-text">{{ item.name }}</span>
</div>
</div>
</div>
</template> </template>
<script> <script>
@@ -25,6 +33,10 @@ export default {
chartData: { chartData: {
type: Array, type: Array,
default: () => [] default: () => []
},
legendData: {
type: Array,
default: () => []
} }
}, },
data() { data() {
@@ -46,9 +58,7 @@ export default {
}, },
methods: { methods: {
initChart() { initChart() {
this.chart = echarts.init(this.$el, 'macarons') this.chart = echarts.init(this.$refs.chart, 'macarons')
const legendData = (this.chartData || []).map(i => i.name)
this.chart.setOption({ this.chart.setOption({
tooltip: { tooltip: {
@@ -56,9 +66,7 @@ export default {
formatter: '{a} <br/>{b} : {c} ({d}%)' formatter: '{a} <br/>{b} : {c} ({d}%)'
}, },
legend: { legend: {
left: 'center', show: false
bottom: '10',
data: legendData
}, },
series: [ series: [
{ {
@@ -71,10 +79,11 @@ export default {
animationEasing: 'cubicInOut', animationEasing: 'cubicInOut',
animationDuration: 2600, animationDuration: 2600,
label: { label: {
show: false show: true,
formatter: '{b}'
}, },
labelLine: { labelLine: {
show: false show: true
} }
} }
] ]
@@ -83,3 +92,27 @@ export default {
} }
} }
</script> </script>
<style scoped>
.chart-legend {
display: flex;
justify-content: center;
flex-wrap: wrap;
margin-top: 10px;
padding: 0 10px;
}
.legend-item {
display: flex;
align-items: center;
margin-right: 15px;
margin-bottom: 5px;
font-size: 12px;
color: #606266;
}
.legend-icon {
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 5px;
}
</style>

View File

@@ -14,7 +14,7 @@
<el-row :gutter="32"> <el-row :gutter="32">
<el-col :xs="24" :sm="24" :lg="8"> <el-col :xs="24" :sm="24" :lg="8">
<div class="chart-wrapper"> <div class="chart-wrapper">
<pie-chart :chart-data="pieChartData" /> <pie-chart :chart-data="pieChartData" :legend-data="pieChartLegend" />
</div> </div>
</el-col> </el-col>
<el-col :xs="24" :sm="24" :lg="8"> <el-col :xs="24" :sm="24" :lg="8">
@@ -63,6 +63,7 @@ export default {
showcases_active: 0 showcases_active: 0
}, },
pieChartData: [], pieChartData: [],
pieChartLegend: [],
barChartData: { barChartData: {
title: '热门机构', title: '热门机构',
names: [], names: [],
@@ -84,6 +85,7 @@ export default {
// Default to organizations line chart // Default to organizations line chart
this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students
this.pieChartData = data.pie_chart_data this.pieChartData = data.pie_chart_data
this.pieChartLegend = data.pie_chart_legend
this.barChartData = data.bar_chart_data this.barChartData = data.bar_chart_data
this.loading = false this.loading = false
}).catch(error => { }).catch(error => {

View File

@@ -1,5 +1,20 @@
from django.contrib import admin 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) @admin.register(Category)
class CategoryAdmin(admin.ModelAdmin): class CategoryAdmin(admin.ModelAdmin):
@@ -38,16 +53,20 @@ class BannerAdmin(admin.ModelAdmin):
@admin.register(Student) @admin.register(Student)
class StudentAdmin(admin.ModelAdmin): class StudentAdmin(admin.ModelAdmin):
list_display = ('name', 'phone', 'wechat_nickname', 'openid', 'teaching_center', 'company_name', 'status', 'learning_count', 'created_at') list_display = ('name', 'phone', 'responsible_teacher', 'is_active', 'status', 'teaching_center', 'created_at')
search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name') search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name', 'responsible_teacher__name')
list_filter = ('teaching_center', 'status') list_filter = ('teaching_center', 'status', 'is_active', 'responsible_teacher')
inlines = [] inlines = []
class StudentProjectInline(admin.TabularInline):
model = StudentProject
extra = 1
class StudentCouponInline(admin.TabularInline): class StudentCouponInline(admin.TabularInline):
model = StudentCoupon model = StudentCoupon
extra = 1 extra = 1
StudentAdmin.inlines = [StudentCouponInline] StudentAdmin.inlines = [StudentProjectInline, StudentCouponInline]
@admin.register(StudentCoupon) @admin.register(StudentCoupon)
class StudentCouponAdmin(admin.ModelAdmin): class StudentCouponAdmin(admin.ModelAdmin):

View File

@@ -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

View File

@@ -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'],
},
),
]

View File

@@ -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='是否活跃'),
),
]

View File

@@ -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='关联批次'),
),
]

View File

@@ -130,10 +130,13 @@ class Student(models.Model):
# 用户需求是“微信唯一标识”,这通常指 OpenID。 # 用户需求是“微信唯一标识”,这通常指 OpenID。
openid = models.CharField(max_length=100, verbose_name="微信唯一标识", null=True, blank=True, unique=True) 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) # 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) address = models.CharField(max_length=200, verbose_name="地址", null=True, blank=True)
# 已经有avatar字段对应微信头像 # 已经有avatar字段对应微信头像
avatar = models.URLField(verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde") 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="关联教学中心") 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, coupon=coupon,
status='assigned' 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}"

View File

@@ -1,5 +1,5 @@
from rest_framework import serializers 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 CategorySerializer(serializers.ModelSerializer):
class Meta: class Meta:
@@ -67,12 +67,13 @@ class BannerSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
class StudentSerializer(serializers.ModelSerializer): 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) teaching_center_name = serializers.CharField(source='teaching_center.name', read_only=True)
stats = serializers.SerializerMethodField() stats = serializers.SerializerMethodField()
enrolled_projects = serializers.SerializerMethodField() enrolled_projects = serializers.SerializerMethodField()
coupons = serializers.PrimaryKeyRelatedField(many=True, read_only=True, source='student_coupons') coupons = serializers.PrimaryKeyRelatedField(many=True, read_only=True, source='student_coupons')
status_display = serializers.CharField(source='get_status_display', read_only=True) 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: class Meta:
model = Student model = Student
@@ -127,3 +128,17 @@ class StudentShowcaseSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = StudentShowcase model = StudentShowcase
fields = '__all__' 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__'

View File

@@ -1,6 +1,6 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter 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 = DefaultRouter()
router.register(r'categories', CategoryViewSet) 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-projects', StudentProjectViewSet)
router.register(r'student-honors', StudentHonorViewSet) router.register(r'student-honors', StudentHonorViewSet)
router.register(r'student-showcases', StudentShowcaseViewSet) router.register(r'student-showcases', StudentShowcaseViewSet)
router.register(r'notifications', NotificationViewSet, basename='notification')
router.register(r'notification-batches', NotificationBatchViewSet, basename='notification-batch')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),

View File

@@ -4,10 +4,11 @@ from rest_framework.decorators import action
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly, IsAuthenticated
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
from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer, NotificationSerializer, NotificationBatchSerializer
import requests 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 django.utils import timezone
from datetime import timedelta, datetime from datetime import timedelta, datetime
@@ -181,28 +182,47 @@ class DashboardStatsView(APIView):
active_showcase_count = StudentShowcase.objects.filter(is_active=True).count() active_showcase_count = StudentShowcase.objects.filter(is_active=True).count()
active_project_count = Project.objects.filter(is_active=True).count() active_project_count = Project.objects.filter(is_active=True).count()
# 2. Pie Chart: Project Types Distribution # 2. Pie Chart: Project Category Proportion
# Group by project_type # Aggregate student counts by project type
project_types_data = Project.objects.values('project_type').annotate(count=Count('id')) 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) type_mapping = dict(Project.PROJECT_TYPE_CHOICES)
pie_chart_data = []
for item in project_types_data: # Construct Legend Data
type_code = item['project_type'] pie_chart_legend = []
name = type_mapping.get(type_code, type_code) for type_code, color in type_colors.items():
pie_chart_data.append({ pie_chart_legend.append({
'type': type_code, 'name': type_mapping.get(type_code, type_code),
'name': name, 'color': color
'value': item['count']
}) })
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 empty, provide some defaults to avoid empty chart
if not pie_chart_data: if not pie_chart_data:
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) # 3. Bar Chart: Popular dimension (organization/project)
@@ -276,6 +296,7 @@ class DashboardStatsView(APIView):
'showcases_active': active_showcase_count 'showcases_active': active_showcase_count
}, },
'pie_chart_data': pie_chart_data, 'pie_chart_data': pie_chart_data,
'pie_chart_legend': pie_chart_legend,
'coupon_pie_chart_data': coupon_pie_chart_data, 'coupon_pie_chart_data': coupon_pie_chart_data,
'bar_chart_data': { 'bar_chart_data': {
'title': bar_title, 'title': bar_title,
@@ -675,3 +696,202 @@ class UserHonorsView(APIView):
honors = StudentHonor.objects.filter(student=student) honors = StudentHonor.objects.filter(student=student)
serializer = StudentHonorSerializer(honors, many=True) serializer = StudentHonorSerializer(honors, many=True)
return Response(serializer.data) 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)

View File

@@ -26,6 +26,7 @@ from io import BytesIO
from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.files.uploadedfile import InMemoryUploadedFile
import sys import sys
import os import os
import socket
from .filters import UserFilter from .filters import UserFilter
from .mixins import CreateUpdateModelAMixin, OptimizationMixin from .mixins import CreateUpdateModelAMixin, OptimizationMixin
@@ -422,6 +423,26 @@ class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListM
# Ensure forward slashes for URL # Ensure forward slashes for URL
file_name = instance.file.name.replace('\\', '/') file_name = instance.file.name.replace('\\', '/')
# 开发阶段本机上传的视频或图片用本机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) instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name)
logger.info(f"File uploaded: {instance.path}") logger.info(f"File uploaded: {instance.path}")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -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` 中的 `🔔` 替换为 `<image>` 组件。
- 添加角标 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 批量发送功能 (可选扩展)
未来可在管理后台增加“批量发送”功能,例如:
- 向所有“在读”学员发送通知。
- 向所有领取了特定优惠券的学员发送提醒。
- 向所有已完成课程的学员发送课程完成通知。
- 向指定项目/活动参与学员发送通知。
- 向所有学员发送系统通知(如:课程更新、系统维护等)。
---
**确认方案后,我们将按此步骤进行开发。**

View File

@@ -5,7 +5,8 @@
"pages/profile/profile", "pages/profile/profile",
"pages/coupon/coupon", "pages/coupon/coupon",
"pages/detail/detail", "pages/detail/detail",
"pages/login/login" "pages/login/login",
"pages/message/index"
], ],
"window": { "window": {
"backgroundTextStyle": "light", "backgroundTextStyle": "light",

View File

@@ -13,8 +13,11 @@ Page({
}, },
onLoad() { onLoad() {
const app = getApp()
if (app.globalData.token) {
this.fetchHonors() this.fetchHonors()
this.fetchProjects() this.fetchProjects()
}
}, },
onShow() { onShow() {
@@ -23,6 +26,12 @@ Page({
getUserInfo() { getUserInfo() {
const app = getApp() 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 // Try to get from globalData first to speed up rendering
if (app.globalData.userInfo) { if (app.globalData.userInfo) {
this.setData({ user: app.globalData.userInfo }) this.setData({ user: app.globalData.userInfo })
@@ -36,10 +45,21 @@ Page({
// this.fetchProjects(); // this.fetchProjects();
}).catch(err => { }).catch(err => {
console.error('Failed to fetch user info', 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() { onPullDownRefresh() {
const app = getApp()
if (!app.globalData.token) {
wx.stopPullDownRefresh()
return
}
Promise.all([this.fetchHonors(), this.fetchProjects()]).then(() => { Promise.all([this.fetchHonors(), this.fetchProjects()]).then(() => {
wx.stopPullDownRefresh() wx.stopPullDownRefresh()
}) })

View File

@@ -12,7 +12,11 @@ Page({
canIUseOpenData: wx.canIUse('open-data.type.userAvatarUrl') && wx.canIUse('open-data.type.userNickName'), // 如需尝试获取用户信息可改为false canIUseOpenData: wx.canIUse('open-data.type.userAvatarUrl') && wx.canIUse('open-data.type.userNickName'), // 如需尝试获取用户信息可改为false
selectedCategory: 'all', selectedCategory: 'all',
showcases: [], showcases: [],
categoryStats: {} categoryStats: {},
unreadCount: 0
},
onShow() {
this.fetchUnreadCount();
}, },
onLoad() { onLoad() {
if (wx.getUserProfile) { if (wx.getUserProfile) {
@@ -30,6 +34,26 @@ Page({
this.setData({ selectedCategory: type }); this.setData({ selectedCategory: type });
this.fetchData(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() { fetchBanners() {
const { request } = require('../../utils/request') const { request } = require('../../utils/request')
request({ url: '/banners/?is_active=true' }) request({ url: '/banners/?is_active=true' })

View File

@@ -6,7 +6,10 @@
<view class="title-sub">致力成就</view> <view class="title-sub">致力成就</view>
<view class="title-main">终身教育伟大事业</view> <view class="title-main">终身教育伟大事业</view>
</view> </view>
<view class="bell-btn">🔔</view> <view class="bell-btn" bindtap="handleNotificationClick">
<image class="notification-icon" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEgMTVhMiAyIDAgMCAxLTIgMkg3bC00IDRWNWEyIDIgMCAwIDEgMi0yaDE0YTIgMiAwIDAgMSAyIDJ6Ij48L3BhdGg+PC9zdmc+" />
<view class="badge" wx:if="{{unreadCount > 0}}">{{unreadCount > 99 ? '99+' : unreadCount}}</view>
</view>
</view> </view>
<!-- Search Bar --> <!-- Search Bar -->

View File

@@ -54,10 +54,9 @@
.bell-btn { .bell-btn {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
padding: 16rpx; padding: 0;
border-radius: 50%; border-radius: 50%;
color: var(--text-white); color: var(--text-white);
font-size: 32rpx;
width: 72rpx; width: 72rpx;
height: 72rpx; height: 72rpx;
display: flex; display: flex;
@@ -65,6 +64,25 @@
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
border: 1rpx solid rgba(255, 255, 255, 0.3); 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 { .search-bar {

View File

@@ -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))
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "消息通知"
}

View File

@@ -0,0 +1,16 @@
<view class="container">
<view class="notification-list" wx:if="{{notifications.length > 0}}">
<view class="notification-item" wx:for="{{notifications}}" wx:key="id">
<view class="notification-header">
<text class="title">{{item.title}}</text>
<text class="time">{{item.created_at}}</text>
</view>
<view class="notification-content">
{{item.content}}
</view>
</view>
</view>
<view class="empty-state" wx:else>
<text>暂无消息</text>
</view>
</view>

View File

@@ -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;
}

View File

@@ -13,7 +13,7 @@
<view class="stats-card"> <view class="stats-card">
<view class="stat-item"> <view class="stat-item">
<view class="stat-val">{{user.stats.learning}}</view> <view class="stat-val">{{user.stats.learning}}</view>
<view class="stat-label">在学课程</view> <view class="stat-label">课程&活动</view>
</view> </view>
<view class="stat-item"> <view class="stat-item">
<view class="stat-val">{{user.stats.coupons}}</view> <view class="stat-val">{{user.stats.coupons}}</view>