Initial commit
@@ -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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '按优惠券发送' }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
276
admin/client/src/views/crm/notification.vue
Normal 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>
|
||||||
101
admin/client/src/views/crm/notification/coupon.vue
Normal 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>
|
||||||
125
admin/client/src/views/crm/notification/custom.vue
Normal 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>
|
||||||
163
admin/client/src/views/crm/notification/history.vue
Normal 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>
|
||||||
103
admin/client/src/views/crm/notification/project.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
31
admin/server/apps/crm/migrations/0028_notification.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
28
admin/server/apps/crm/migrations/0029_auto_20251209_1134.py
Normal 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='是否活跃'),
|
||||||
|
),
|
||||||
|
]
|
||||||
37
admin/server/apps/crm/migrations/0030_auto_20251209_1207.py
Normal 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='关联批次'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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__'
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 361 B |
|
Before Width: | Height: | Size: 361 B |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 141 KiB |
BIN
admin/server/media/projects/ai.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
admin/server/media/projects/alumni.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
admin/server/media/projects/dba.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
admin/server/media/projects/edba.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
admin/server/media/projects/forum.jpg
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
admin/server/media/projects/graduation.jpg
Normal file
|
After Width: | Height: | Size: 370 KiB |
BIN
admin/server/media/projects/mba.jpg
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
admin/server/media/projects/opening_ceremony.jpg
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
admin/server/media/projects/psychology.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
admin/server/media/showcases/video_ai.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
admin/server/media/showcases/video_dba.jpg
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
admin/server/media/showcases/video_grad.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
165
wechat-mini-program/TRANSFORMATION_PLAN.md
Normal 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 批量发送功能 (可选扩展)
|
||||||
|
未来可在管理后台增加“批量发送”功能,例如:
|
||||||
|
- 向所有“在读”学员发送通知。
|
||||||
|
- 向所有领取了特定优惠券的学员发送提醒。
|
||||||
|
- 向所有已完成课程的学员发送课程完成通知。
|
||||||
|
- 向指定项目/活动参与学员发送通知。
|
||||||
|
- 向所有学员发送系统通知(如:课程更新、系统维护等)。
|
||||||
|
|
||||||
|
---
|
||||||
|
**确认方案后,我们将按此步骤进行开发。**
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
42
wechat-mini-program/pages/message/index.js
Normal 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))
|
||||||
|
}
|
||||||
|
})
|
||||||
3
wechat-mini-program/pages/message/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "消息通知"
|
||||||
|
}
|
||||||
16
wechat-mini-program/pages/message/index.wxml
Normal 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>
|
||||||
46
wechat-mini-program/pages/message/index.wxss
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||