Initial commit
@@ -358,3 +358,67 @@ export function deleteShowcase(id) {
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// Notifications
|
||||
export function getNotifications(query) {
|
||||
return request({
|
||||
url: '/notifications/',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
export function createNotification(data) {
|
||||
return request({
|
||||
url: '/notifications/',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateNotification(id, data) {
|
||||
return request({
|
||||
url: `/notifications/${id}/`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteNotification(id) {
|
||||
return request({
|
||||
url: `/notifications/${id}/`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// Notification Batches
|
||||
export function getNotificationBatches(query) {
|
||||
return request({
|
||||
url: '/notification-batches/',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
export function createNotificationBatch(data) {
|
||||
return request({
|
||||
url: '/notification-batches/',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getBatchRecipients(id) {
|
||||
return request({
|
||||
url: `/notification-batches/${id}/recipients/`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteNotificationBatch(id) {
|
||||
return request({
|
||||
url: `/notification-batches/${id}/`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,39 @@ export const asyncRoutes = [
|
||||
name: 'Showcases',
|
||||
component: () => import('@/views/crm/showcase'),
|
||||
meta: { title: '精彩视频', icon: 'video' }
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'Notifications',
|
||||
component: () => import('@/views/crm/index'),
|
||||
redirect: '/crm/notifications/history',
|
||||
meta: { title: '小程序通知', icon: 'message' },
|
||||
children: [
|
||||
{
|
||||
path: 'history',
|
||||
name: 'NotificationHistory',
|
||||
component: () => import('@/views/crm/notification/history'),
|
||||
meta: { title: '发送记录' }
|
||||
},
|
||||
{
|
||||
path: 'custom',
|
||||
name: 'NotificationCustom',
|
||||
component: () => import('@/views/crm/notification/custom'),
|
||||
meta: { title: '自定义发送' }
|
||||
},
|
||||
{
|
||||
path: 'project',
|
||||
name: 'NotificationProject',
|
||||
component: () => import('@/views/crm/notification/project'),
|
||||
meta: { title: '按项目发送' }
|
||||
},
|
||||
{
|
||||
path: 'coupon',
|
||||
name: 'NotificationCoupon',
|
||||
component: () => import('@/views/crm/notification/coupon'),
|
||||
meta: { title: '按优惠券发送' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
@@ -25,6 +33,10 @@ export default {
|
||||
chartData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
legendData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -46,9 +58,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
initChart() {
|
||||
this.chart = echarts.init(this.$el, 'macarons')
|
||||
|
||||
const legendData = (this.chartData || []).map(i => i.name)
|
||||
this.chart = echarts.init(this.$refs.chart, 'macarons')
|
||||
|
||||
this.chart.setOption({
|
||||
tooltip: {
|
||||
@@ -56,9 +66,7 @@ export default {
|
||||
formatter: '{a} <br/>{b} : {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
left: 'center',
|
||||
bottom: '10',
|
||||
data: legendData
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -71,10 +79,11 @@ export default {
|
||||
animationEasing: 'cubicInOut',
|
||||
animationDuration: 2600,
|
||||
label: {
|
||||
show: false
|
||||
show: true,
|
||||
formatter: '{b}'
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
show: true
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -83,3 +92,27 @@ export default {
|
||||
}
|
||||
}
|
||||
</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-col :xs="24" :sm="24" :lg="8">
|
||||
<div class="chart-wrapper">
|
||||
<pie-chart :chart-data="pieChartData" />
|
||||
<pie-chart :chart-data="pieChartData" :legend-data="pieChartLegend" />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :lg="8">
|
||||
@@ -63,6 +63,7 @@ export default {
|
||||
showcases_active: 0
|
||||
},
|
||||
pieChartData: [],
|
||||
pieChartLegend: [],
|
||||
barChartData: {
|
||||
title: '热门机构',
|
||||
names: [],
|
||||
@@ -84,6 +85,7 @@ export default {
|
||||
// Default to organizations line chart
|
||||
this.lineChartData = this.allLineChartData.organizations || this.allLineChartData.students
|
||||
this.pieChartData = data.pie_chart_data
|
||||
this.pieChartLegend = data.pie_chart_legend
|
||||
this.barChartData = data.bar_chart_data
|
||||
this.loading = false
|
||||
}).catch(error => {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
from django.contrib import admin
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentHonor, StudentShowcase
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentHonor, StudentShowcase, Notification, StudentProject
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'student', 'notification_type', 'is_read', 'created_at')
|
||||
list_filter = ('notification_type', 'is_read', 'created_at')
|
||||
search_fields = ('title', 'content', 'student__name', 'student__phone')
|
||||
readonly_fields = ('created_at',)
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('student', 'title', 'content', 'notification_type')
|
||||
}),
|
||||
('状态', {
|
||||
'fields': ('is_read', 'created_at')
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
@@ -38,16 +53,20 @@ class BannerAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Student)
|
||||
class StudentAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'phone', 'wechat_nickname', 'openid', 'teaching_center', 'company_name', 'status', 'learning_count', 'created_at')
|
||||
search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name')
|
||||
list_filter = ('teaching_center', 'status')
|
||||
list_display = ('name', 'phone', 'responsible_teacher', 'is_active', 'status', 'teaching_center', 'created_at')
|
||||
search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name', 'responsible_teacher__name')
|
||||
list_filter = ('teaching_center', 'status', 'is_active', 'responsible_teacher')
|
||||
inlines = []
|
||||
|
||||
class StudentProjectInline(admin.TabularInline):
|
||||
model = StudentProject
|
||||
extra = 1
|
||||
|
||||
class StudentCouponInline(admin.TabularInline):
|
||||
model = StudentCoupon
|
||||
extra = 1
|
||||
|
||||
StudentAdmin.inlines = [StudentCouponInline]
|
||||
StudentAdmin.inlines = [StudentProjectInline, StudentCouponInline]
|
||||
|
||||
@admin.register(StudentCoupon)
|
||||
class StudentCouponAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -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 = models.CharField(max_length=100, verbose_name="微信唯一标识", null=True, blank=True, unique=True)
|
||||
# parent = models.CharField(max_length=50, verbose_name="家长", null=True, blank=True)
|
||||
teacher = models.ForeignKey(Teacher, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="负责老师", related_name="students")
|
||||
responsible_teacher = models.ForeignKey(Teacher, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="负责老师", related_name="students")
|
||||
address = models.CharField(max_length=200, verbose_name="地址", null=True, blank=True)
|
||||
# 已经有avatar字段,对应微信头像
|
||||
avatar = models.URLField(verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde")
|
||||
is_active = models.BooleanField(default=True, verbose_name="是否活跃")
|
||||
|
||||
enrolled_projects = models.ManyToManyField(Project, through='StudentProject', related_name='enrolled_students', verbose_name="已报名项目", blank=True)
|
||||
|
||||
# 新增字段
|
||||
teaching_center = models.ForeignKey(TeachingCenter, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联教学中心")
|
||||
@@ -303,3 +306,58 @@ def activate_coupons_on_phone_bind(sender, instance, created, **kwargs):
|
||||
coupon=coupon,
|
||||
status='assigned'
|
||||
)
|
||||
|
||||
class NotificationBatch(models.Model):
|
||||
SEND_MODE_CHOICES = (
|
||||
('custom', '自定义发送'),
|
||||
('project', '按项目发送'),
|
||||
('coupon', '按优惠券发送'),
|
||||
)
|
||||
# Using choices from Notification class requires Notification to be defined,
|
||||
# but NotificationBatch is defined before Notification.
|
||||
# So I will redefine choices or use strings.
|
||||
TYPE_CHOICES = (
|
||||
('system', '系统通知'),
|
||||
('activity', '活动提醒'),
|
||||
('course', '课程通知'),
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=100, verbose_name="标题")
|
||||
content = models.TextField(verbose_name="内容")
|
||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='system', verbose_name="通知类型")
|
||||
send_mode = models.CharField(max_length=20, choices=SEND_MODE_CHOICES, default='custom', verbose_name="发送方式")
|
||||
# SQLite version issue with JSONField, using TextField with manual JSON handling
|
||||
target_criteria = models.TextField(verbose_name="发送条件", default="{}")
|
||||
recipient_count = models.IntegerField(default=0, verbose_name="接收人数")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="发送时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "通知发送记录"
|
||||
verbose_name_plural = verbose_name
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.get_send_mode_display()})"
|
||||
|
||||
class Notification(models.Model):
|
||||
TYPE_CHOICES = (
|
||||
('system', '系统通知'),
|
||||
('activity', '活动提醒'),
|
||||
('course', '课程通知'),
|
||||
)
|
||||
|
||||
student = models.ForeignKey('Student', on_delete=models.CASCADE, related_name='notifications', verbose_name="接收学员")
|
||||
batch = models.ForeignKey(NotificationBatch, on_delete=models.CASCADE, null=True, blank=True, related_name="notifications", verbose_name="关联批次")
|
||||
title = models.CharField(max_length=100, verbose_name="标题")
|
||||
content = models.TextField(verbose_name="内容")
|
||||
notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='system', verbose_name="通知类型")
|
||||
is_read = models.BooleanField(default=False, verbose_name="是否已读")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="发送时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "消息通知"
|
||||
verbose_name_plural = verbose_name
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.student.name}"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase, Notification, NotificationBatch
|
||||
|
||||
class CategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -67,12 +67,13 @@ class BannerSerializer(serializers.ModelSerializer):
|
||||
fields = '__all__'
|
||||
|
||||
class StudentSerializer(serializers.ModelSerializer):
|
||||
teacher_name = serializers.CharField(source='teacher.name', read_only=True)
|
||||
teacher_name = serializers.CharField(source='responsible_teacher.name', read_only=True)
|
||||
teaching_center_name = serializers.CharField(source='teaching_center.name', read_only=True)
|
||||
stats = serializers.SerializerMethodField()
|
||||
enrolled_projects = serializers.SerializerMethodField()
|
||||
coupons = serializers.PrimaryKeyRelatedField(many=True, read_only=True, source='student_coupons')
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
teacher = serializers.PrimaryKeyRelatedField(source='responsible_teacher', queryset=Teacher.objects.all(), required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
@@ -127,3 +128,17 @@ class StudentShowcaseSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = StudentShowcase
|
||||
fields = '__all__'
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = '__all__'
|
||||
|
||||
class NotificationBatchSerializer(serializers.ModelSerializer):
|
||||
send_mode_display = serializers.CharField(source='get_send_mode_display', read_only=True)
|
||||
notification_type_display = serializers.CharField(source='get_notification_type_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = NotificationBatch
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import CategoryViewSet, TeacherViewSet, TeachingCenterViewSet, ProjectViewSet, CouponViewSet, BannerViewSet, StudentViewSet, StudentCouponViewSet, StudentProjectViewSet, StudentHonorViewSet, StudentShowcaseViewSet, UserProfileView, UserCouponsView, UserProjectsView, UserHonorsView, LoginView, UserPhoneView, DashboardStatsView, AvailableCouponsView
|
||||
from .views import CategoryViewSet, TeacherViewSet, TeachingCenterViewSet, ProjectViewSet, CouponViewSet, BannerViewSet, StudentViewSet, StudentCouponViewSet, StudentProjectViewSet, StudentHonorViewSet, StudentShowcaseViewSet, UserProfileView, UserCouponsView, UserProjectsView, UserHonorsView, LoginView, UserPhoneView, DashboardStatsView, AvailableCouponsView, NotificationViewSet, NotificationBatchViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'categories', CategoryViewSet)
|
||||
@@ -14,6 +14,8 @@ router.register(r'student-coupons', StudentCouponViewSet)
|
||||
router.register(r'student-projects', StudentProjectViewSet)
|
||||
router.register(r'student-honors', StudentHonorViewSet)
|
||||
router.register(r'student-showcases', StudentShowcaseViewSet)
|
||||
router.register(r'notifications', NotificationViewSet, basename='notification')
|
||||
router.register(r'notification-batches', NotificationBatchViewSet, basename='notification-batch')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
@@ -4,10 +4,11 @@ from rest_framework.decorators import action
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly, IsAuthenticated
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase
|
||||
from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer
|
||||
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase, Notification, NotificationBatch
|
||||
from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer, NotificationSerializer, NotificationBatchSerializer
|
||||
import requests
|
||||
from django.db.models import Count, F
|
||||
import json
|
||||
from django.db.models import Count, F, Sum
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
@@ -181,28 +182,47 @@ class DashboardStatsView(APIView):
|
||||
active_showcase_count = StudentShowcase.objects.filter(is_active=True).count()
|
||||
active_project_count = Project.objects.filter(is_active=True).count()
|
||||
|
||||
# 2. Pie Chart: Project Types Distribution
|
||||
# Group by project_type
|
||||
project_types_data = Project.objects.values('project_type').annotate(count=Count('id'))
|
||||
# 2. Pie Chart: Project Category Proportion
|
||||
# Aggregate student counts by project type
|
||||
projects_data = Project.objects.filter(is_active=True).values('project_type').annotate(total_students=Sum('students')).order_by('-total_students')
|
||||
|
||||
# Map type codes to display names
|
||||
# Define colors for each project type
|
||||
type_colors = {
|
||||
'training': '#36cfc9', # Cyan
|
||||
'competition': '#b37feb', # Purple
|
||||
'grading': '#409EFF', # Blue
|
||||
}
|
||||
|
||||
# Map type codes to display names for legend
|
||||
type_mapping = dict(Project.PROJECT_TYPE_CHOICES)
|
||||
pie_chart_data = []
|
||||
for item in project_types_data:
|
||||
type_code = item['project_type']
|
||||
name = type_mapping.get(type_code, type_code)
|
||||
pie_chart_data.append({
|
||||
'type': type_code,
|
||||
'name': name,
|
||||
'value': item['count']
|
||||
|
||||
# Construct Legend Data
|
||||
pie_chart_legend = []
|
||||
for type_code, color in type_colors.items():
|
||||
pie_chart_legend.append({
|
||||
'name': type_mapping.get(type_code, type_code),
|
||||
'color': color
|
||||
})
|
||||
|
||||
pie_chart_data = []
|
||||
for item in projects_data:
|
||||
type_code = item['project_type']
|
||||
# Use default color if type not found
|
||||
color = type_colors.get(type_code, '#909399')
|
||||
pie_chart_data.append({
|
||||
'name': type_mapping.get(type_code, type_code),
|
||||
'value': item['total_students'],
|
||||
'itemStyle': { 'color': color }
|
||||
})
|
||||
|
||||
# If too many, maybe limit? For now, let's keep all active ones as user requested "specific names".
|
||||
# But if we have 0 students, maybe skip?
|
||||
# pie_chart_data = [d for d in pie_chart_data if d['value'] > 0]
|
||||
|
||||
# If empty, provide some defaults to avoid empty chart
|
||||
if not pie_chart_data:
|
||||
pie_chart_data = [
|
||||
{'name': '小主持语言培训', 'value': 0},
|
||||
{'name': '赛事管理', 'value': 0},
|
||||
{'name': '考级管理', 'value': 0}
|
||||
{'name': '暂无数据', 'value': 0}
|
||||
]
|
||||
|
||||
# 3. Bar Chart: Popular dimension (organization/project)
|
||||
@@ -276,6 +296,7 @@ class DashboardStatsView(APIView):
|
||||
'showcases_active': active_showcase_count
|
||||
},
|
||||
'pie_chart_data': pie_chart_data,
|
||||
'pie_chart_legend': pie_chart_legend,
|
||||
'coupon_pie_chart_data': coupon_pie_chart_data,
|
||||
'bar_chart_data': {
|
||||
'title': bar_title,
|
||||
@@ -675,3 +696,202 @@ class UserHonorsView(APIView):
|
||||
honors = StudentHonor.objects.filter(student=student)
|
||||
serializer = StudentHonorSerializer(honors, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
class NotificationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
# 仅返回当前登录用户的通知
|
||||
# 需结合认证系统,假设 request.user.student 存在,或通过 openid 关联
|
||||
user = self.request.user
|
||||
|
||||
# Attempt to find the student associated with the request
|
||||
# Note: In our current mock auth setup, 'user' might be the Django Admin user.
|
||||
# But the frontend sends 'Authorization: Bearer mock_token_{id}'.
|
||||
# DRF's default authentication might not parse this mock token into request.user.
|
||||
# However, for consistency with the plan, let's implement the token parsing logic here
|
||||
# OR rely on a custom authentication class.
|
||||
|
||||
# Since other views use `get_student_from_token` helper, but ViewSets usually rely on Authentication classes.
|
||||
# Let's assume we can extract the student from the token here as well.
|
||||
|
||||
student = None
|
||||
auth = self.request.headers.get('Authorization')
|
||||
if auth and 'Bearer' in auth:
|
||||
try:
|
||||
token_str = auth.split(' ')[1]
|
||||
if token_str.startswith('mock_token_'):
|
||||
student_id = int(token_str.split('_')[-1])
|
||||
student = Student.objects.get(id=student_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if student:
|
||||
return Notification.objects.filter(student=student)
|
||||
|
||||
# If accessed by admin user (Django user)
|
||||
if user.is_staff:
|
||||
return Notification.objects.all()
|
||||
|
||||
return Notification.objects.none()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def unread_count(self, request):
|
||||
count = self.get_queryset().filter(is_read=False).count()
|
||||
return Response({'count': count})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def read(self, request, pk=None):
|
||||
notification = self.get_object()
|
||||
notification.is_read = True
|
||||
notification.save()
|
||||
return Response({'status': 'marked as read'})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def read_all(self, request):
|
||||
self.get_queryset().filter(is_read=False).update(is_read=True)
|
||||
return Response({'status': 'all marked as read'})
|
||||
|
||||
class NotificationBatchViewSet(viewsets.ModelViewSet):
|
||||
queryset = NotificationBatch.objects.all()
|
||||
serializer_class = NotificationBatchSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
# 1. Create Batch
|
||||
data = request.data
|
||||
send_mode = data.get('send_mode', 'custom')
|
||||
target_criteria = data.get('target_criteria', {})
|
||||
|
||||
# Ensure target_criteria is string for TextField
|
||||
if isinstance(target_criteria, dict):
|
||||
target_criteria_str = json.dumps(target_criteria)
|
||||
else:
|
||||
target_criteria_str = target_criteria
|
||||
try:
|
||||
target_criteria = json.loads(target_criteria)
|
||||
except:
|
||||
target_criteria = {}
|
||||
|
||||
# Validate basics
|
||||
# Use partial=True if some fields are missing but not required, though ModelSerializer usually handles it.
|
||||
# But we modify data to set target_criteria to string.
|
||||
# So we should create a mutable copy of data if it's immutable (QueryDict)
|
||||
if hasattr(data, '_mutable'):
|
||||
data._mutable = True
|
||||
|
||||
data['target_criteria'] = target_criteria_str
|
||||
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
batch = serializer.save()
|
||||
|
||||
# 2. Find Recipients
|
||||
recipients = Student.objects.none()
|
||||
|
||||
if send_mode == 'custom':
|
||||
student_ids = target_criteria.get('student_ids', [])
|
||||
select_all = target_criteria.get('select_all', False)
|
||||
if select_all:
|
||||
recipients = Student.objects.all()
|
||||
elif student_ids:
|
||||
recipients = Student.objects.filter(id__in=student_ids)
|
||||
|
||||
elif send_mode == 'project':
|
||||
project_id = target_criteria.get('project_id')
|
||||
statuses = target_criteria.get('statuses', []) # ['enrolled', 'graduated']
|
||||
if project_id:
|
||||
query = {'enrolled_projects__id': project_id} # Filter students by enrolled project
|
||||
# But we want to filter by the status in that project enrollment?
|
||||
# StudentProject has status.
|
||||
# So we should query StudentProject first.
|
||||
|
||||
sp_query = {'project_id': project_id}
|
||||
if statuses:
|
||||
sp_query['status__in'] = statuses
|
||||
|
||||
student_ids = StudentProject.objects.filter(**sp_query).values_list('student_id', flat=True)
|
||||
recipients = Student.objects.filter(id__in=student_ids)
|
||||
|
||||
elif send_mode == 'coupon':
|
||||
coupon_id = target_criteria.get('coupon_id')
|
||||
coupon_status = target_criteria.get('status') # 'assigned', 'used', etc.
|
||||
if coupon_id:
|
||||
query = {'coupon_id': coupon_id}
|
||||
if coupon_status:
|
||||
query['status'] = coupon_status
|
||||
|
||||
student_ids = StudentCoupon.objects.filter(**query).values_list('student_id', flat=True)
|
||||
recipients = Student.objects.filter(id__in=student_ids)
|
||||
|
||||
# Deduplicate recipients if needed (though IDs set should handle it, but values_list returns list)
|
||||
recipients = recipients.distinct()
|
||||
|
||||
# 3. Create Notifications
|
||||
notification_list = []
|
||||
count = 0
|
||||
for student in recipients:
|
||||
notification_list.append(Notification(
|
||||
student=student,
|
||||
batch=batch,
|
||||
title=batch.title,
|
||||
content=batch.content,
|
||||
notification_type=batch.notification_type
|
||||
))
|
||||
count += 1
|
||||
|
||||
if notification_list:
|
||||
Notification.objects.bulk_create(notification_list)
|
||||
|
||||
# 4. Update Count
|
||||
batch.recipient_count = count
|
||||
batch.save()
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def recipients(self, request, pk=None):
|
||||
batch = self.get_object()
|
||||
notifications = Notification.objects.filter(batch=batch).select_related('student', 'student__teaching_center', 'student__responsible_teacher')
|
||||
|
||||
results = []
|
||||
# Parse criteria once
|
||||
try:
|
||||
criteria = json.loads(batch.target_criteria)
|
||||
except:
|
||||
criteria = {}
|
||||
|
||||
for n in notifications:
|
||||
student = n.student
|
||||
|
||||
project_info = ""
|
||||
status_info = student.get_status_display()
|
||||
|
||||
if batch.send_mode == 'project':
|
||||
project_id = criteria.get('project_id')
|
||||
# Optimally we should prefetch this, but for now loop is okay for admin view
|
||||
sp = StudentProject.objects.filter(student=student, project_id=project_id).first()
|
||||
if sp:
|
||||
project_info = sp.project.title
|
||||
status_info = sp.get_status_display()
|
||||
elif batch.send_mode == 'coupon':
|
||||
coupon_id = criteria.get('coupon_id')
|
||||
sc = StudentCoupon.objects.filter(student=student, coupon_id=coupon_id).first()
|
||||
if sc:
|
||||
project_info = sc.coupon.title
|
||||
status_info = sc.get_status_display()
|
||||
|
||||
results.append({
|
||||
'id': student.id,
|
||||
'student_name': student.name,
|
||||
'student_phone': student.phone,
|
||||
'teaching_center': student.teaching_center.name if student.teaching_center else (student.responsible_teacher.name if student.responsible_teacher else '-'),
|
||||
'project_info': project_info,
|
||||
'status_info': status_info,
|
||||
'is_read': n.is_read,
|
||||
'read_at': None
|
||||
})
|
||||
|
||||
return Response(results)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from io import BytesIO
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
import sys
|
||||
import os
|
||||
import socket
|
||||
|
||||
from .filters import UserFilter
|
||||
from .mixins import CreateUpdateModelAMixin, OptimizationMixin
|
||||
@@ -422,7 +423,27 @@ class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListM
|
||||
|
||||
# Ensure forward slashes for URL
|
||||
file_name = instance.file.name.replace('\\', '/')
|
||||
instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name)
|
||||
|
||||
# 开发阶段,本机上传的视频或图片用本机IP保存
|
||||
if settings.DEBUG and (type == '视频' or type == '图片'):
|
||||
try:
|
||||
# 获取本机IP
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
# 获取端口
|
||||
host = self.request.get_host()
|
||||
port = host.split(':')[1] if ':' in host else '80'
|
||||
|
||||
# 构建URL
|
||||
instance.path = f"{self.request.scheme}://{ip}:{port}{settings.MEDIA_URL}{file_name}"
|
||||
except Exception as e:
|
||||
logger.error(f"获取本机IP失败: {e}")
|
||||
instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name)
|
||||
else:
|
||||
instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name)
|
||||
|
||||
logger.info(f"File uploaded: {instance.path}")
|
||||
instance.save()
|
||||
|
||||
|
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/coupon/coupon",
|
||||
"pages/detail/detail",
|
||||
"pages/login/login"
|
||||
"pages/login/login",
|
||||
"pages/message/index"
|
||||
],
|
||||
"window": {
|
||||
"backgroundTextStyle": "light",
|
||||
|
||||
@@ -13,8 +13,11 @@ Page({
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.fetchHonors()
|
||||
this.fetchProjects()
|
||||
const app = getApp()
|
||||
if (app.globalData.token) {
|
||||
this.fetchHonors()
|
||||
this.fetchProjects()
|
||||
}
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -23,6 +26,12 @@ Page({
|
||||
|
||||
getUserInfo() {
|
||||
const app = getApp()
|
||||
if (!app.globalData.token) {
|
||||
// Not logged in, clear data
|
||||
this.setData({ user: {}, honors: [], projects: [] })
|
||||
return
|
||||
}
|
||||
|
||||
// Try to get from globalData first to speed up rendering
|
||||
if (app.globalData.userInfo) {
|
||||
this.setData({ user: app.globalData.userInfo })
|
||||
@@ -36,10 +45,21 @@ Page({
|
||||
// this.fetchProjects();
|
||||
}).catch(err => {
|
||||
console.error('Failed to fetch user info', err)
|
||||
if (err && (err.code === 401 || err.code === 403)) {
|
||||
// Token invalid, clear it
|
||||
app.globalData.token = null;
|
||||
app.globalData.userInfo = null;
|
||||
this.setData({ user: {}, honors: [], projects: [] });
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
const app = getApp()
|
||||
if (!app.globalData.token) {
|
||||
wx.stopPullDownRefresh()
|
||||
return
|
||||
}
|
||||
Promise.all([this.fetchHonors(), this.fetchProjects()]).then(() => {
|
||||
wx.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
@@ -12,7 +12,11 @@ Page({
|
||||
canIUseOpenData: wx.canIUse('open-data.type.userAvatarUrl') && wx.canIUse('open-data.type.userNickName'), // 如需尝试获取用户信息可改为false
|
||||
selectedCategory: 'all',
|
||||
showcases: [],
|
||||
categoryStats: {}
|
||||
categoryStats: {},
|
||||
unreadCount: 0
|
||||
},
|
||||
onShow() {
|
||||
this.fetchUnreadCount();
|
||||
},
|
||||
onLoad() {
|
||||
if (wx.getUserProfile) {
|
||||
@@ -30,6 +34,26 @@ Page({
|
||||
this.setData({ selectedCategory: type });
|
||||
this.fetchData(type);
|
||||
},
|
||||
fetchUnreadCount() {
|
||||
// Only fetch if logged in
|
||||
const app = getApp()
|
||||
if (!app.globalData.token) return;
|
||||
|
||||
console.log('Fetching unread count...');
|
||||
const { request } = require('../../utils/request')
|
||||
request({ url: '/notifications/unread_count/' })
|
||||
.then(res => {
|
||||
if (res && typeof res.count === 'number') {
|
||||
this.setData({ unreadCount: res.count })
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Fetch unread count error:', err))
|
||||
},
|
||||
handleNotificationClick() {
|
||||
wx.navigateTo({
|
||||
url: '/pages/message/index'
|
||||
})
|
||||
},
|
||||
fetchBanners() {
|
||||
const { request } = require('../../utils/request')
|
||||
request({ url: '/banners/?is_active=true' })
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<view class="title-sub">致力成就</view>
|
||||
<view class="title-main">终身教育伟大事业</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>
|
||||
|
||||
<!-- Search Bar -->
|
||||
|
||||
@@ -54,10 +54,9 @@
|
||||
.bell-btn {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 16rpx;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--text-white);
|
||||
font-size: 32rpx;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
@@ -65,6 +64,25 @@
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -6rpx;
|
||||
right: -6rpx;
|
||||
background-color: #ff4d4f;
|
||||
color: white;
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 20rpx;
|
||||
line-height: 1;
|
||||
border: 2rpx solid #fff;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
|
||||
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="stat-item">
|
||||
<view class="stat-val">{{user.stats.learning}}</view>
|
||||
<view class="stat-label">在学课程</view>
|
||||
<view class="stat-label">课程&活动</view>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<view class="stat-val">{{user.stats.coupons}}</view>
|
||||
|
||||