Browse Source

修改 bug

master
王泽彦 8 months ago
parent
commit
f2b2dab6ad
  1. 71
      admin/src/app/api/course_schedule.ts
  2. 227
      admin/src/app/types/course-schedule.ts
  3. 377
      admin/src/app/views/contract/contract.vue
  4. 714
      admin/src/app/views/course_schedule/components/course-arrangement-detail.vue
  5. 214
      admin/src/app/views/course_schedule/components/course-info-section.vue
  6. 230
      admin/src/app/views/course_schedule/components/empty-seat-card.vue
  7. 442
      admin/src/app/views/course_schedule/components/student-action-menu.vue
  8. 373
      admin/src/app/views/course_schedule/components/student-card.vue
  9. 570
      admin/src/app/views/course_schedule/components/student-search-modal.vue
  10. 194
      admin/src/app/views/course_schedule/components/student-section.vue
  11. 11
      admin/src/app/views/course_schedule/course_schedule.vue
  12. 110
      niucloud/app/adminapi/controller/course_schedule/CourseSchedule.php
  13. 8
      niucloud/app/adminapi/route/course_schedule.php
  14. 659
      niucloud/app/common.php
  15. 747
      niucloud/app/service/api/apiService/CourseScheduleService.php
  16. 434
      uniapp/api/apiRoute.js
  17. 19
      uniapp/pages-coach/coach/schedule/schedule_table.vue

71
admin/src/app/api/course_schedule.ts

@ -90,9 +90,80 @@ export function getCourseInfo(params: Record<string, any>) {
}
/**
*
* @param params
* @returns
*/
export function addSchedule(params: Record<string, any>) {
return request.post('course_schedule/addSchedule', params, {
showErrorMessage: true,
showSuccessMessage: true,
})
}
/**
*
* @param params
* @returns
*/
export function getScheduleDetail(params: { schedule_id: number }) {
return request.get('course_schedule/getScheduleDetail', { params })
}
/**
*
* @param params
* @returns
*/
export function searchStudents(params: { phone_number?: string, name?: string }) {
return request.get('course_schedule/searchStudents', { params })
}
/**
* /
* @param params
* @returns
*/
export function removeStudent(params: Record<string, any>) {
return request.post('course_schedule/removeStudent', params, {
showErrorMessage: true,
showSuccessMessage: true,
})
}
/**
*
* @param params
* @returns
*/
export function upgradeStudent(params: Record<string, any>) {
return request.post('course_schedule/upgradeStudent', params, {
showErrorMessage: true,
showSuccessMessage: true,
})
}
/**
*
* @param params
* @returns
*/
export function updateStudentStatus(params: Record<string, any>) {
return request.post('course_schedule/updateStudentStatus', params, {
showErrorMessage: true,
showSuccessMessage: true,
})
}
/**
*
* @param params
* @returns
*/
export function restoreStudent(params: Record<string, any>) {
return request.post('course_schedule/restoreStudent', params, {
showErrorMessage: true,
showSuccessMessage: true,
})
}

227
admin/src/app/types/course-schedule.ts

@ -0,0 +1,227 @@
/**
*
*/
// 基础API响应接口
export interface ApiResponse<T = any> {
code: number
msg: string
data: T
}
// 学员信息接口
export interface StudentInfo {
id: number
person_id?: number
student_id?: number
resource_id?: number
resources_id?: number
name: string
phone: string
age: string | number
person_type: 'student' | 'customer_resource'
schedule_type: 1 | 2 // 1:正式位 2:等待位
status: number // 签到状态:0-待上课 1-已签到 2-请假
courseStatus: string // 课程状态描述
course_progress: {
used: number
total: number
percentage: number
}
student_course_info?: {
end_date: string
start_date?: string
course_id?: number
class_id?: number
}
renewal_status?: boolean // 续费状态
created_at?: string
updated_at?: string
}
// 课程安排基本信息接口
export interface ScheduleInfo {
id: number
course_id?: number
class_id?: number
venue_id?: number
campus_id?: number
coach_id?: number
course_name?: string
class_name?: string
course_date?: string
time_slot?: string
coach_name?: string
venue_name?: string
campus_name?: string
max_students?: number
current_students?: number
waiting_students?: number
status?: 'pending' | 'upcoming' | 'ongoing' | 'completed'
created_at?: string
updated_at?: string
}
// 课程安排详情响应接口
export interface ScheduleDetailResponse {
schedule_info: ScheduleInfo
formal_students: StudentInfo[]
waiting_students: StudentInfo[]
formal_empty_seats: number[]
waiting_empty_seats: number[]
total_capacity?: number
available_capacity?: number
}
// 位置信息接口
export interface SlotInfo {
type: 'formal' | 'waiting'
index: number
}
// 添加学员请求参数
export interface AddStudentParams {
schedule_id: number
person_type: 'student' | 'customer_resource'
schedule_type: 1 | 2 // 1:正式位 2:等待位
course_date: string
time_slot: string
student_id?: number
resources_id?: number
remarks?: string
}
// 删除学员请求参数
export interface RemoveStudentParams {
schedule_id: number
person_id: number
person_type: 'student' | 'customer_resource'
reason?: string
is_leave?: boolean // 是否为请假
}
// 升级学员请求参数
export interface UpgradeStudentParams {
schedule_id: number
person_id: number
from_type: 1 | 2 // 从哪个位置类型
to_type: 1 | 2 // 到哪个位置类型
remarks?: string
}
// 搜索学员请求参数
export interface SearchStudentsParams {
phone_number?: string
name?: string
campus_id?: number
exclude_schedule_id?: number // 排除已在该课程的学员
limit?: number
}
// 搜索学员响应结果
export interface SearchStudentsResponse {
students: StudentInfo[]
total: number
has_more: boolean
}
// 学员状态枚举
export enum StudentStatus {
PENDING = 0, // 待上课
ATTENDED = 1, // 已签到
LEAVE = 2 // 请假
}
// 课程状态枚举
export enum CourseStatus {
PENDING = 'pending', // 待开始
UPCOMING = 'upcoming', // 即将开始
ONGOING = 'ongoing', // 进行中
COMPLETED = 'completed' // 已结束
}
// 人员类型枚举
export enum PersonType {
STUDENT = 'student', // 正式学员
CUSTOMER_RESOURCE = 'customer_resource' // 潜在客户
}
// 位置类型枚举
export enum ScheduleType {
FORMAL = 1, // 正式位
WAITING = 2 // 等待位
}
// 组件事件类型定义
export interface CourseArrangementEvents {
'student-added': StudentInfo
'student-removed': StudentInfo
'student-upgraded': StudentInfo
'schedule-updated': ScheduleInfo
'data-refreshed': ScheduleDetailResponse
}
// 操作类型定义
export type StudentActionType =
| 'view' // 查看详情
| 'checkin' // 更新签到
| 'leave' // 请假
| 'cancel_leave' // 取消请假
| 'upgrade' // 升级到正式位
| 'remove' // 移除学员
// 错误码定义
export enum ErrorCode {
SUCCESS = 1,
FAILURE = 0,
INVALID_PARAMS = -1,
UNAUTHORIZED = -2,
FORBIDDEN = -3,
NOT_FOUND = -4,
CONFLICT = -5,
SERVER_ERROR = -500
}
// 通用分页接口
export interface PaginationParams {
page?: number
limit?: number
total?: number
}
// 学员操作历史记录
export interface StudentOperationLog {
id: number
schedule_id: number
student_id: number
operation_type: StudentActionType
operation_desc: string
operator_id: number
operator_name: string
created_at: string
remarks?: string
}
// 批量操作接口
export interface BatchOperationParams {
schedule_id: number
student_ids: number[]
operation_type: StudentActionType
remarks?: string
}
// 课程容量配置
export interface CapacityConfig {
max_formal_seats: number // 最大正式位数量
max_waiting_seats: number // 最大等待位数量
allow_overbook: boolean // 是否允许超额预订
warning_threshold: number // 容量警告阈值
}
// 预设学员信息
export interface PresetStudentInfo extends StudentInfo {
is_preset: true
preset_reason?: string
preset_by?: string
preset_at?: string
}

377
admin/src/app/views/contract/contract.vue

@ -10,6 +10,20 @@
<el-select v-model="searchForm.contract_type" placeholder="请选择" clearable>
<el-option label="内部合同" value="内部" />
<el-option label="外部合同" value="外部" />
<el-option label="员工合同" value="员工" />
<el-option label="学员合同" value="学员" />
<el-option label="会员合同" value="会员" />
<el-option label="培训合同" value="培训" />
<el-option label="服务合同" value="服务" />
</el-select>
</el-form-item>
<el-form-item label="合同状态">
<el-select v-model="searchForm.contract_status" placeholder="请选择" clearable>
<el-option label="草稿" value="draft" />
<el-option label="启用" value="active" />
<el-option label="禁用" value="inactive" />
<el-option label="已归档" value="archived" />
<el-option label="已过期" value="expired" />
</el-select>
</el-form-item>
<el-form-item>
@ -45,9 +59,18 @@
<el-option label="草稿" value="draft" />
<el-option label="启用" value="active" />
<el-option label="禁用" value="inactive" />
<el-option label="已归档" value="archived" />
<el-option label="已过期" value="expired" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="original_filename" label="原始文件名" />
<el-table-column prop="file_size" label="文件大小">
<template #default="{ row }">
<span v-if="row.file_size">{{ formatFileSize(row.file_size) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" />
<el-table-column label="操作" width="360">
<template #default="{ row }">
@ -103,6 +126,11 @@
<option value="">请选择合同类型</option>
<option value="内部">内部合同</option>
<option value="外部">外部合同</option>
<option value="员工">员工合同</option>
<option value="学员">学员合同</option>
<option value="会员">会员合同</option>
<option value="培训">培训合同</option>
<option value="服务">服务合同</option>
</select>
</div>
<div class="form-item">
@ -251,41 +279,52 @@
<div v-if="config.data_type === 'database'" class="data-config">
<select v-model="config.table_name" @change="onTableChange(config)" class="form-select form-select-small">
<option value="">选择表</option>
<option value="students">学员表</option>
<option value="users">用户表</option>
<option value="contracts">合同表</option>
<option value="orders">订单表</option>
<option value="courses">课程表</option>
<option value="teachers">教师表</option>
<option value="school_student">学员表 (school_student)</option>
<option value="school_customer_resources">用户表 (school_customer_resources)</option>
<option value="school_personnel">员工表 (school_personnel)</option>
<option value="school_order_table">订单表 (school_order_table)</option>
</select>
<select v-model="config.field_name" class="form-select form-select-small">
<option value="">选择字段</option>
<!-- 学员表字段 -->
<option v-if="config.table_name === 'students'" value="name">姓名</option>
<option v-if="config.table_name === 'students'" value="real_name">真实姓名</option>
<option v-if="config.table_name === 'students'" value="mobile">手机号</option>
<option v-if="config.table_name === 'students'" value="id_card">身份证号</option>
<option v-if="config.table_name === 'students'" value="address">地址</option>
<!-- 用户表字段 -->
<option v-if="config.table_name === 'users'" value="username">用户名</option>
<option v-if="config.table_name === 'users'" value="real_name">真实姓名</option>
<option v-if="config.table_name === 'users'" value="mobile">手机号</option>
<!-- 合同表字段 -->
<option v-if="config.table_name === 'contracts'" value="contract_name">合同名称</option>
<option v-if="config.table_name === 'contracts'" value="amount">金额</option>
<option v-if="config.table_name === 'contracts'" value="total_amount">总金额</option>
<option v-if="config.table_name === 'contracts'" value="start_date">开始日期</option>
<option v-if="config.table_name === 'contracts'" value="end_date">结束日期</option>
<!-- 订单表字段 -->
<option v-if="config.table_name === 'orders'" value="order_no">订单号</option>
<option v-if="config.table_name === 'orders'" value="order_money">订单金额</option>
<option v-if="config.table_name === 'orders'" value="pay_time">支付时间</option>
<!-- 课程表字段 -->
<option v-if="config.table_name === 'courses'" value="course_name">课程名称</option>
<option v-if="config.table_name === 'courses'" value="course_price">课程价格</option>
<!-- 教师表字段 -->
<option v-if="config.table_name === 'teachers'" value="teacher_name">教师姓名</option>
<option v-if="config.table_name === 'teachers'" value="teacher_mobile">教师手机号</option>
<!-- 学员表字段 (school_student) -->
<option v-if="config.table_name === 'school_student'" value="id">学员ID</option>
<option v-if="config.table_name === 'school_student'" value="name">姓名</option>
<option v-if="config.table_name === 'school_student'" value="gender">性别</option>
<option v-if="config.table_name === 'school_student'" value="age">年龄</option>
<option v-if="config.table_name === 'school_student'" value="birthday">生日</option>
<option v-if="config.table_name === 'school_student'" value="emergency_contact">紧急联系人</option>
<option v-if="config.table_name === 'school_student'" value="contact_phone">联系电话</option>
<option v-if="config.table_name === 'school_student'" value="member_label">会员标签</option>
<option v-if="config.table_name === 'school_student'" value="user_id">关联用户ID</option>
<option v-if="config.table_name === 'school_student'" value="campus_id">校区ID</option>
<!-- 用户表字段 (school_customer_resources) -->
<option v-if="config.table_name === 'school_customer_resources'" value="id">用户ID</option>
<option v-if="config.table_name === 'school_customer_resources'" value="name">姓名</option>
<option v-if="config.table_name === 'school_customer_resources'" value="phone_number">手机号</option>
<option v-if="config.table_name === 'school_customer_resources'" value="campus">所属校区</option>
<option v-if="config.table_name === 'school_customer_resources'" value="member_id">会员ID</option>
<!-- 订单表字段 (school_order_table) -->
<option v-if="config.table_name === 'school_order_table'" value="id">订单ID</option>
<option v-if="config.table_name === 'school_order_table'" value="order_amount">订单金额</option>
<option v-if="config.table_name === 'school_order_table'" value="course_id">课程</option>
<option v-if="config.table_name === 'school_order_table'" value="class_id">班级</option>
<option v-if="config.table_name === 'school_order_table'" value="staff_id">销售人员</option>
<option v-if="config.table_name === 'school_order_table'" value="campus_id">校区</option>
<option v-if="config.table_name === 'school_order_table'" value="course_plan_id">课程计划</option>
<option v-if="config.table_name === 'school_order_table'" value="student_id">学员ID</option>
<!-- 员工表字段 (school_personnel) -->
<option v-if="config.table_name === 'school_personnel'" value="id">员工ID</option>
<option v-if="config.table_name === 'school_personnel'" value="name">姓名</option>
<option v-if="config.table_name === 'school_personnel'" value="phone">电话</option>
<option v-if="config.table_name === 'school_personnel'" value="email">邮箱</option>
<option v-if="config.table_name === 'school_personnel'" value="address">地址</option>
<option v-if="config.table_name === 'school_personnel'" value="education">学历</option>
<option v-if="config.table_name === 'school_personnel'" value="employee_number">工号</option>
<option v-if="config.table_name === 'school_personnel'" value="join_time">入职时间</option>
<option v-if="config.table_name === 'school_personnel'" value="sys_user_id">系统用户ID</option>
</select>
</div>
@ -293,15 +332,31 @@
<div v-else-if="config.data_type === 'system'" class="data-config">
<select v-model="config.system_function" class="form-select">
<option value="">选择系统函数</option>
<option value="current_date">当前日期</option>
<option value="current_time">当前时间</option>
<option value="current_datetime">当前日期时间</option>
<option value="current_year">当前年份</option>
<option value="current_month">当前月份</option>
<option value="current_day">当前日</option>
<option value="current_campus">当前校区</option>
<option value="contract_generate_time">合同生成时间</option>
<option value="system_name">系统名称</option>
<!-- 日期时间函数 -->
<option value="get_current_date">当前日期</option>
<option value="get_current_time">当前时间</option>
<option value="get_current_datetime">当前日期时间</option>
<option value="get_current_year">当前年份</option>
<option value="get_current_month">当前月份</option>
<option value="get_current_day">当前日</option>
<option value="get_current_week">当前周</option>
<option value="get_current_quarter">当前季度</option>
<!-- 业务函数 -->
<option value="get_current_campus">当前校区</option>
<option value="get_current_login_user">当前用户</option>
<option value="get_current_personnel">当前员工</option>
<option value="get_contract_generate_time">合同生成时间</option>
<option value="get_contract_sign_time">合同签署时间</option>
<!-- 系统信息 -->
<option value="get_system_name">系统名称</option>
<option value="get_system_version">系统版本</option>
<option value="get_random_number">随机数字</option>
<option value="get_contract_sequence">合同序号</option>
<option value="format_currency">格式化货币</option>
<!-- 上课规则函数 -->
<option value="get_class_schedule_rules">上课规则配置</option>
<option value="generate_class_schedule">生成课程表</option>
<option value="check_class_time_conflict">检查时间冲突</option>
</select>
</div>
@ -317,12 +372,40 @@
<!-- 签名图片类型配置 -->
<div v-else-if="config.data_type === 'sign_img'" class="data-config">
<span class="config-info">图片文件上传字段</span>
<select v-model="config.sign_image_source" class="form-select">
<option value="">选择图片来源</option>
<option value="upload">上传图片</option>
<option value="campus_seal">校区印章</option>
<option value="user_signature">用户签名</option>
</select>
<div v-if="config.sign_image_source === 'campus_seal'" class="sign-config-tip">
<span class="config-info">从school_campus.seal_image获取校区印章</span>
</div>
<div v-if="config.sign_image_source === 'upload'" class="sign-config-tip">
<span class="config-info">支持上传PNGJPG格式图片</span>
</div>
<div v-if="config.sign_image_source === 'user_signature'" class="sign-config-tip">
<span class="config-info">从用户签名记录中获取</span>
</div>
</div>
<!-- 电子签名类型配置 -->
<div v-else-if="config.data_type === 'signature'" class="data-config">
<span class="config-info">手写签名字段需跳转签名页面</span>
<select v-model="config.signature_type" class="form-select">
<option value="">选择签名方式</option>
<option value="handwrite">手写签名</option>
<option value="canvas">画布签名</option>
<option value="upload_image">上传签名图片</option>
</select>
<div v-if="config.signature_type === 'handwrite'" class="sign-config-tip">
<span class="config-info">跳转到签名页面进行手写签名</span>
</div>
<div v-if="config.signature_type === 'canvas'" class="sign-config-tip">
<span class="config-info">在页面上直接进行电子签名</span>
</div>
<div v-if="config.signature_type === 'upload_image'" class="sign-config-tip">
<span class="config-info">允许用户上传签名图片</span>
</div>
</div>
<!-- 未选择类型 -->
@ -493,6 +576,11 @@
<option value="">请选择合同类型</option>
<option value="内部">内部合同</option>
<option value="外部">外部合同</option>
<option value="员工">员工合同</option>
<option value="学员">学员合同</option>
<option value="会员">会员合同</option>
<option value="培训">培训合同</option>
<option value="服务">服务合同</option>
</select>
</div>
<div class="form-item">
@ -548,7 +636,8 @@ const staffLoading = ref(false)
const searchForm = reactive({
contract_name: '',
contract_type: ''
contract_type: '',
contract_status: ''
})
const uploadForm = reactive({
@ -592,7 +681,15 @@ const getList = async () => {
limit: pagination.limit
}
const { data } = await contractTemplateApi.getList(params)
tableData.value = data.data
//
const processedData = data.data.map((item: any) => ({
...item,
formatted_file_size: item.file_size ? formatFileSize(item.file_size) : '-',
formatted_created_at: item.created_at ? new Date(item.created_at).toLocaleString() : '-'
}))
tableData.value = processedData
pagination.total = data.total
} catch (error) {
ElMessage.error('获取数据失败')
@ -601,21 +698,70 @@ const getList = async () => {
}
}
//
const getStatusType = (status: string) => {
//
//
const validateSystemFunction = (functionName: string) => {
const validFunctions = [
//
'get_current_date', 'get_current_time', 'get_current_datetime',
'get_current_year', 'get_current_month', 'get_current_day',
'get_current_week', 'get_current_quarter',
//
'get_current_campus', 'get_current_login_user', 'get_current_personnel',
'get_contract_generate_time', 'get_contract_sign_time',
//
'get_system_name', 'get_system_version',
'get_random_number', 'get_contract_sequence', 'format_currency',
//
'get_class_schedule_rules', 'generate_class_schedule', 'check_class_time_conflict'
]
return validFunctions.includes(functionName)
}
//
const formatFileSize = (bytes: number) => {
if (!bytes || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
//
const getContractTypeText = (type: string) => {
const typeMap: Record<string, string> = {
'内部': '内部合同',
'外部': '外部合同',
'员工': '员工合同',
'学员': '学员合同',
'会员': '会员合同',
'培训': '培训合同',
'服务': '服务合同'
}
return typeMap[type] || type
}
//
const getContractStatusType = (status: string) => {
const statusMap: Record<string, string> = {
'draft': 'info',
'active': 'success',
'inactive': 'warning'
'inactive': 'warning',
'archived': 'info',
'expired': 'danger'
}
return statusMap[status] || 'info'
}
const getStatusText = (status: string) => {
//
const getContractStatusText = (status: string) => {
const statusMap: Record<string, string> = {
'draft': '草稿',
'active': '启用',
'inactive': '禁用'
'inactive': '禁用',
'archived': '已归档',
'expired': '已过期'
}
return statusMap[status] || '未知'
}
@ -624,7 +770,8 @@ const getStatusText = (status: string) => {
const resetSearch = () => {
Object.assign(searchForm, {
contract_name: '',
contract_type: ''
contract_type: '',
contract_status: ''
})
pagination.page = 1
getList()
@ -914,6 +1061,10 @@ const loadPlaceholderConfig = async (contractId: number) => {
system_function: config.system_function || '',
//
user_input_value: config.user_input_value || '',
//
sign_image_source: config.sign_image_source || '',
//
signature_type: config.signature_type || '',
//
sign_party: config.sign_party || '',
//
@ -932,6 +1083,8 @@ const loadPlaceholderConfig = async (contractId: number) => {
field_name: config.field_name || '',
system_function: config.system_function || '',
user_input_value: config.user_input_value || '',
sign_image_source: config.sign_image_source || '',
signature_type: config.signature_type || '',
sign_party: config.sign_party || '',
field_type: config.field_type || 'text',
is_required: config.is_required || 0,
@ -948,6 +1101,8 @@ const loadPlaceholderConfig = async (contractId: number) => {
field_name: '',
system_function: '',
user_input_value: '',
sign_image_source: '',
signature_type: '',
sign_party: '',
field_type: 'text',
is_required: 0,
@ -964,6 +1119,8 @@ const loadPlaceholderConfig = async (contractId: number) => {
field_name: config.field_name || '',
system_function: config.system_function || '',
user_input_value: config.user_input_value || '',
sign_image_source: config.sign_image_source || '',
signature_type: config.signature_type || '',
sign_party: config.sign_party || '',
field_type: config.field_type || 'text',
is_required: config.is_required || 0,
@ -978,10 +1135,12 @@ const loadPlaceholderConfig = async (contractId: number) => {
{
placeholder: '{{学员姓名}}',
data_type: 'database',
table_name: 'students',
field_name: 'real_name',
table_name: 'school_student',
field_name: 'name',
system_function: '',
user_input_value: '',
sign_image_source: '',
signature_type: '',
sign_party: 'party_b',
field_type: 'text',
is_required: 1,
@ -990,10 +1149,12 @@ const loadPlaceholderConfig = async (contractId: number) => {
{
placeholder: '{{合同金额}}',
data_type: 'database',
table_name: 'contracts',
field_name: 'amount',
table_name: 'school_order_table',
field_name: 'order_amount',
system_function: '',
user_input_value: '',
sign_image_source: '',
signature_type: '',
sign_party: 'party_a',
field_type: 'money',
is_required: 1,
@ -1004,8 +1165,10 @@ const loadPlaceholderConfig = async (contractId: number) => {
data_type: 'system',
table_name: '',
field_name: '',
system_function: 'current_date',
system_function: 'get_current_date',
user_input_value: '',
sign_image_source: '',
signature_type: '',
sign_party: 'party_b',
field_type: 'date',
is_required: 0,
@ -1018,11 +1181,27 @@ const loadPlaceholderConfig = async (contractId: number) => {
field_name: '',
system_function: '',
user_input_value: '',
sign_image_source: '',
signature_type: 'handwrite',
sign_party: 'party_b',
field_type: 'signature',
is_required: 1,
default_value: ''
},
{
placeholder: '{{校区印章}}',
data_type: 'sign_img',
table_name: '',
field_name: '',
system_function: '',
user_input_value: '',
sign_image_source: 'campus_seal',
signature_type: '',
sign_party: 'party_a',
field_type: 'image',
is_required: 1,
default_value: ''
},
{
placeholder: '{{机构名称}}',
data_type: 'user_input',
@ -1030,6 +1209,8 @@ const loadPlaceholderConfig = async (contractId: number) => {
field_name: '',
system_function: '',
user_input_value: '某某培训机构',
sign_image_source: '',
signature_type: '',
sign_party: 'party_a',
field_type: 'text',
is_required: 1,
@ -1113,10 +1294,12 @@ const loadPlaceholderConfig = async (contractId: number) => {
{
placeholder: '{{学员姓名}}',
data_type: 'database',
table_name: 'students',
field_name: 'real_name',
table_name: 'school_student',
field_name: 'name',
system_function: '',
user_input_value: '',
sign_image_source: '',
signature_type: '',
sign_party: 'party_b',
field_type: 'text',
is_required: 1,
@ -1125,10 +1308,12 @@ const loadPlaceholderConfig = async (contractId: number) => {
{
placeholder: '{{合同金额}}',
data_type: 'database',
table_name: 'contracts',
field_name: 'amount',
table_name: 'school_order_table',
field_name: 'order_amount',
system_function: '',
user_input_value: '',
sign_image_source: '',
signature_type: '',
sign_party: 'party_a',
field_type: 'money',
is_required: 1,
@ -1139,8 +1324,10 @@ const loadPlaceholderConfig = async (contractId: number) => {
data_type: 'system',
table_name: '',
field_name: '',
system_function: 'current_date',
system_function: 'get_current_date',
user_input_value: '',
sign_image_source: '',
signature_type: '',
sign_party: 'party_b',
field_type: 'date',
is_required: 0,
@ -1153,11 +1340,27 @@ const loadPlaceholderConfig = async (contractId: number) => {
field_name: '',
system_function: '',
user_input_value: '',
sign_image_source: '',
signature_type: 'handwrite',
sign_party: 'party_b',
field_type: 'signature',
is_required: 1,
default_value: ''
},
{
placeholder: '{{校区印章}}',
data_type: 'sign_img',
table_name: '',
field_name: '',
system_function: '',
user_input_value: '',
sign_image_source: 'campus_seal',
signature_type: '',
sign_party: 'party_a',
field_type: 'image',
is_required: 1,
default_value: ''
},
{
placeholder: '{{机构名称}}',
data_type: 'user_input',
@ -1165,6 +1368,8 @@ const loadPlaceholderConfig = async (contractId: number) => {
field_name: '',
system_function: '',
user_input_value: '某某培训机构',
sign_image_source: '',
signature_type: '',
sign_party: 'party_a',
field_type: 'text',
is_required: 1,
@ -1191,6 +1396,12 @@ const onDataTypeChange = (config: any) => {
if (config.data_type !== 'user_input') {
config.user_input_value = ''
}
if (config.data_type !== 'sign_img') {
config.sign_image_source = ''
}
if (config.data_type !== 'signature') {
config.signature_type = ''
}
//
// config.sign_party
@ -1221,6 +1432,10 @@ const handleConfigSuccess = async () => {
system_function: config.data_type === 'system' ? config.system_function : '',
//
user_input_value: config.data_type === 'user_input' ? config.user_input_value : '',
//
sign_image_source: config.data_type === 'sign_img' ? config.sign_image_source : '',
//
signature_type: config.data_type === 'signature' ? config.signature_type : '',
//
sign_party: config.sign_party || '',
//
@ -1236,7 +1451,7 @@ const handleConfigSuccess = async () => {
await contractTemplateApi.savePlaceholderConfig(currentContractId.value, saveData)
console.log('✅ 保存成功')
ElMessage.success('配置保存成功')
ElMessage.success('占位符配置保存成功')
showConfigDialog.value = false
//
@ -2142,4 +2357,42 @@ onMounted(() => {
text-align: center;
min-width: 150px;
}
/* 签名配置提示样式 */
.sign-config-tip {
margin-top: 8px;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
}
.sign-config-tip .config-info {
color: #67c23a;
background: #f0f9ff;
border: 1px solid #b3d8ff;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
white-space: normal;
text-align: left;
min-width: auto;
}
/* 签名图片配置样式 */
.data-config select {
min-width: 150px;
}
/* 签名配置区域响应式 */
@media (max-width: 1200px) {
.data-config {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.sign-config-tip {
width: 100%;
}
}
</style>

714
admin/src/app/views/course_schedule/components/course-arrangement-detail.vue

@ -0,0 +1,714 @@
<template>
<el-dialog
v-model="visible"
title="课程安排详情"
width="90%"
class="course-arrangement-dialog"
:destroy-on-close="true"
@open="handleDialogOpen"
>
<div class="course-arrangement-container">
<!-- 课程信息区域 -->
<course-info-section
:schedule-info="scheduleInfo"
:loading="loading"
/>
<!-- 学员区域 -->
<student-section
:formal-students="formalStudents"
:waiting-students="waitingStudents"
:formal-empty-seats="formalEmptySeats"
:waiting-empty-seats="waitingEmptySeats"
:loading="loading"
@add-student="handleAddStudent"
@view-student="handleViewStudent"
@remove-student="handleRemoveStudent"
@upgrade-student="handleUpgradeStudent"
/>
</div>
<!-- 学员搜索弹窗 -->
<student-search-modal
v-model:visible="showSearchModal"
:current-slot="currentSlot"
:preset-student="presetStudent"
@confirm="handleConfirmAddStudent"
/>
<!-- 学员操作菜单 -->
<student-action-menu
v-model:visible="showActionMenu"
:student="currentStudent"
:formal-has-empty="formalEmptySeats.length > 0"
@confirm-action="handleConfirmAction"
/>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getScheduleDetail,
removeStudent,
upgradeStudent,
addSchedule,
updateStudentStatus,
restoreStudent
} from '@/app/api/course_schedule'
import CourseInfoSection from './course-info-section.vue'
import StudentSection from './student-section.vue'
import StudentSearchModal from './student-search-modal.vue'
import StudentActionMenu from './student-action-menu.vue'
import type {
StudentInfo,
ScheduleInfo,
SlotInfo,
ScheduleDetailResponse,
AddStudentParams,
RemoveStudentParams,
UpgradeStudentParams,
ApiResponse
} from '@/app/types/course-schedule'
// Props
interface Props {
scheduleId?: number
resourceId?: number
studentId?: number
}
const props = withDefaults(defineProps<Props>(), {
scheduleId: 0,
resourceId: 0,
studentId: 0
})
//
const visible = ref(false)
const loading = ref(false)
const showSearchModal = ref(false)
const showActionMenu = ref(false)
//
const scheduleInfo = ref<ScheduleInfo>({} as ScheduleInfo)
const formalStudents = ref<StudentInfo[]>([])
const waitingStudents = ref<StudentInfo[]>([])
const formalEmptySeats = ref<number[]>([])
const waitingEmptySeats = ref<number[]>([])
//
const currentSlot = ref<SlotInfo>({ type: 'formal', index: 1 })
const currentStudent = ref<StudentInfo | null>(null)
const presetStudent = ref<StudentInfo | null>(null)
//
const open = (scheduleId: number, resourceId?: number, studentId?: number) => {
if (scheduleId) {
scheduleInfo.value.id = scheduleId
}
if (resourceId) {
presetStudent.value = {
id: resourceId,
resource_id: resourceId,
student_id: studentId
} as StudentInfo
}
visible.value = true
}
//
const handleDialogOpen = async () => {
if (scheduleInfo.value.id) {
await loadScheduleDetail()
}
}
//
const loadScheduleDetail = async () => {
try {
loading.value = true
if (!scheduleInfo.value.id) {
ElMessage.warning('课程安排ID不能为空')
return
}
const response: ApiResponse<ScheduleDetailResponse> = await getScheduleDetail({
schedule_id: scheduleInfo.value.id
})
if (response.code === 1 && response.data) {
const data = response.data
//
scheduleInfo.value = {
...scheduleInfo.value,
...data.schedule_info
}
//
formalStudents.value = data.formal_students || []
waitingStudents.value = data.waiting_students || []
//
formalEmptySeats.value = data.formal_empty_seats || []
waitingEmptySeats.value = data.waiting_empty_seats || []
console.log('课程安排详情加载成功:', {
scheduleInfo: scheduleInfo.value,
formalCount: formalStudents.value.length,
waitingCount: waitingStudents.value.length,
formalEmpty: formalEmptySeats.value.length,
waitingEmpty: waitingEmptySeats.value.length
})
//
nextTick(() => {
ElMessage.success({
message: '课程详情加载成功',
duration: 1500,
showClose: false
})
})
} else {
const errorMsg = response.msg || '获取课程安排详情失败'
ElMessage.error({
message: errorMsg,
duration: 3000,
showClose: true
})
console.error('加载课程详情失败:', response)
}
} catch (error) {
console.error('加载课程安排详情失败:', error)
let errorMessage = '网络异常,获取课程详情失败'
if (error instanceof Error) {
if (error.message.includes('timeout')) {
errorMessage = '请求超时,请检查网络连接'
} else if (error.message.includes('Network')) {
errorMessage = '网络连接失败,请检查网络设置'
}
}
ElMessage.error({
message: errorMessage,
duration: 5000,
showClose: true
})
} finally {
loading.value = false
}
}
//
const handleAddStudent = (slotInfo: SlotInfo) => {
currentSlot.value = slotInfo
showSearchModal.value = true
}
//
const handleViewStudent = (student: StudentInfo) => {
currentStudent.value = student
showActionMenu.value = true
}
//
const handleConfirmAddStudent = async (student: StudentInfo, slot: SlotInfo) => {
try {
//
if (!scheduleInfo.value.id) {
ElMessage.warning('课程安排ID不能为空')
return
}
if (!student.person_type) {
ElMessage.warning('学员类型不能为空')
return
}
const addData: AddStudentParams = {
schedule_id: scheduleInfo.value.id,
person_type: student.person_type,
schedule_type: slot.type === 'formal' ? 1 : 2,
course_date: scheduleInfo.value.course_date || '',
time_slot: scheduleInfo.value.time_slot || ''
}
// ID
if (student.person_type === 'student') {
addData.student_id = student.student_id || student.id
if (!addData.student_id) {
ElMessage.warning('学员ID不能为空')
return
}
} else {
addData.resources_id = student.resources_id || student.resource_id || student.id
if (!addData.resources_id) {
ElMessage.warning('客户资源ID不能为空')
return
}
}
console.log('添加学员数据:', addData)
//
const loadingMessage = ElMessage({
message: '正在添加学员...',
type: 'info',
duration: 0,
showClose: false
})
const response = await addSchedule(addData)
//
loadingMessage.close()
if (response.code === 1) {
ElMessage.success({
message: `成功将学员 ${student.name} 添加到${slot.type === 'formal' ? '正式位' : '等待位'}`,
duration: 3000,
showClose: true
})
await loadScheduleDetail() //
} else {
ElMessage.error({
message: response.msg || '添加学员失败',
duration: 5000,
showClose: true
})
}
} catch (error) {
console.error('添加学员失败:', error)
let errorMessage = '添加学员失败'
if (error instanceof Error) {
if (error.message.includes('timeout')) {
errorMessage = '添加学员超时,请重试'
} else if (error.message.includes('Network')) {
errorMessage = '网络异常,请检查网络连接'
}
}
ElMessage.error({
message: errorMessage,
duration: 5000,
showClose: true
})
}
}
//
const handleConfirmAction = async (action: string) => {
if (!currentStudent.value) return
try {
switch (action) {
case 'remove':
await handleRemoveStudent(currentStudent.value)
break
case 'upgrade':
await handleUpgradeStudent(currentStudent.value)
break
case 'leave':
await handleStudentLeave(currentStudent.value)
break
case 'cancel_leave':
await handleCancelLeave(currentStudent.value)
break
case 'checkin':
await handleStudentCheckin(currentStudent.value)
break
case 'view':
await handleViewStudentDetails(currentStudent.value)
break
default:
ElMessage.warning('未知操作类型')
break
}
await loadScheduleDetail() //
} catch (error) {
console.error('操作失败:', error)
}
}
//
const handleRemoveStudent = async (student: StudentInfo) => {
try {
//
if (student.status === 1) {
const confirmResult = await ElMessageBox.confirm(
`学员 ${student.name} 已经签到上课,确定要删除吗?\n\n此操作将影响考勤记录,请谨慎操作。`,
'重要提示',
{
confirmButtonText: '仍要删除',
cancelButtonText: '取消',
type: 'error',
showCancelButton: true
}
)
if (confirmResult !== 'confirm') return
}
const { value: reason } = await ElMessageBox.prompt(
`请输入删除学员 ${student.name} 的原因:`,
'删除学员',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
inputPlaceholder: '请输入删除原因(必填)',
inputValue: '',
inputType: 'textarea',
inputValidator: (value) => {
if (!value || value.trim().length === 0) {
return '请输入删除原因'
}
if (value.length > 200) {
return '删除原因不能超过200个字符'
}
return true
},
showCancelButton: true,
closeOnClickModal: false,
closeOnPressEscape: false
}
)
if (reason) {
//
const loadingMessage = ElMessage({
message: '正在删除学员...',
type: 'info',
duration: 0,
showClose: false
})
const response = await removeStudent({
schedule_id: scheduleInfo.value.id,
person_id: student.person_id || student.id,
person_type: student.person_type,
reason: `管理员删除:${reason.trim()}`
})
loadingMessage.close()
if (response.code === 1) {
ElMessage.success({
message: `学员 ${student.name} 删除成功`,
duration: 3000,
showClose: true
})
} else {
ElMessage.error({
message: response.msg || '删除学员失败',
duration: 5000,
showClose: true
})
}
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除学员失败:', error)
let errorMessage = '删除学员失败'
if (error instanceof Error && error.message.includes('Network')) {
errorMessage = '网络异常,请检查网络连接'
}
ElMessage.error({
message: errorMessage,
duration: 5000,
showClose: true
})
}
}
}
//
const handleUpgradeStudent = async (student: StudentInfo) => {
//
if (formalEmptySeats.value.length === 0) {
ElMessage.warning({
message: '正式位已满,无法升级。请先移除部分正式位学员或联系管理员扩容。',
duration: 5000,
showClose: true
})
return
}
//
if (student.schedule_type !== 2) {
ElMessage.warning('只有等待位学员才能升级为正式位')
return
}
try {
const result = await ElMessageBox.confirm(
`确定要将等待位学员 ${student.name} 升级为正式学员吗?\n\n升级后将占用一个正式位名额,且操作不可撤销。`,
'确认升级',
{
confirmButtonText: '确定升级',
cancelButtonText: '取消',
type: 'warning',
showCancelButton: true,
closeOnClickModal: false,
closeOnPressEscape: false
}
)
if (result === 'confirm') {
//
const loadingMessage = ElMessage({
message: '正在升级学员...',
type: 'info',
duration: 0,
showClose: false
})
const response = await upgradeStudent({
schedule_id: scheduleInfo.value.id,
person_id: student.person_id || student.id,
from_type: 2, //
to_type: 1 //
})
loadingMessage.close()
if (response.code === 1) {
ElMessage.success({
message: `学员 ${student.name} 升级成功!已移至正式位`,
duration: 3000,
showClose: true
})
} else {
ElMessage.error({
message: response.msg || '升级失败',
duration: 5000,
showClose: true
})
}
}
} catch (error) {
if (error !== 'cancel') {
console.error('升级学员失败:', error)
let errorMessage = '升级学员失败'
if (error instanceof Error) {
if (error.message.includes('timeout')) {
errorMessage = '升级超时,请重试'
} else if (error.message.includes('Network')) {
errorMessage = '网络异常,请检查网络连接'
}
}
ElMessage.error({
message: errorMessage,
duration: 5000,
showClose: true
})
}
}
}
//
const handleStudentLeave = async (student: StudentInfo) => {
try {
//
if (student.status === 2) {
ElMessage.warning(`学员 ${student.name} 已经在请假状态了`)
return
}
const { value: reason } = await ElMessageBox.prompt(
`请输入学员 ${student.name} 的请假原因:`,
'学员请假',
{
confirmButtonText: '确定请假',
cancelButtonText: '取消',
inputPlaceholder: '请输入请假原因(可选)',
inputValue: '',
inputType: 'textarea',
inputValidator: (value) => {
if (value && value.length > 200) {
return '请假原因不能超过200个字符'
}
return true
},
showCancelButton: true,
closeOnClickModal: false,
closeOnPressEscape: false
}
)
//
const loadingMessage = ElMessage({
message: '正在处理请假...',
type: 'info',
duration: 0,
showClose: false
})
const response = await removeStudent({
schedule_id: scheduleInfo.value.id,
person_id: student.person_id || student.id,
person_type: student.person_type,
reason: `请假:${reason || '无原因'}`
})
loadingMessage.close()
if (response.code === 1) {
ElMessage.success({
message: `学员 ${student.name} 请假处理成功`,
duration: 3000,
showClose: true
})
} else {
ElMessage.error({
message: response.msg || '请假处理失败',
duration: 5000,
showClose: true
})
}
} catch (error) {
if (error !== 'cancel') {
console.error('学员请假失败:', error)
let errorMessage = '请假处理失败'
if (error instanceof Error && error.message.includes('Network')) {
errorMessage = '网络异常,请检查网络连接'
}
ElMessage.error({
message: errorMessage,
duration: 5000,
showClose: true
})
}
}
}
//
const handleCancelLeave = async (student: StudentInfo) => {
try {
const result = await ElMessageBox.confirm(
`确定要取消学员 ${student.name} 的请假状态吗?`,
'取消请假',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}
)
if (result === 'confirm') {
const response = await restoreStudent({
schedule_id: scheduleInfo.value.id,
person_id: student.person_id || student.id
})
if (response.code === 1) {
ElMessage.success('取消请假成功')
} else {
ElMessage.error(response.msg || '取消请假失败')
}
}
} catch (error) {
if (error !== 'cancel') {
console.error('取消请假失败:', error)
ElMessage.error('取消请假失败')
}
}
}
//
const handleStudentCheckin = async (student: StudentInfo) => {
try {
const statusOptions = [
{ label: '待上课', value: 0 },
{ label: '已签到', value: 1 },
{ label: '请假', value: 2 }
]
const currentStatus = statusOptions.find(opt => opt.value === student.status)
const { value: statusValue } = await ElMessageBox.prompt(
`当前状态:${currentStatus?.label || '未知'}\n请选择新的签到状态:`,
'更新签到状态',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: '请选择状态: 0-待上课, 1-已签到, 2-请假',
inputPattern: /^[0-2]$/,
inputErrorMessage: '请输入 0、1 或 2'
}
)
const newStatus = parseInt(statusValue)
if (!isNaN(newStatus) && newStatus !== student.status && [0, 1, 2].includes(newStatus)) {
const response = await updateStudentStatus({
schedule_id: scheduleInfo.value.id,
person_id: student.person_id || student.id,
status: newStatus,
reason: `签到状态更新为: ${statusOptions.find(opt => opt.value === newStatus)?.label}`
})
if (response.code === 1) {
ElMessage.success('签到状态更新成功')
} else {
ElMessage.error(response.msg || '签到状态更新失败')
}
}
} catch (error) {
if (error !== 'cancel') {
console.error('更新签到状态失败:', error)
ElMessage.error('更新签到状态失败')
}
}
}
//
const handleViewStudentDetails = async (student: StudentInfo) => {
try {
//
ElMessage.info('查看学员详情功能开发中')
// TODO:
console.log('查看学员详情:', student)
} catch (error) {
console.error('查看学员详情失败:', error)
ElMessage.error('查看学员详情失败')
}
}
//
defineExpose({
open
})
</script>
<style lang="scss" scoped>
.course-arrangement-dialog {
.course-arrangement-container {
display: flex;
flex-direction: column;
gap: 24px;
max-height: 70vh;
overflow-y: auto;
}
}
:deep(.el-dialog__body) {
padding: 20px;
}
:deep(.el-dialog__header) {
padding: 20px 20px 10px 20px;
margin-right: 0;
}
</style>

214
admin/src/app/views/course_schedule/components/course-info-section.vue

@ -0,0 +1,214 @@
<template>
<div class="course-info-section">
<el-card class="course-info-card" shadow="never">
<template #header>
<div class="card-header">
<h3 class="course-title">
<el-icon><Calendar /></el-icon>
课程安排信息
</h3>
<el-tag
:type="getStatusTagType(scheduleInfo.status)"
size="large"
>
{{ getStatusText(scheduleInfo.status) }}
</el-tag>
</div>
</template>
<div v-loading="loading" class="course-info-content">
<div class="info-grid">
<div class="info-item">
<div class="info-label">
<el-icon><Calendar /></el-icon>
上课日期
</div>
<div class="info-value">{{ scheduleInfo.course_date || '待安排' }}</div>
</div>
<div class="info-item">
<div class="info-label">
<el-icon><Clock /></el-icon>
上课时间
</div>
<div class="info-value">{{ scheduleInfo.time_slot || '待安排' }}</div>
</div>
<div class="info-item">
<div class="info-label">
<el-icon><Reading /></el-icon>
课程名称
</div>
<div class="info-value">{{ scheduleInfo.course_name || '未设置' }}</div>
</div>
<div class="info-item">
<div class="info-label">
<el-icon><UserFilled /></el-icon>
班级名称
</div>
<div class="info-value">{{ scheduleInfo.class_name || '未设置' }}</div>
</div>
<div class="info-item">
<div class="info-label">
<el-icon><Avatar /></el-icon>
主教练
</div>
<div class="info-value">{{ scheduleInfo.coach_name || '待安排' }}</div>
</div>
<div class="info-item">
<div class="info-label">
<el-icon><MapLocation /></el-icon>
上课场地
</div>
<div class="info-value">{{ scheduleInfo.venue_name || '待安排' }}</div>
</div>
<div class="info-item">
<div class="info-label">
<el-icon><OfficeBuilding /></el-icon>
所属校区
</div>
<div class="info-value">{{ scheduleInfo.campus_name || '未设置' }}</div>
</div>
<div class="info-item">
<div class="info-label">
<el-icon><User /></el-icon>
课程容量
</div>
<div class="info-value">{{ scheduleInfo.max_students || 0 }}</div>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import {
Calendar,
Clock,
Reading,
UserFilled,
Avatar,
MapLocation,
OfficeBuilding,
User
} from '@element-plus/icons-vue'
import type { ScheduleInfo } from '@/app/types/course-schedule'
// Props
interface Props {
scheduleInfo: ScheduleInfo
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
//
const getStatusText = (status?: string) => {
const statusMap: Record<string, string> = {
'pending': '待开始',
'upcoming': '即将开始',
'ongoing': '进行中',
'completed': '已结束'
}
return statusMap[status || ''] || status || '未知状态'
}
//
const getStatusTagType = (status?: string) => {
const typeMap: Record<string, string> = {
'pending': 'info',
'upcoming': 'warning',
'ongoing': 'success',
'completed': ''
}
return typeMap[status || ''] || 'info'
}
</script>
<style lang="scss" scoped>
.course-info-section {
.course-info-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.course-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
.el-icon {
color: #409eff;
}
}
}
.course-info-content {
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
.info-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: #f8f9fa;
border-radius: 6px;
border-left: 4px solid #409eff;
.info-label {
font-size: 14px;
color: #606266;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
.el-icon {
color: #909399;
font-size: 16px;
}
}
.info-value {
font-size: 16px;
color: #303133;
font-weight: 600;
word-break: break-all;
}
}
}
}
}
}
//
:deep(.el-card__header) {
background: #fafafa;
border-bottom: 1px solid #e4e7ed;
}
:deep(.el-card__body) {
padding: 20px;
}
</style>

230
admin/src/app/views/course_schedule/components/empty-seat-card.vue

@ -0,0 +1,230 @@
<template>
<div
:class="[
'empty-seat-card',
`empty-seat-card--${type}`,
{ 'empty-seat-card--clickable': !disabled }
]"
@click="handleClick"
>
<!-- 空位标识 ---->
<div class="empty-indicator">
<el-icon class="add-icon"><Plus /></el-icon>
</div>
<!-- 空位信息 ---->
<div class="empty-content">
<div class="empty-title">
{{ type === 'formal' ? '正式位空位' : '等待位空位' }}
</div>
<div class="empty-subtitle">
位置 #{{ index }}
</div>
<div class="empty-action">
<el-icon><UserFilled /></el-icon>
点击添加学员
</div>
</div>
<!-- 悬浮操作提示 ---->
<div class="hover-tip">
<el-icon><Plus /></el-icon>
添加学员
</div>
</div>
</template>
<script lang="ts" setup>
import { Plus, UserFilled } from '@element-plus/icons-vue'
// Props
interface Props {
type: 'formal' | 'waiting'
index: number
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
// Emits
interface Emits {
click: []
}
const emit = defineEmits<Emits>()
//
const handleClick = () => {
if (!props.disabled) {
emit('click')
}
}
</script>
<style lang="scss" scoped>
.empty-seat-card {
position: relative;
background: #ffffff;
border: 2px dashed #d9d9d9;
border-radius: 12px;
padding: 16px;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
transition: all 0.3s ease;
&--clickable {
cursor: pointer;
&:hover {
border-color: #409eff;
background: #f0f8ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
.empty-indicator {
background: #409eff;
color: white;
transform: scale(1.1);
}
.hover-tip {
opacity: 1;
transform: translateY(0);
}
}
}
&--formal {
border-left: 4px dashed #409eff;
&:hover {
border-left-color: #409eff;
}
.empty-indicator {
border: 2px solid #409eff;
color: #409eff;
}
}
&--waiting {
border-left: 4px dashed #f56c6c;
background: linear-gradient(135deg, #fff9f9 0%, #ffffff 100%);
&:hover {
border-left-color: #f56c6c;
background: linear-gradient(135deg, #fef5f5 0%, #f0f8ff 100%);
border-color: #f56c6c;
}
.empty-indicator {
border: 2px solid #f56c6c;
color: #f56c6c;
}
&:hover .empty-indicator {
background: #f56c6c;
}
}
.empty-indicator {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
transition: all 0.3s ease;
.add-icon {
font-size: 24px;
font-weight: bold;
}
}
.empty-content {
flex: 1;
.empty-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.empty-subtitle {
font-size: 14px;
color: #909399;
margin-bottom: 12px;
}
.empty-action {
font-size: 12px;
color: #606266;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
.el-icon {
font-size: 14px;
}
}
}
.hover-tip {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: #409eff;
color: white;
padding: 6px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
opacity: 0;
transition: all 0.3s ease;
white-space: nowrap;
.el-icon {
font-size: 12px;
}
}
&--waiting .hover-tip {
background: #f56c6c;
}
}
//
.empty-seat-card:not(.empty-seat-card--clickable) {
opacity: 0.6;
cursor: not-allowed;
&:hover {
border-color: #d9d9d9;
background: #ffffff;
transform: none;
box-shadow: none;
.empty-indicator {
transform: none;
}
}
&.empty-seat-card--waiting:hover {
background: linear-gradient(135deg, #fff9f9 0%, #ffffff 100%);
}
}
</style>

442
admin/src/app/views/course_schedule/components/student-action-menu.vue

@ -0,0 +1,442 @@
<template>
<el-dialog
v-model="visible"
:title="`学员操作 - ${student?.name || '未知学员'}`"
width="500px"
class="student-action-modal"
:destroy-on-close="true"
>
<div v-if="student" class="student-detail-container">
<!-- 学员基本信息 -->
<div class="student-info-section">
<div class="student-header">
<div class="student-avatar">
{{ student.name ? student.name.charAt(0) : '?' }}
</div>
<div class="student-basic-info">
<h3 class="student-name">{{ student.name }}</h3>
<div class="student-tags">
<el-tag
:type="student.schedule_type === 1 ? 'primary' : 'warning'"
size="small"
>
{{ student.schedule_type === 1 ? '正式位' : '等待位' }}
</el-tag>
<el-tag
:type="getPersonTypeTag(student.person_type)"
size="small"
>
{{ getPersonTypeText(student.person_type) }}
</el-tag>
</div>
</div>
</div>
<div class="student-details">
<div class="detail-row">
<div class="detail-item">
<el-icon><Phone /></el-icon>
<span class="label">手机号</span>
<span class="value">{{ student.phone || '未知' }}</span>
</div>
<div class="detail-item">
<el-icon><User /></el-icon>
<span class="label">年龄</span>
<span class="value">{{ student.age || '未知' }}</span>
</div>
</div>
<div class="detail-row">
<div class="detail-item">
<el-icon><Clock /></el-icon>
<span class="label">状态</span>
<span class="value">{{ student.courseStatus || '未知' }}</span>
</div>
<div class="detail-item">
<el-icon><Flag /></el-icon>
<span class="label">签到状态</span>
<el-tag
:type="getStatusTagType(student.status)"
size="small"
>
{{ getStatusText(student.status) }}
</el-tag>
</div>
</div>
<!-- 课程进度 -->
<div v-if="student.course_progress" class="progress-section">
<div class="progress-header">
<el-icon><Reading /></el-icon>
<span class="label">课程进度</span>
<span class="value">{{ student.course_progress.used }}/{{ student.course_progress.total }}</span>
</div>
<el-progress
:percentage="student.course_progress.percentage"
:stroke-width="6"
:color="getProgressColor(student.course_progress.percentage)"
/>
</div>
<!-- 到期时间 -->
<div v-if="student.student_course_info?.end_date" class="detail-row">
<div class="detail-item full-width">
<el-icon><Calendar /></el-icon>
<span class="label">到期时间</span>
<span class="value">{{ formatDate(student.student_course_info.end_date) }}</span>
</div>
</div>
</div>
</div>
<!-- 续费提醒 -->
<div v-if="student.renewal_status" class="renewal-alert">
<el-alert
title="续费提醒"
description="该学员需要办理续费手续"
type="warning"
:closable="false"
show-icon
/>
</div>
<!-- 操作按钮区域 -->
<div class="action-section">
<h4 class="action-title">
<el-icon><Operation /></el-icon>
可执行操作
</h4>
<div class="action-buttons">
<!-- 查看详情 -->
<el-button
type="primary"
:icon="View"
@click="handleAction('view')"
>
查看详情
</el-button>
<!-- 更新签到状态 -->
<el-button
type="success"
:icon="Check"
@click="handleAction('checkin')"
>
更新签到
</el-button>
<!-- 升级到正式位仅等待位显示 -->
<el-button
v-if="student.schedule_type === 2 && formalHasEmpty"
type="warning"
:icon="Top"
@click="handleAction('upgrade')"
>
升级到正式位
</el-button>
<!-- 请假/取消请假 -->
<el-button
:type="student.status === 2 ? 'info' : 'warning'"
:icon="student.status === 2 ? Refresh : Clock"
@click="handleAction(student.status === 2 ? 'cancel_leave' : 'leave')"
>
{{ student.status === 2 ? '取消请假' : '请假' }}
</el-button>
<!-- 移除学员 -->
<el-button
type="danger"
:icon="Delete"
@click="handleAction('remove')"
>
移除学员
</el-button>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import {
Phone,
User,
Clock,
Flag,
Reading,
Calendar,
Operation,
View,
Check,
Top,
Refresh,
Delete
} from '@element-plus/icons-vue'
import type { StudentInfo, StudentActionType } from '@/app/types/course-schedule'
// Props
interface Props {
visible: boolean
student?: StudentInfo | null
formalHasEmpty?: boolean
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
student: null,
formalHasEmpty: false
})
// Emits
interface Emits {
'update:visible': [value: boolean]
'confirm-action': [action: string]
}
const emit = defineEmits<Emits>()
//
const visible = ref(false)
// visible prop
watch(() => props.visible, (newVal) => {
visible.value = newVal
})
// visible
watch(visible, (newVal) => {
emit('update:visible', newVal)
})
//
const getPersonTypeText = (type: string) => {
const typeMap: Record<string, string> = {
'student': '正式学员',
'customer_resource': '潜在客户'
}
return typeMap[type] || type
}
//
const getPersonTypeTag = (type: string) => {
const tagMap: Record<string, string> = {
'student': 'success',
'customer_resource': 'info'
}
return tagMap[type] || 'info'
}
//
const getStatusText = (status: number) => {
const statusMap: Record<number, string> = {
0: '待上课',
1: '已签到',
2: '请假'
}
return statusMap[status] || '未知'
}
//
const getStatusTagType = (status: number) => {
const typeMap: Record<number, string> = {
0: 'info',
1: 'success',
2: 'warning'
}
return typeMap[status] || 'info'
}
//
const getProgressColor = (percentage: number) => {
if (percentage < 30) return '#67c23a'
if (percentage < 70) return '#e6a23c'
return '#f56c6c'
}
//
const formatDate = (dateStr: string) => {
if (!dateStr) return '未设置'
try {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN')
} catch {
return dateStr
}
}
//
const handleAction = (action: string) => {
emit('confirm-action', action)
visible.value = false
}
//
const handleClose = () => {
visible.value = false
}
</script>
<style lang="scss" scoped>
.student-action-modal {
.student-detail-container {
.student-info-section {
margin-bottom: 24px;
.student-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
.student-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #409eff 0%, #67c23a 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
flex-shrink: 0;
}
.student-basic-info {
flex: 1;
.student-name {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.student-tags {
display: flex;
gap: 8px;
}
}
}
.student-details {
.detail-row {
display: flex;
gap: 24px;
margin-bottom: 12px;
.detail-item {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
&.full-width {
flex: 100%;
}
.el-icon {
color: #909399;
font-size: 16px;
}
.label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.value {
font-size: 14px;
color: #303133;
}
}
}
.progress-section {
margin: 16px 0;
padding: 12px;
background: #f0f8ff;
border-radius: 6px;
border-left: 4px solid #409eff;
.progress-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
.el-icon {
color: #409eff;
font-size: 16px;
}
.label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.value {
font-size: 14px;
color: #303133;
font-weight: 600;
}
}
}
}
}
.renewal-alert {
margin-bottom: 24px;
}
.action-section {
.action-title {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
.el-icon {
color: #409eff;
}
}
.action-buttons {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
.el-button {
width: 100%;
}
}
}
}
}
:deep(.el-dialog__body) {
padding: 20px;
}
:deep(.el-progress-bar__outer) {
background-color: #f0f2f5;
}
</style>

373
admin/src/app/views/course_schedule/components/student-card.vue

@ -0,0 +1,373 @@
<template>
<div
:class="[
'student-card',
`student-card--${type}`,
{ 'student-card--clickable': !disabled }
]"
@click="handleClick"
>
<!-- 续费提醒标签 -->
<div v-if="student.renewal_status" class="renewal-badge">
<el-icon><Warning /></el-icon>
待续费
</div>
<!-- 签到状态标签 -->
<div
v-if="student.status !== undefined"
:class="['status-badge', getStatusClass(student.status)]"
>
{{ getStatusText(student.status) }}
</div>
<div class="student-content">
<!-- 头像区域 -->
<div :class="['student-avatar', `student-avatar--${type}`]">
{{ student.name ? student.name.charAt(0) : '?' }}
</div>
<!-- 学员信息区域 -->
<div class="student-info">
<div class="student-name">
{{ student.name || '未知学员' }}
<el-tag
v-if="type === 'waiting'"
type="warning"
size="small"
class="position-tag"
>
等待位
</el-tag>
</div>
<div class="student-details">
<div class="detail-item">
<el-icon><Phone /></el-icon>
{{ student.phone || '未知' }}
</div>
<div class="detail-item">
<el-icon><User /></el-icon>
年龄{{ student.age || '未知' }}
</div>
<div class="detail-item">
<el-icon><Clock /></el-icon>
状态{{ student.courseStatus || '未知' }}
</div>
<!-- 课程进度 -->
<div v-if="student.course_progress" class="course-progress">
<div class="progress-label">
<el-icon><Reading /></el-icon>
上课进度{{ student.course_progress.used }}/{{ student.course_progress.total }}
</div>
<el-progress
:percentage="student.course_progress.percentage"
:stroke-width="4"
:show-text="false"
:color="getProgressColor(student.course_progress.percentage)"
/>
</div>
<!-- 到期时间 -->
<div v-if="student.student_course_info?.end_date" class="detail-item">
<el-icon><Calendar /></el-icon>
到期{{ formatDate(student.student_course_info.end_date) }}
</div>
</div>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="student-actions">
<el-tooltip content="查看详情" placement="top">
<el-button
type="primary"
size="small"
:icon="View"
circle
@click.stop="$emit('view')"
/>
</el-tooltip>
<el-tooltip content="更多操作" placement="top">
<el-button
type="info"
size="small"
:icon="More"
circle
@click.stop="$emit('more')"
/>
</el-tooltip>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import {
Warning,
Phone,
User,
Clock,
Reading,
Calendar,
View,
More
} from '@element-plus/icons-vue'
import type { StudentInfo } from '@/app/types/course-schedule'
// Props
interface Props {
student: StudentInfo
type: 'formal' | 'waiting'
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
// Emits
interface Emits {
click: []
view: []
more: []
}
const emit = defineEmits<Emits>()
//
const handleClick = () => {
if (!props.disabled) {
emit('click')
}
}
//
const getStatusText = (status: number) => {
const statusMap: Record<number, string> = {
0: '待上课',
1: '已签到',
2: '请假'
}
return statusMap[status] || '未知'
}
//
const getStatusClass = (status: number) => {
const classMap: Record<number, string> = {
0: 'status-pending',
1: 'status-attended',
2: 'status-leave'
}
return classMap[status] || ''
}
//
const getProgressColor = (percentage: number) => {
if (percentage < 30) return '#67c23a'
if (percentage < 70) return '#e6a23c'
return '#f56c6c'
}
//
const formatDate = (dateStr: string) => {
if (!dateStr) return '未设置'
try {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN')
} catch {
return dateStr
}
}
</script>
<style lang="scss" scoped>
.student-card {
position: relative;
background: #ffffff;
border: 2px solid #e4e7ed;
border-radius: 12px;
padding: 16px;
transition: all 0.3s ease;
min-height: 200px;
display: flex;
flex-direction: column;
&--clickable {
cursor: pointer;
&:hover {
border-color: #409eff;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
transform: translateY(-2px);
}
}
&--formal {
border-left: 4px solid #409eff;
&:hover {
border-left-color: #409eff;
}
}
&--waiting {
border-left: 4px solid #f56c6c;
background: linear-gradient(135deg, #fff9f9 0%, #ffffff 100%);
&:hover {
border-left-color: #f56c6c;
}
}
.renewal-badge {
position: absolute;
top: 8px;
right: 8px;
background: #f56c6c;
color: white;
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 4px;
z-index: 2;
.el-icon {
font-size: 12px;
}
}
.status-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
z-index: 2;
&.status-pending {
background: #e6f7ff;
color: #1890ff;
border: 1px solid #91d5ff;
}
&.status-attended {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
&.status-leave {
background: #fff2e8;
color: #fa8c16;
border: 1px solid #ffd591;
}
}
.student-content {
flex: 1;
display: flex;
gap: 12px;
margin-top: 20px;
.student-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
color: white;
flex-shrink: 0;
&--formal {
background: linear-gradient(135deg, #409eff 0%, #67c23a 100%);
}
&--waiting {
background: linear-gradient(135deg, #f56c6c 0%, #e6a23c 100%);
}
}
.student-info {
flex: 1;
.student-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
.position-tag {
font-size: 10px;
}
}
.student-details {
.detail-item {
font-size: 12px;
color: #606266;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 4px;
.el-icon {
color: #909399;
font-size: 12px;
}
}
.course-progress {
margin-top: 8px;
.progress-label {
font-size: 12px;
color: #606266;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 4px;
.el-icon {
color: #909399;
font-size: 12px;
}
}
}
}
}
}
.student-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover .student-actions {
opacity: 1;
}
}
:deep(.el-progress-bar__outer) {
background-color: #f0f2f5;
}
</style>

570
admin/src/app/views/course_schedule/components/student-search-modal.vue

@ -0,0 +1,570 @@
<template>
<el-dialog
v-model="visible"
title="添加学员"
width="600px"
class="student-search-modal"
:destroy-on-close="true"
@open="handleDialogOpen"
@close="handleDialogClose"
>
<div class="search-container">
<!-- 预设学员信息 -->
<div v-if="presetStudent" class="preset-student-section">
<h4 class="section-title">
<el-icon><Star /></el-icon>
预设学员信息
</h4>
<div class="preset-student-card">
<div class="student-avatar">
{{ presetStudent.name ? presetStudent.name.charAt(0) : '?' }}
</div>
<div class="student-info">
<div class="student-name">{{ presetStudent.name || '未知学员' }}</div>
<div class="student-details">
<div class="detail-item">
<el-icon><Phone /></el-icon>
{{ presetStudent.phone || '未知' }}
</div>
<div class="detail-item">
<el-icon><User /></el-icon>
类型{{ presetStudent.person_type === 'student' ? '正式学员' : '潜在客户' }}
</div>
</div>
</div>
<div class="preset-actions">
<el-button type="primary" size="small" @click="selectPresetStudent">
选择此学员
</el-button>
</div>
</div>
<el-divider>
<span class="divider-text">或者搜索其他学员</span>
</el-divider>
</div>
<!-- 搜索区域 -->
<div class="search-section">
<h4 class="section-title">
<el-icon><Search /></el-icon>
搜索学员
</h4>
<div class="search-form">
<el-row :gutter="16">
<el-col :span="12">
<el-input
v-model="searchForm.name"
placeholder="请输入学员姓名"
prefix-icon="User"
clearable
@input="handleSearch"
@clear="handleSearch"
/>
</el-col>
<el-col :span="12">
<el-input
v-model="searchForm.phone_number"
placeholder="请输入手机号"
prefix-icon="Phone"
clearable
@input="handleSearch"
@clear="handleSearch"
/>
</el-col>
</el-row>
</div>
</div>
<!-- 搜索结果 -->
<div class="search-results">
<div v-if="searching" class="loading-wrapper">
<el-icon class="is-loading"><Loading /></el-icon>
搜索中...
</div>
<div v-else-if="searchResults.length === 0 && hasSearched" class="empty-results">
<el-empty description="未找到匹配的学员" />
</div>
<div v-else-if="searchResults.length > 0" class="results-list">
<h4 class="section-title">
<el-icon><List /></el-icon>
搜索结果 ({{ searchResults.length }})
</h4>
<div class="student-list">
<div
v-for="student in searchResults"
:key="student.id"
:class="[
'search-result-item',
{ 'selected': selectedStudent?.id === student.id }
]"
@click="selectStudent(student)"
>
<div class="student-avatar">
{{ student.name ? student.name.charAt(0) : '?' }}
</div>
<div class="student-info">
<div class="student-name">{{ student.name }}</div>
<div class="student-details">
<div class="detail-item">
<el-icon><Phone /></el-icon>
{{ student.phone }}
</div>
<div class="detail-item">
<el-icon><User /></el-icon>
类型{{ student.person_type === 'student' ? '正式学员' : '潜在客户' }}
</div>
<div v-if="student.age" class="detail-item">
<el-icon><Calendar /></el-icon>
年龄{{ student.age }}
</div>
</div>
</div>
<div class="selection-indicator">
<el-icon v-if="selectedStudent?.id === student.id"><Check /></el-icon>
</div>
</div>
</div>
</div>
<div v-else class="search-hint">
<el-icon><Search /></el-icon>
请输入学员姓名或手机号进行搜索
</div>
</div>
<!-- 位置信息 -->
<div class="position-info">
<el-alert
:title="`将添加到:${currentSlot?.type === 'formal' ? '正式位' : '等待位'} #${currentSlot?.index}`"
type="info"
:closable="false"
show-icon
/>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button
type="primary"
:disabled="!selectedStudent"
:loading="confirming"
@click="handleConfirm"
>
确定添加
</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, reactive, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import {
Star,
Phone,
User,
Search,
Loading,
List,
Check,
Calendar
} from '@element-plus/icons-vue'
import { searchStudents } from '@/app/api/course_schedule'
import type {
StudentInfo,
SlotInfo,
SearchStudentsParams,
ApiResponse
} from '@/app/types/course-schedule'
// Props
interface Props {
visible: boolean
currentSlot?: SlotInfo | null
presetStudent?: StudentInfo | null
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
currentSlot: null,
presetStudent: null
})
// Emits
interface Emits {
'update:visible': [value: boolean]
confirm: [student: StudentInfo, slot: SlotInfo]
}
const emit = defineEmits<Emits>()
//
const visible = ref(false)
const searching = ref(false)
const confirming = ref(false)
const hasSearched = ref(false)
const searchResults = ref<StudentInfo[]>([])
const selectedStudent = ref<StudentInfo | null>(null)
//
const searchForm = reactive({
name: '',
phone_number: ''
})
// visible prop
watch(() => props.visible, (newVal) => {
visible.value = newVal
})
// visible
watch(visible, (newVal) => {
emit('update:visible', newVal)
})
//
const handleDialogOpen = () => {
resetForm()
//
if (props.presetStudent) {
selectedStudent.value = props.presetStudent
}
}
//
const handleDialogClose = () => {
resetForm()
}
//
const resetForm = () => {
searchForm.name = ''
searchForm.phone_number = ''
searchResults.value = []
selectedStudent.value = null
hasSearched.value = false
}
//
let searchTimer: NodeJS.Timeout | null = null
//
const handleSearch = () => {
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
performSearch()
}, 500)
}
//
const performSearch = async () => {
const { name, phone_number } = searchForm
if (!name.trim() && !phone_number.trim()) {
searchResults.value = []
hasSearched.value = false
return
}
try {
searching.value = true
const searchParams: SearchStudentsParams = {
name: name.trim() || undefined,
phone_number: phone_number.trim() || undefined
}
console.log('搜索学员参数:', searchParams)
const response: ApiResponse<StudentInfo[]> = await searchStudents(searchParams)
if (response.code === 1) {
searchResults.value = response.data || []
hasSearched.value = true
console.log('搜索结果:', searchResults.value)
} else {
ElMessage.error(response.msg || '搜索失败')
searchResults.value = []
}
} catch (error) {
console.error('搜索学员失败:', error)
ElMessage.error('搜索失败')
searchResults.value = []
} finally {
searching.value = false
}
}
//
const selectStudent = (student: StudentInfo) => {
selectedStudent.value = student
}
//
const selectPresetStudent = () => {
if (props.presetStudent) {
selectedStudent.value = props.presetStudent
}
}
//
const handleConfirm = async () => {
if (!selectedStudent.value || !props.currentSlot) {
ElMessage.warning('请选择学员')
return
}
try {
confirming.value = true
emit('confirm', selectedStudent.value, props.currentSlot)
visible.value = false
} catch (error) {
console.error('添加学员失败:', error)
} finally {
confirming.value = false
}
}
//
const handleCancel = () => {
visible.value = false
}
</script>
<style lang="scss" scoped>
.student-search-modal {
.search-container {
max-height: 60vh;
overflow-y: auto;
.section-title {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
.el-icon {
color: #409eff;
}
}
.preset-student-section {
margin-bottom: 24px;
.preset-student-card {
display: flex;
align-items: center;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
gap: 12px;
.student-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #409eff 0%, #67c23a 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: bold;
flex-shrink: 0;
}
.student-info {
flex: 1;
.student-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.student-details {
.detail-item {
font-size: 12px;
color: #606266;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 4px;
.el-icon {
color: #909399;
font-size: 12px;
}
}
}
}
.preset-actions {
flex-shrink: 0;
}
}
.divider-text {
color: #909399;
font-size: 14px;
}
}
.search-section {
margin-bottom: 24px;
.search-form {
margin-top: 16px;
}
}
.search-results {
margin-bottom: 24px;
.loading-wrapper {
text-align: center;
padding: 40px;
color: #606266;
.el-icon {
font-size: 24px;
margin-right: 8px;
}
}
.empty-results {
padding: 20px 0;
}
.search-hint {
text-align: center;
padding: 40px;
color: #909399;
.el-icon {
font-size: 24px;
margin-bottom: 8px;
}
}
.results-list {
.student-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
.search-result-item {
display: flex;
align-items: center;
padding: 12px;
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
gap: 12px;
&:hover {
border-color: #409eff;
background: #f0f8ff;
}
&.selected {
border-color: #409eff;
background: #f0f8ff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.student-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #409eff 0%, #67c23a 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
flex-shrink: 0;
}
.student-info {
flex: 1;
.student-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.student-details {
.detail-item {
font-size: 12px;
color: #606266;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 4px;
.el-icon {
color: #909399;
font-size: 12px;
}
}
}
}
.selection-indicator {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
.el-icon {
color: #409eff;
font-size: 16px;
}
}
}
}
}
}
.position-info {
margin-bottom: 16px;
}
}
}
:deep(.el-dialog__body) {
padding: 20px;
}
:deep(.el-alert) {
.el-alert__content {
font-size: 14px;
}
}
</style>

194
admin/src/app/views/course_schedule/components/student-section.vue

@ -0,0 +1,194 @@
<template>
<div class="student-section">
<!-- 正式学员区域 -->
<el-card class="student-card" shadow="never">
<template #header>
<div class="section-header">
<h4 class="section-title">
<el-icon><UserFilled /></el-icon>
正式学员
<el-badge
:value="formalStudents.length"
type="primary"
class="student-count-badge"
/>
</h4>
<div class="section-info">
剩余容量: {{ formalEmptySeats.length }}
</div>
</div>
</template>
<div v-loading="loading" class="students-grid">
<!-- 正式学员卡片 -->
<student-card
v-for="student in formalStudents"
:key="`formal-${student.id}`"
:student="student"
type="formal"
@click="$emit('viewStudent', student)"
/>
<!-- 正式位空位卡片 -->
<empty-seat-card
v-for="index in formalEmptySeats"
:key="`formal-empty-${index}`"
type="formal"
:index="index"
@click="$emit('addStudent', { type: 'formal', index })"
/>
</div>
</el-card>
<!-- 等待位学员区域 -->
<el-card class="student-card waiting-card" shadow="never">
<template #header>
<div class="section-header">
<h4 class="section-title waiting-title">
<el-icon><Clock /></el-icon>
等待位
<el-badge
:value="waitingStudents.length"
type="warning"
class="student-count-badge"
/>
</h4>
<div class="section-info">
剩余等待位: {{ waitingEmptySeats.length }}
</div>
</div>
</template>
<div v-loading="loading" class="students-grid">
<!-- 等待位学员卡片 -->
<student-card
v-for="student in waitingStudents"
:key="`waiting-${student.id}`"
:student="student"
type="waiting"
@click="$emit('viewStudent', student)"
/>
<!-- 等待位空位卡片 -->
<empty-seat-card
v-for="index in waitingEmptySeats"
:key="`waiting-empty-${index}`"
type="waiting"
:index="index"
@click="$emit('addStudent', { type: 'waiting', index })"
/>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { UserFilled, Clock } from '@element-plus/icons-vue'
import StudentCard from './student-card.vue'
import EmptySeatCard from './empty-seat-card.vue'
import type { StudentInfo, SlotInfo } from '@/app/types/course-schedule'
// Props
interface Props {
formalStudents: StudentInfo[]
waitingStudents: StudentInfo[]
formalEmptySeats: number[]
waitingEmptySeats: number[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
// Emits
interface Emits {
addStudent: [slotInfo: SlotInfo]
viewStudent: [student: StudentInfo]
removeStudent: [student: StudentInfo]
upgradeStudent: [student: StudentInfo]
}
defineEmits<Emits>()
</script>
<style lang="scss" scoped>
.student-section {
display: flex;
flex-direction: column;
gap: 24px;
.student-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
&.waiting-card {
border-color: #f56c6c;
background: linear-gradient(135deg, #fff9f9 0%, #ffffff 100%);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
.section-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
.el-icon {
color: #409eff;
}
&.waiting-title {
.el-icon {
color: #f56c6c;
}
}
.student-count-badge {
margin-left: 8px;
}
}
.section-info {
font-size: 14px;
color: #909399;
font-weight: 500;
}
}
.students-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
min-height: 120px;
@media (max-width: 768px) {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}
}
}
:deep(.el-card__header) {
background: #fafafa;
border-bottom: 1px solid #e4e7ed;
padding: 16px 20px;
}
:deep(.el-card__body) {
padding: 20px;
}
:deep(.waiting-card .el-card__header) {
background: linear-gradient(135deg, #fef5f5 0%, #fafafa 100%);
border-bottom-color: #f5c6cb;
}
</style>

11
admin/src/app/views/course_schedule/course_schedule.vue

@ -212,8 +212,8 @@
<edit ref="editCourseScheduleDialog" @complete="loadCourseScheduleList" />
<ff ref="ffCourseScheduleDialog" @complete="loadCourseScheduleList" />
<!-- 课程安排详情对话框 -->
<course-arrangement-detail ref="courseArrangementDetailDialog" />
</el-card>
</div>
</template>
@ -231,7 +231,7 @@ import { getWithCampusList, getAllVenueList } from '@/app/api/venue'
import { img } from '@/utils/common'
import { ElMessageBox, FormInstance } from 'element-plus'
import Edit from '@/app/views/course_schedule/components/course-schedule-edit.vue'
import Ff from '@/app/views/course_schedule/components/ff.vue'
import CourseArrangementDetail from '@/app/views/course_schedule/components/course-arrangement-detail.vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const pageName = route.meta.title
@ -371,11 +371,10 @@ const editEvent = (data: any) => {
const ffCourseScheduleDialog: Record<string, any> | null = ref(null)
const courseArrangementDetailDialog: Record<string, any> | null = ref(null)
const ffEvent = (data: any) => {
ffCourseScheduleDialog.value.setFormData(data.id,data.resource_id)
ffCourseScheduleDialog.value.showDialog = true
courseArrangementDetailDialog.value.open(data.id, data.resource_id)
}
/**

110
niucloud/app/adminapi/controller/course_schedule/CourseSchedule.php

@ -156,4 +156,114 @@ class CourseSchedule extends BaseAdminController
]);
return success((new CourseScheduleService())->courseInfo($data['id']));
}
/**
* 获取完整课程安排详情(复用移动端逻辑)
* @return \think\Response
*/
public function getScheduleDetail()
{
$data = $this->request->params([
['schedule_id', 0]
]);
// 复用API端的服务层逻辑
return success((new \app\service\api\apiService\CourseScheduleService())->getScheduleDetail($data));
}
/**
* 智能搜索学员
* @return \think\Response
*/
public function searchStudents()
{
$data = $this->request->params([
['phone_number', ''],
['name', ''],
['campus_id', 0]
]);
return success((new \app\service\api\apiService\CourseScheduleService())->searchStudents($data));
}
/**
* 删除学员/请假处理
* @return \think\Response
*/
public function removeStudent()
{
$data = $this->request->params([
['schedule_id', 0],
['person_id', 0],
['person_type', ''],
['reason', '']
]);
return success((new \app\service\api\apiService\CourseScheduleService())->removeStudent($data));
}
/**
* 添加学员到课程安排
* @return \think\Response
*/
public function addSchedule()
{
$data = $this->request->params([
['schedule_id', 0],
['person_type', ''],
['schedule_type', 2], // 默认等待位
['student_id', 0],
['resources_id', 0],
['course_date', ''],
['time_slot', '']
]);
return success((new \app\service\api\apiService\CourseScheduleService())->addStudentToSchedule($data));
}
/**
* 学员升级(等待位→正式位)
* @return \think\Response
*/
public function upgradeStudent()
{
$data = $this->request->params([
['schedule_id', 0],
['person_id', 0],
['from_type', 2], // 从等待位
['to_type', 1] // 到正式位
]);
return success((new \app\service\api\apiService\CourseScheduleService())->upgradeStudent($data));
}
/**
* 更新学员签到状态
* @return \think\Response
*/
public function updateStudentStatus()
{
$data = $this->request->params([
['schedule_id', 0],
['person_id', 0],
['status', 0],
['reason', '']
]);
return success((new \app\service\api\apiService\CourseScheduleService())->updateStudentStatus($data));
}
/**
* 恢复学员(取消请假)
* @return \think\Response
*/
public function restoreStudent()
{
$data = $this->request->params([
['schedule_id', 0],
['person_id', 0]
]);
return success((new \app\service\api\apiService\CourseScheduleService())->restoreStudent($data));
}
}

8
niucloud/app/adminapi/route/course_schedule.php

@ -42,6 +42,14 @@ Route::group('course_schedule', function () {
Route::post('addSchedule', 'course_schedule.CourseSchedule/addSchedule');
// 新增统一接口(复用移动端逻辑)
Route::get('getScheduleDetail', 'course_schedule.CourseSchedule/getScheduleDetail');
Route::get('searchStudents', 'course_schedule.CourseSchedule/searchStudents');
Route::post('removeStudent', 'course_schedule.CourseSchedule/removeStudent');
Route::post('upgradeStudent', 'course_schedule.CourseSchedule/upgradeStudent');
Route::post('updateStudentStatus', 'course_schedule.CourseSchedule/updateStudentStatus');
Route::post('restoreStudent', 'course_schedule.CourseSchedule/restoreStudent');
})->middleware([
AdminCheckToken::class,
AdminCheckRole::class,

659
niucloud/app/common.php

@ -1606,3 +1606,662 @@ function get_personnel_by_campus_role($campus_id, $role_ids = [])
return [];
}
}
// ==================== 合同系统函数 ====================
/**
* 获取当前日期
* @return string
*/
function get_current_date()
{
return date('Y-m-d');
}
/**
* 获取当前时间
* @return string
*/
function get_current_time()
{
return date('H:i:s');
}
/**
* 获取当前日期时间
* @return string
*/
function get_current_datetime()
{
return date('Y-m-d H:i:s');
}
/**
* 获取当前年份
* @return string
*/
function get_current_year()
{
return date('Y');
}
/**
* 获取当前月份
* @return string
*/
function get_current_month()
{
return date('m');
}
/**
* 获取当前日
* @return string
*/
function get_current_day()
{
return date('d');
}
/**
* 获取当前周
* @return string
*/
function get_current_week()
{
return date('W');
}
/**
* 获取当前季度
* @return string
*/
function get_current_quarter()
{
$month = (int)date('m');
return ceil($month / 3);
}
/**
* 获取当前校区名称
* @param int $campus_id 校区ID,为空时尝试从当前用户获取
* @return string
*/
function get_current_campus($campus_id = 0)
{
try {
if (empty($campus_id)) {
// 尝试从当前登录用户获取校区信息
// 这里需要根据实际的用户会话机制来获取
return '默认校区';
}
$campus = \think\facade\Db::table('school_campus')
->where('id', $campus_id)
->value('campus_name');
return $campus ?: '未知校区';
} catch (\Exception $e) {
return '获取校区失败';
}
}
/**
* 获取当前登录用户信息
* @return string
*/
function get_current_login_user()
{
try {
// 这里需要根据实际的用户会话机制来获取当前用户
// 可以从JWT token或session中获取
return '当前用户';
} catch (\Exception $e) {
return '获取用户失败';
}
}
/**
* 获取当前员工信息
* @return string
*/
function get_current_personnel()
{
try {
// 这里需要根据实际的员工会话机制来获取当前员工
return '当前员工';
} catch (\Exception $e) {
return '获取员工失败';
}
}
/**
* 获取合同生成时间
* @return string
*/
function get_contract_generate_time()
{
return date('Y-m-d H:i:s');
}
/**
* 获取合同签署时间
* @return string
*/
function get_contract_sign_time()
{
return date('Y-m-d H:i:s');
}
/**
* 获取系统名称
* @return string
*/
function get_system_name()
{
return '智慧教务系统';
}
/**
* 获取系统版本
* @return string
*/
function get_system_version()
{
return '1.0.0';
}
/**
* 生成随机数字
* @param int $length 数字长度,默认6位
* @return string
*/
function get_random_number($length = 6)
{
$min = pow(10, $length - 1);
$max = pow(10, $length) - 1;
return str_pad(rand($min, $max), $length, '0', STR_PAD_LEFT);
}
/**
* 生成合同序号
* @param string $prefix 前缀,默认为HT
* @return string
*/
function get_contract_sequence($prefix = 'HT')
{
return $prefix . date('Ymd') . get_random_number(4);
}
/**
* 格式化货币
* @param float $amount 金额
* @param string $currency 货币符号,默认为¥
* @return string
*/
function format_currency($amount, $currency = '¥')
{
return $currency . number_format($amount, 2);
}
/**
* 上课规则配置函数
* @param array $params 配置参数
* @return array
*/
function get_class_schedule_rules($params = [])
{
// 默认上课规则配置
$default_rules = [
'class_duration' => 45, // 课时长度(分钟)
'break_duration' => 15, // 课间休息时间(分钟)
'start_time' => '08:00', // 开始时间
'end_time' => '18:00', // 结束时间
'lunch_break_start' => '12:00', // 午休开始时间
'lunch_break_end' => '14:00', // 午休结束时间
'weekend_enabled' => true, // 是否允许周末上课
'holiday_enabled' => false, // 是否允许节假日上课
'max_students_per_class' => 20, // 每班最大学生数
'min_students_per_class' => 5, // 每班最小学生数
'advance_booking_days' => 7, // 提前预约天数
'cancel_deadline_hours' => 24, // 取消课程截止时间(小时)
];
// 合并自定义参数
return array_merge($default_rules, $params);
}
/**
* 生成上课时间表
* @param string $start_date 开始日期
* @param string $end_date 结束日期
* @param array $rules 上课规则
* @return array
*/
function generate_class_schedule($start_date, $end_date, $rules = [])
{
$rules = get_class_schedule_rules($rules);
$schedule = [];
$current_date = strtotime($start_date);
$end_timestamp = strtotime($end_date);
while ($current_date <= $end_timestamp) {
$date_str = date('Y-m-d', $current_date);
$day_of_week = date('w', $current_date);
// 检查是否为周末
if (!$rules['weekend_enabled'] && ($day_of_week == 0 || $day_of_week == 6)) {
$current_date = strtotime('+1 day', $current_date);
continue;
}
// 生成当天的课程时间段
$daily_schedule = generate_daily_time_slots($date_str, $rules);
if (!empty($daily_schedule)) {
$schedule[$date_str] = $daily_schedule;
}
$current_date = strtotime('+1 day', $current_date);
}
return $schedule;
}
/**
* 生成单日课程时间段
* @param string $date 日期
* @param array $rules 规则配置
* @return array
*/
function generate_daily_time_slots($date, $rules)
{
$slots = [];
$current_time = strtotime($date . ' ' . $rules['start_time']);
$end_time = strtotime($date . ' ' . $rules['end_time']);
$lunch_start = strtotime($date . ' ' . $rules['lunch_break_start']);
$lunch_end = strtotime($date . ' ' . $rules['lunch_break_end']);
$slot_number = 1;
while ($current_time < $end_time) {
$slot_end = $current_time + ($rules['class_duration'] * 60);
// 检查是否与午休时间冲突
if ($current_time < $lunch_end && $slot_end > $lunch_start) {
$current_time = $lunch_end;
continue;
}
$slots[] = [
'slot_number' => $slot_number,
'start_time' => date('H:i', $current_time),
'end_time' => date('H:i', $slot_end),
'duration' => $rules['class_duration'],
'available' => true
];
$current_time = $slot_end + ($rules['break_duration'] * 60);
$slot_number++;
}
return $slots;
}
/**
* 验证上课时间冲突
* @param string $date 日期
* @param string $start_time 开始时间
* @param string $end_time 结束时间
* @param int $teacher_id 教师ID
* @param int $classroom_id 教室ID
* @return bool
*/
function check_class_time_conflict($date, $start_time, $end_time, $teacher_id = 0, $classroom_id = 0)
{
try {
$where = [
['date', '=', $date],
['status', '=', 1], // 正常状态
];
// 时间冲突检查:新课程的开始时间在已有课程时间范围内,或新课程的结束时间在已有课程时间范围内
$time_conflict = [
['start_time', '<', $end_time],
['end_time', '>', $start_time]
];
$where = array_merge($where, $time_conflict);
// 检查教师冲突
if ($teacher_id > 0) {
$teacher_conflict = \think\facade\Db::table('school_class_schedule')
->where($where)
->where('teacher_id', $teacher_id)
->count();
if ($teacher_conflict > 0) {
return true; // 有冲突
}
}
// 检查教室冲突
if ($classroom_id > 0) {
$classroom_conflict = \think\facade\Db::table('school_class_schedule')
->where($where)
->where('classroom_id', $classroom_id)
->count();
if ($classroom_conflict > 0) {
return true; // 有冲突
}
}
return false; // 无冲突
} catch (\Exception $e) {
\think\facade\Log::write('检查上课时间冲突失败:' . $e->getMessage());
return true; // 异常情况下认为有冲突,确保安全
}
}
// ==================== 签名图片处理函数 ====================
/**
* 获取校区印章图片URL
* @param int $campus_id 校区ID
* @return string 印章图片URL
*/
function get_campus_seal_image($campus_id)
{
try {
if (empty($campus_id)) {
return '';
}
$seal_image = \think\facade\Db::table('school_campus')
->where('id', $campus_id)
->value('seal_image');
if (empty($seal_image)) {
return '';
}
// 如果已经是完整URL,直接返回
if (str_contains($seal_image, 'http://') || str_contains($seal_image, 'https://')) {
return $seal_image;
}
// 转换为完整的URL路径
return get_file_url($seal_image);
} catch (\Exception $e) {
\think\facade\Log::write('获取校区印章图片失败:' . $e->getMessage());
return '';
}
}
/**
* 验证并处理上传的签名图片
* @param array $file 上传的文件信息
* @param array $options 配置选项
* @return array 处理结果
*/
function process_signature_image($file, $options = [])
{
try {
// 默认配置
$default_options = [
'max_size' => 2 * 1024 * 1024, // 2MB
'allowed_types' => ['jpg', 'jpeg', 'png', 'gif'],
'save_path' => 'upload/signature/',
'compress_quality' => 80, // 图片压缩质量
'max_width' => 800, // 最大宽度
'max_height' => 600, // 最大高度
];
$config = array_merge($default_options, $options);
// 验证文件大小
if ($file['size'] > $config['max_size']) {
return [
'success' => false,
'message' => '图片文件大小不能超过' . ($config['max_size'] / 1024 / 1024) . 'MB'
];
}
// 验证文件类型
$file_ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($file_ext, $config['allowed_types'])) {
return [
'success' => false,
'message' => '只支持' . implode('、', $config['allowed_types']) . '格式的图片'
];
}
// 创建保存目录
$save_dir = $config['save_path'] . date('Y/m/d') . '/';
if (!is_dir($save_dir) && !mkdir($save_dir, 0755, true)) {
return [
'success' => false,
'message' => '创建保存目录失败'
];
}
// 生成文件名
$filename = 'signature_' . date('YmdHis') . '_' . uniqid() . '.' . $file_ext;
$save_path = $save_dir . $filename;
// 移动文件
if (!move_uploaded_file($file['tmp_name'], $save_path)) {
return [
'success' => false,
'message' => '文件保存失败'
];
}
// 压缩图片(如果需要)
$compressed_path = compress_signature_image($save_path, $config);
if ($compressed_path) {
$save_path = $compressed_path;
}
return [
'success' => true,
'message' => '签名图片上传成功',
'data' => [
'file_path' => $save_path,
'file_url' => get_file_url($save_path),
'file_size' => filesize($save_path),
'original_name' => $file['name']
]
];
} catch (\Exception $e) {
\think\facade\Log::write('处理签名图片失败:' . $e->getMessage());
return [
'success' => false,
'message' => '处理签名图片失败:' . $e->getMessage()
];
}
}
/**
* 压缩签名图片
* @param string $source_path 源文件路径
* @param array $config 压缩配置
* @return string|false 压缩后的文件路径
*/
function compress_signature_image($source_path, $config)
{
try {
// 获取图片信息
$image_info = getimagesize($source_path);
if (!$image_info) {
return false;
}
[$width, $height, $type] = $image_info;
// 如果图片尺寸已经符合要求,不需要压缩
if ($width <= $config['max_width'] && $height <= $config['max_height']) {
return $source_path;
}
// 计算新尺寸
$ratio = min($config['max_width'] / $width, $config['max_height'] / $height);
$new_width = intval($width * $ratio);
$new_height = intval($height * $ratio);
// 创建源图像资源
switch ($type) {
case IMAGETYPE_JPEG:
$source_image = imagecreatefromjpeg($source_path);
break;
case IMAGETYPE_PNG:
$source_image = imagecreatefrompng($source_path);
break;
case IMAGETYPE_GIF:
$source_image = imagecreatefromgif($source_path);
break;
default:
return false;
}
if (!$source_image) {
return false;
}
// 创建新图像
$new_image = imagecreatetruecolor($new_width, $new_height);
// 保持透明度(PNG/GIF)
if ($type == IMAGETYPE_PNG || $type == IMAGETYPE_GIF) {
imagealphablending($new_image, false);
imagesavealpha($new_image, true);
$transparent = imagecolorallocatealpha($new_image, 255, 255, 255, 127);
imagefill($new_image, 0, 0, $transparent);
}
// 重新采样
imagecopyresampled($new_image, $source_image, 0, 0, 0, 0,
$new_width, $new_height, $width, $height);
// 生成压缩后的文件路径
$compressed_path = str_replace('.', '_compressed.', $source_path);
// 保存压缩后的图片
$saved = false;
switch ($type) {
case IMAGETYPE_JPEG:
$saved = imagejpeg($new_image, $compressed_path, $config['compress_quality']);
break;
case IMAGETYPE_PNG:
$saved = imagepng($new_image, $compressed_path);
break;
case IMAGETYPE_GIF:
$saved = imagegif($new_image, $compressed_path);
break;
}
// 释放资源
imagedestroy($source_image);
imagedestroy($new_image);
if ($saved) {
// 删除原文件
unlink($source_path);
return $compressed_path;
}
return false;
} catch (\Exception $e) {
\think\facade\Log::write('压缩签名图片失败:' . $e->getMessage());
return false;
}
}
/**
* 获取用户签名记录
* @param int $user_id 用户ID
* @param string $user_type 用户类型(staff/student/member)
* @return array 签名记录
*/
function get_user_signature_records($user_id, $user_type = 'staff')
{
try {
if (empty($user_id)) {
return [];
}
$signatures = \think\facade\Db::table('school_user_signatures')
->where([
['user_id', '=', $user_id],
['user_type', '=', $user_type],
['status', '=', 1], // 有效状态
['deleted_at', '=', 0]
])
->order('created_at', 'desc')
->select()
->toArray();
// 处理签名图片URL
foreach ($signatures as &$signature) {
if (!empty($signature['signature_image'])) {
$signature['signature_url'] = get_file_url($signature['signature_image']);
}
}
return $signatures;
} catch (\Exception $e) {
\think\facade\Log::write('获取用户签名记录失败:' . $e->getMessage());
return [];
}
}
/**
* 保存用户签名记录
* @param array $data 签名数据
* @return int|false 签名记录ID
*/
function save_user_signature($data)
{
try {
$required_fields = ['user_id', 'user_type', 'signature_image'];
foreach ($required_fields as $field) {
if (empty($data[$field])) {
throw new \Exception("缺少必要字段:{$field}");
}
}
$insert_data = [
'user_id' => $data['user_id'],
'user_type' => $data['user_type'],
'signature_image' => $data['signature_image'],
'signature_name' => $data['signature_name'] ?? '用户签名',
'signature_type' => $data['signature_type'] ?? 'image', // image/canvas/handwrite
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'status' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'deleted_at' => 0
];
$signature_id = \think\facade\Db::table('school_user_signatures')
->insertGetId($insert_data);
if ($signature_id) {
\think\facade\Log::write('保存用户签名记录成功:' . json_encode($insert_data));
return $signature_id;
}
return false;
} catch (\Exception $e) {
\think\facade\Log::write('保存用户签名记录失败:' . $e->getMessage());
return false;
}
}

747
niucloud/app/service/api/apiService/CourseScheduleService.php

@ -1667,6 +1667,597 @@ class CourseScheduleService extends BaseApiService
}
}
/**
* 获取完整课程安排详情(包含正式位和等待位学员分类)
* @param array $data 请求参数
* @return array 课程安排详细信息
*/
public function getScheduleDetail(array $data)
{
try {
$scheduleId = $data['schedule_id'];
if (empty($scheduleId)) {
return [
'code' => 0,
'msg' => '课程安排ID不能为空',
'data' => null
];
}
// 获取课程基本信息
$scheduleInfo = Db::name('course_schedule')
->alias('cs')
->leftJoin($this->prefix . 'course c', 'cs.course_id = c.id')
->leftJoin($this->prefix . 'venue v', 'cs.venue_id = v.id')
->leftJoin($this->prefix . 'campus cap', 'cs.campus_id = cap.id')
->leftJoin($this->prefix . 'personnel coach', 'cs.coach_id = coach.id')
->leftJoin($this->prefix . 'class cla', 'cs.class_id = cla.id')
->where('cs.id', $scheduleId)
->where('cs.deleted_at', 0)
->field([
'cs.*',
'c.course_name',
'v.venue_name',
'cap.campus_name',
'coach.name as coach_name',
'cla.class_name'
])
->find();
if (!$scheduleInfo) {
return [
'code' => 0,
'msg' => '课程安排不存在',
'data' => null
];
}
// 获取学员列表
$students = Db::name('person_course_schedule')
->alias('pcs')
->leftJoin($this->prefix . 'customer_resources cr', 'pcs.resources_id = cr.id')
->leftJoin($this->prefix . 'student s', 'pcs.student_id = s.id')
->leftJoin($this->prefix . 'student_courses sc', 'pcs.student_id = sc.student_id AND sc.course_id = pcs.schedule_id')
->where('pcs.schedule_id', $scheduleId)
->where('pcs.deleted_at', 0)
->field([
'pcs.id',
'pcs.person_id',
'pcs.student_id',
'pcs.resources_id',
'pcs.person_type',
'pcs.schedule_type',
'pcs.status',
'cr.name as resource_name',
'cr.phone_number',
'cr.age',
's.name as student_name',
's.contact_phone as student_phone',
'sc.end_date',
'sc.use_total_hours as used_session_count',
'sc.total_hours as total_session_count'
])
->group('pcs.id') // 防止重复数据
->select()
->toArray();
// 分类处理学员数据
$formalStudents = [];
$waitingStudents = [];
foreach ($students as $student) {
$studentData = $this->formatStudentDataForDetail($student);
// 如果schedule_type为空,默认为等待位(2)
$scheduleType = $student['schedule_type'] ?? 2;
if ($scheduleType == 1) {
$formalStudents[] = $studentData;
} else {
$waitingStudents[] = $studentData;
}
}
// 计算空位
$maxFormalSeats = $scheduleInfo['max_students'] ?? 10;
$maxWaitingSeats = 5; // 等待位默认5个
$formalEmptySeats = max(0, $maxFormalSeats - count($formalStudents));
$waitingEmptySeats = max(0, $maxWaitingSeats - count($waitingStudents));
return [
'code' => 1,
'msg' => '获取成功',
'data' => [
'schedule_info' => $scheduleInfo,
'formal_students' => $formalStudents,
'waiting_students' => $waitingStudents,
'formal_empty_seats' => range(1, $formalEmptySeats),
'waiting_empty_seats' => range(1, $waitingEmptySeats)
]
];
} catch (\Exception $e) {
return [
'code' => 0,
'msg' => '获取课程安排详情失败:' . $e->getMessage(),
'data' => null
];
}
}
/**
* 格式化学员数据用于详情显示
* @param array $student 学员数据
* @return array 格式化后的学员数据
*/
private function formatStudentDataForDetail($student)
{
// 获取课程进度
$courseProgress = [
'used' => $student['used_session_count'] ?? 0,
'total' => $student['total_session_count'] ?? 0,
'percentage' => 0
];
if ($courseProgress['total'] > 0) {
$courseProgress['percentage'] = round(($courseProgress['used'] / $courseProgress['total']) * 100, 1);
}
return [
'id' => $student['id'],
'person_id' => $student['person_id'],
'student_id' => $student['student_id'],
'resources_id' => $student['resources_id'],
'name' => $student['resource_name'] ?: $student['student_name'],
'phone' => $student['phone_number'] ?: $student['student_phone'],
'age' => $student['age'] ?? '',
'person_type' => $student['person_type'],
'schedule_type' => $student['schedule_type'] ?? 2, // 默认为等待位
'status' => $student['status'],
'courseStatus' => $this->getStudentStatusText($student['status']),
'course_progress' => $courseProgress,
'renewal_status' => false, // 可以根据实际逻辑判断
'student_course_info' => [
'end_date' => $student['end_date'] ?? ''
]
];
}
/**
* 智能搜索学员
* @param array $data 搜索参数
* @return array 搜索结果
*/
public function searchStudents(array $data)
{
try {
$phoneNumber = $data['phone_number'] ?? '';
$name = $data['name'] ?? '';
if (empty($phoneNumber) && empty($name)) {
return [
'code' => 1,
'msg' => '请输入搜索条件',
'data' => []
];
}
$where = [];
// 构建搜索条件
if (!empty($phoneNumber)) {
$where[] = ['cr.phone_number', 'like', '%' . $phoneNumber . '%'];
}
if (!empty($name)) {
$where[] = ['cr.name', 'like', '%' . $name . '%'];
}
// 搜索客户资源表
$results = Db::name('customer_resources')
->alias('cr')
->leftJoin($this->prefix . 'student s', 'cr.member_id = s.user_id')
->leftJoin($this->prefix . 'student_courses sc', 's.id = sc.student_id')
->where($where)
->where('cr.deleted_at', 0)
->field([
'cr.id as resource_id',
'cr.name',
'cr.phone_number',
'cr.age',
'cr.member_id',
's.id as student_id',
'sc.id as student_course_id',
'sc.status as course_status',
'sc.total_hours as total_session_count',
'sc.use_total_hours as used_session_count'
])
->limit(20)
->select()
->toArray();
// 处理结果数据
$searchResults = [];
foreach ($results as $result) {
$searchResults[] = [
'id' => $result['resource_id'],
'name' => $result['name'],
'phone_number' => $result['phone_number'],
'age' => $result['age'],
'member_id' => $result['member_id'],
'student_id' => $result['student_id'] ?? 0,
'resource_id' => $result['resource_id'],
'is_formal_student' => !empty($result['student_course_id']),
'course_info' => [
'course_status' => $result['course_status'] ?? '',
'total_sessions' => $result['total_session_count'] ?? 0,
'used_sessions' => $result['used_session_count'] ?? 0
]
];
}
return [
'code' => 1,
'msg' => '搜索成功',
'data' => $searchResults
];
} catch (\Exception $e) {
return [
'code' => 0,
'msg' => '搜索失败:' . $e->getMessage(),
'data' => []
];
}
}
/**
* 删除学员/请假处理
* @param array $data 删除参数
* @return array 处理结果
*/
public function removeStudent(array $data)
{
try {
$scheduleId = $data['schedule_id'] ?? 0;
$personId = $data['person_id'] ?? 0;
$personType = $data['person_type'] ?? '';
$reason = $data['reason'] ?? '删除学员';
if (empty($scheduleId) || empty($personId)) {
return [
'code' => 0,
'msg' => '参数不完整'
];
}
// 开启事务
Db::startTrans();
// 查找学员记录
$enrollment = Db::name('person_course_schedule')
->where('schedule_id', $scheduleId)
->where('person_id', $personId)
->where('deleted_at', 0)
->find();
if (!$enrollment) {
Db::rollback();
return [
'code' => 0,
'msg' => '找不到学员记录'
];
}
// 判断是请假还是删除
if (strpos($reason, '请假') !== false) {
// 请假处理:更新状态为请假
$result = Db::name('person_course_schedule')
->where('id', $enrollment['id'])
->update([
'status' => 2, // 请假状态
'updated_at' => date('Y-m-d H:i:s'),
'remark' => $reason
]);
} else {
// 删除处理:软删除记录
$result = Db::name('person_course_schedule')
->where('id', $enrollment['id'])
->update([
'deleted_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'remark' => $reason
]);
}
if ($result === false) {
Db::rollback();
return [
'code' => 0,
'msg' => '删除失败'
];
}
// 提交事务
Db::commit();
return [
'code' => 1,
'msg' => '删除成功'
];
} catch (\Exception $e) {
Db::rollback();
return [
'code' => 0,
'msg' => '删除学员失败:' . $e->getMessage()
];
}
}
/**
* 添加学员到课程安排
* @param array $data 添加学员的数据
* @return array 添加结果
*/
public function addStudentToSchedule(array $data)
{
try {
$scheduleId = $data['schedule_id'] ?? 0;
$personType = $data['person_type'] ?? '';
$scheduleType = $data['schedule_type'] ?? 2; // 默认等待位
if (empty($scheduleId) || empty($personType)) {
return [
'code' => 0,
'msg' => '课程安排ID和人员类型不能为空'
];
}
// 开启事务
Db::startTrans();
// 查询课程安排信息
$schedule = Db::name('course_schedule')
->where('id', $scheduleId)
->where('deleted_at', 0)
->find();
if (!$schedule) {
Db::rollback();
return [
'code' => 0,
'msg' => '课程安排不存在或已被删除'
];
}
// 准备插入数据
$insertData = [
'schedule_id' => $scheduleId,
'person_type' => $personType,
'schedule_type' => $scheduleType,
'course_type' => $scheduleType == 1 ? 1 : 3, // 正式位为正常课程,等待位为等待位课程
'status' => 0, // 待上课
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'deleted_at' => 0
];
// 根据人员类型设置对应的ID
if ($personType === 'student') {
$studentId = $data['student_id'] ?? 0;
if (empty($studentId)) {
Db::rollback();
return [
'code' => 0,
'msg' => '学员ID不能为空'
];
}
$insertData['student_id'] = $studentId;
$insertData['person_id'] = $studentId;
} else {
$resourcesId = $data['resources_id'] ?? 0;
if (empty($resourcesId)) {
Db::rollback();
return [
'code' => 0,
'msg' => '客户资源ID不能为空'
];
}
$insertData['resources_id'] = $resourcesId;
$insertData['person_id'] = $resourcesId;
}
// 检查是否已经添加过
$whereCheck = [
'schedule_id' => $scheduleId,
'deleted_at' => 0
];
if ($personType === 'student') {
$whereCheck['student_id'] = $insertData['student_id'];
} else {
$whereCheck['resources_id'] = $insertData['resources_id'];
}
$exists = Db::name('person_course_schedule')
->where($whereCheck)
->find();
if ($exists) {
Db::rollback();
return [
'code' => 0,
'msg' => '该学员已经在此课程安排中'
];
}
// 检查容量限制
if ($scheduleType == 1) {
// 检查正式位容量
$formalCount = Db::name('person_course_schedule')
->where('schedule_id', $scheduleId)
->where('schedule_type', 1)
->where('deleted_at', 0)
->count();
$maxStudents = intval($schedule['max_students'] ?? 10);
if ($formalCount >= $maxStudents) {
Db::rollback();
return [
'code' => 0,
'msg' => '正式位已满,无法添加'
];
}
} else {
// 检查等待位容量(默认最多5个)
$waitingCount = Db::name('person_course_schedule')
->where('schedule_id', $scheduleId)
->where('schedule_type', 2)
->where('deleted_at', 0)
->count();
if ($waitingCount >= 5) {
Db::rollback();
return [
'code' => 0,
'msg' => '等待位已满,无法添加'
];
}
}
// 插入学员记录
$result = Db::name('person_course_schedule')->insert($insertData);
if (!$result) {
Db::rollback();
return [
'code' => 0,
'msg' => '添加学员失败'
];
}
// 提交事务
Db::commit();
return [
'code' => 1,
'msg' => '添加学员成功'
];
} catch (\Exception $e) {
Db::rollback();
return [
'code' => 0,
'msg' => '添加学员失败:' . $e->getMessage()
];
}
}
/**
* 学员升级(等待位→正式位)
* @param array $data 升级参数
* @return array 升级结果
*/
public function upgradeStudent(array $data)
{
try {
$scheduleId = $data['schedule_id'] ?? 0;
$personId = $data['person_id'] ?? 0;
$fromType = $data['from_type'] ?? 2;
$toType = $data['to_type'] ?? 1;
if (empty($scheduleId) || empty($personId)) {
return [
'code' => 0,
'msg' => '参数不完整'
];
}
// 验证升级方向
if ($fromType != 2 || $toType != 1) {
return [
'code' => 0,
'msg' => '只能从等待位升级到正式位'
];
}
// 开启事务
Db::startTrans();
// 查找等待位学员记录
$enrollment = Db::name('person_course_schedule')
->where('schedule_id', $scheduleId)
->where('person_id', $personId)
->where('schedule_type', 2)
->where('deleted_at', 0)
->find();
if (!$enrollment) {
Db::rollback();
return [
'code' => 0,
'msg' => '找不到等待位学员记录'
];
}
// 检查正式位容量
$schedule = Db::name('course_schedule')
->where('id', $scheduleId)
->find();
$formalCount = Db::name('person_course_schedule')
->where('schedule_id', $scheduleId)
->where('schedule_type', 1)
->where('deleted_at', 0)
->count();
$maxStudents = intval($schedule['max_students'] ?? 10);
if ($formalCount >= $maxStudents) {
Db::rollback();
return [
'code' => 0,
'msg' => '正式位已满,无法升级'
];
}
// 更新学员记录
$result = Db::name('person_course_schedule')
->where('id', $enrollment['id'])
->update([
'schedule_type' => 1,
'course_type' => 1, // 等待位课程改为正式课
'updated_at' => date('Y-m-d H:i:s'),
'remark' => '从等待位升级为正式位'
]);
if ($result === false) {
Db::rollback();
return [
'code' => 0,
'msg' => '升级失败'
];
}
// 提交事务
Db::commit();
return [
'code' => 1,
'msg' => '升级成功'
];
} catch (\Exception $e) {
Db::rollback();
return [
'code' => 0,
'msg' => '升级失败:' . $e->getMessage()
];
}
}
/**
* 升级等待位学员为正式学员
* @param array $data 升级数据
@ -1794,4 +2385,160 @@ class CourseScheduleService extends BaseApiService
];
}
}
/**
* 更新学员签到状态
* @param array $data 更新参数
* @return array 更新结果
*/
public function updateStudentStatus(array $data)
{
try {
$scheduleId = $data['schedule_id'] ?? 0;
$personId = $data['person_id'] ?? 0;
$status = $data['status'] ?? 0;
$reason = $data['reason'] ?? '';
if (empty($scheduleId) || empty($personId)) {
return [
'code' => 0,
'msg' => '参数不完整'
];
}
// 验证状态值
if (!in_array($status, [0, 1, 2])) {
return [
'code' => 0,
'msg' => '无效的状态值'
];
}
// 开启事务
Db::startTrans();
// 查找学员记录
$enrollment = Db::name('person_course_schedule')
->where('schedule_id', $scheduleId)
->where('person_id', $personId)
->where('deleted_at', 0)
->find();
if (!$enrollment) {
Db::rollback();
return [
'code' => 0,
'msg' => '找不到学员记录'
];
}
// 准备更新数据
$updateData = [
'status' => $status,
'updated_at' => date('Y-m-d H:i:s')
];
if ($reason) {
$updateData['remark'] = $reason;
}
// 更新学员记录
$result = Db::name('person_course_schedule')
->where('id', $enrollment['id'])
->update($updateData);
if ($result === false) {
Db::rollback();
return [
'code' => 0,
'msg' => '更新失败'
];
}
// 提交事务
Db::commit();
return [
'code' => 1,
'msg' => '更新成功'
];
} catch (\Exception $e) {
Db::rollback();
return [
'code' => 0,
'msg' => '更新失败:' . $e->getMessage()
];
}
}
/**
* 恢复学员(取消请假)
* @param array $data 恢复参数
* @return array 恢复结果
*/
public function restoreStudent(array $data)
{
try {
$scheduleId = $data['schedule_id'] ?? 0;
$personId = $data['person_id'] ?? 0;
if (empty($scheduleId) || empty($personId)) {
return [
'code' => 0,
'msg' => '参数不完整'
];
}
// 开启事务
Db::startTrans();
// 查找学员记录
$enrollment = Db::name('person_course_schedule')
->where('schedule_id', $scheduleId)
->where('person_id', $personId)
->where('deleted_at', 0)
->find();
if (!$enrollment) {
Db::rollback();
return [
'code' => 0,
'msg' => '找不到学员记录'
];
}
// 恢复学员状态
$result = Db::name('person_course_schedule')
->where('id', $enrollment['id'])
->update([
'status' => 0, // 恢复为待上课状态
'updated_at' => date('Y-m-d H:i:s'),
'remark' => '取消请假,恢复正常状态'
]);
if ($result === false) {
Db::rollback();
return [
'code' => 0,
'msg' => '恢复失败'
];
}
// 提交事务
Db::commit();
return [
'code' => 1,
'msg' => '恢复成功'
];
} catch (\Exception $e) {
Db::rollback();
return [
'code' => 0,
'msg' => '恢复失败:' . $e->getMessage()
];
}
}
}

434
uniapp/api/apiRoute.js

@ -1293,6 +1293,440 @@ export default {
return await http.post('/course/updateStudentStatus', data)
},
//↓↓↓↓↓↓↓↓↓↓↓↓-----统一课程安排管理接口(与admin端保持一致)-----↓↓↓↓↓↓↓↓↓↓↓↓
// 获取课程安排详情(统一接口)
async getScheduleDetail(data = {}) {
try {
const response = await http.get('/course/scheduleDetail', data)
return response
} catch (error) {
console.error('获取课程安排详情失败:', error)
// 降级到 Mock 数据
return await this.getScheduleDetailMock(data)
}
},
// 搜索学员(统一接口)
async searchStudents(data = {}) {
try {
const response = await http.get('/course/searchStudents', data)
return response
} catch (error) {
console.error('搜索学员失败:', error)
// 降级到 Mock 数据
return await this.searchStudentsMock(data)
}
},
// 添加学员到课程安排(统一接口)
async addSchedule(data = {}) {
try {
const response = await http.post('/course/addStudentToSchedule', data)
return response
} catch (error) {
console.error('添加学员失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '添加学员成功(模拟)',
data: { id: Date.now() }
}
}
},
// 移除学员(统一接口)
async removeStudent(data = {}) {
try {
const response = await http.post('/course/removeStudentFromSchedule', data)
return response
} catch (error) {
console.error('移除学员失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '移除学员成功(模拟)',
data: {}
}
}
},
// 升级学员(统一接口)
async upgradeStudent(data = {}) {
try {
const response = await http.post('/course/upgradeStudent', data)
return response
} catch (error) {
console.error('升级学员失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '升级学员成功(模拟)',
data: {}
}
}
},
// 更新学员状态(统一接口)
async updateStudentStatus(data = {}) {
try {
const response = await http.post('/course/updateStudentStatus', data)
return response
} catch (error) {
console.error('更新学员状态失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '更新状态成功(模拟)',
data: {}
}
}
},
// 恢复学员(统一接口)
async restoreStudent(data = {}) {
try {
const response = await http.post('/course/restoreStudent', data)
return response
} catch (error) {
console.error('恢复学员失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '恢复学员成功(模拟)',
data: {}
}
}
},
//↓↓↓↓↓↓↓↓↓↓↓↓-----统一课程安排详情管理接口(与admin端保持一致)-----↓↓↓↓↓↓↓↓↓↓↓↓
// 获取课程安排详情(新统一接口 - 对接admin端)
async getCourseArrangementDetail(data = {}) {
try {
const response = await http.get('/course/scheduleDetail', data)
return response
} catch (error) {
console.error('获取课程安排详情失败:', error)
// 降级到 Mock 数据
return await this.getCourseArrangementDetailMock(data)
}
},
// 搜索学员(新统一接口 - 对接admin端)
async searchStudentsForArrangement(data = {}) {
try {
const response = await http.get('/course/searchStudents', data)
return response
} catch (error) {
console.error('搜索学员失败:', error)
// 降级到 Mock 数据
return await this.searchStudentsForArrangementMock(data)
}
},
// 添加学员到课程安排(新统一接口 - 对接admin端)
async addStudentToArrangement(data = {}) {
try {
const response = await http.post('/course/addStudentToSchedule', data)
return response
} catch (error) {
console.error('添加学员失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '添加学员成功(模拟)',
data: { id: Date.now() }
}
}
},
// 移除学员(新统一接口 - 对接admin端)
async removeStudentFromArrangement(data = {}) {
try {
const response = await http.post('/course/removeStudentFromSchedule', data)
return response
} catch (error) {
console.error('移除学员失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '移除学员成功(模拟)',
data: {}
}
}
},
// 升级学员(新统一接口 - 对接admin端)
async upgradeStudentInArrangement(data = {}) {
try {
const response = await http.post('/course/upgradeStudent', data)
return response
} catch (error) {
console.error('升级学员失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '升级学员成功(模拟)',
data: {}
}
}
},
// 更新学员状态(新统一接口 - 对接admin端)
async updateStudentStatusInArrangement(data = {}) {
try {
const response = await http.post('/course/updateStudentStatus', data)
return response
} catch (error) {
console.error('更新学员状态失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '更新状态成功(模拟)',
data: {}
}
}
},
// 恢复学员(新统一接口 - 对接admin端)
async restoreStudentInArrangement(data = {}) {
try {
const response = await http.post('/course/restoreStudent', data)
return response
} catch (error) {
console.error('恢复学员失败:', error)
// 返回模拟成功响应
return {
code: 1,
msg: '恢复学员成功(模拟)',
data: {}
}
}
},
//↓↓↓↓↓↓↓↓↓↓↓↓-----新统一课程安排Mock数据(与admin端保持一致)-----↓↓↓↓↓↓↓↓↓↓↓↓
// 模拟课程安排详情数据(对应admin端格式)
async getCourseArrangementDetailMock(data = {}) {
await new Promise(resolve => setTimeout(resolve, 500))
const mockStudents = [
{
id: 1,
person_id: 1,
student_id: 1,
name: '张小明',
phone: '13800138001',
age: 8,
person_type: 'student',
person_type_text: '正式学员',
schedule_type: 1,
status: 0,
course_progress: {
used: 5,
total: 20,
percentage: 25
}
},
{
id: 2,
person_id: 2,
resources_id: 2,
name: '李小红',
phone: '13800138002',
age: 7,
person_type: 'customer_resource',
person_type_text: '潜在客户',
schedule_type: 2,
status: 0,
course_progress: {
used: 0,
total: 1,
percentage: 0
}
}
]
return {
code: 1,
data: {
schedule_info: {
id: parseInt(data.schedule_id) || 1,
course_name: '少儿体适能课程',
course_date: '2025-08-16',
time_slot: '09:00-10:00',
venue_name: '训练馆A',
campus_name: '总部校区',
coach_name: '张教练',
status: 'upcoming',
status_text: '即将开始'
},
formal_students: mockStudents.filter(s => s.schedule_type === 1),
waiting_students: mockStudents.filter(s => s.schedule_type === 2),
formal_empty_seats: [2, 3, 4, 5],
waiting_empty_seats: [2, 3]
},
msg: 'SUCCESS'
}
},
// 模拟搜索学员数据(对应admin端格式)
async searchStudentsForArrangementMock(data = {}) {
await new Promise(resolve => setTimeout(resolve, 300))
const mockResults = [
{
id: 101,
person_id: 101,
student_id: 101,
name: '王小华',
phone: '13800138101',
age: 9,
person_type: 'student',
person_type_text: '正式学员'
},
{
id: 102,
person_id: 102,
resources_id: 102,
name: '赵小美',
phone: '13800138102',
age: 6,
person_type: 'customer_resource',
person_type_text: '潜在客户'
}
]
// 根据关键词过滤
let filteredResults = mockResults
if (data.keyword) {
filteredResults = mockResults.filter(student =>
student.name.includes(data.keyword) || student.phone.includes(data.keyword)
)
}
return {
code: 1,
data: {
list: filteredResults,
total: filteredResults.length
},
msg: 'SUCCESS'
}
},
//↓↓↓↓↓↓↓↓↓↓↓↓-----统一课程安排Mock数据-----↓↓↓↓↓↓↓↓↓↓↓↓
// 模拟课程安排详情数据
async getScheduleDetailMock(data = {}) {
await new Promise(resolve => setTimeout(resolve, 500))
const mockStudents = [
{
id: 1,
person_id: 1,
student_id: 1,
name: '张小明',
phone: '13800138001',
age: 8,
person_type: 'student',
person_type_text: '正式学员',
schedule_type: 1,
status: 0,
course_progress: {
used: 5,
total: 20,
percentage: 25
}
},
{
id: 2,
person_id: 2,
resources_id: 2,
name: '李小红',
phone: '13800138002',
age: 7,
person_type: 'customer_resource',
person_type_text: '潜在客户',
schedule_type: 2,
status: 0,
course_progress: {
used: 0,
total: 1,
percentage: 0
}
}
]
return {
code: 1,
data: {
schedule_info: {
id: parseInt(data.schedule_id) || 1,
course_name: '少儿体适能课程',
course_date: '2025-08-16',
time_slot: '09:00-10:00',
venue_name: '训练馆A',
campus_name: '总部校区',
coach_name: '张教练',
status: 'upcoming',
status_text: '即将开始'
},
formal_students: mockStudents.filter(s => s.schedule_type === 1),
waiting_students: mockStudents.filter(s => s.schedule_type === 2),
formal_empty_seats: [2, 3, 4, 5],
waiting_empty_seats: [2, 3]
},
msg: 'SUCCESS'
}
},
// 模拟搜索学员数据
async searchStudentsMock(data = {}) {
await new Promise(resolve => setTimeout(resolve, 300))
const mockResults = [
{
id: 101,
person_id: 101,
student_id: 101,
name: '王小华',
phone: '13800138101',
age: 9,
person_type: 'student',
person_type_text: '正式学员'
},
{
id: 102,
person_id: 102,
resources_id: 102,
name: '赵小美',
phone: '13800138102',
age: 6,
person_type: 'customer_resource',
person_type_text: '潜在客户'
}
]
// 根据关键词过滤
let filteredResults = mockResults
if (data.keyword) {
filteredResults = mockResults.filter(student =>
student.name.includes(data.keyword) || student.phone.includes(data.keyword)
)
}
return {
code: 1,
data: {
list: filteredResults,
total: filteredResults.length
},
msg: 'SUCCESS'
}
},
//↓↓↓↓↓↓↓↓↓↓↓↓-----学员出勤管理相关接口-----↓↓↓↓↓↓↓↓↓↓↓↓
// 学员签到

19
uniapp/pages-coach/coach/schedule/schedule_table.vue

@ -1438,11 +1438,26 @@ export default {
})
},
//
viewScheduleDetail(scheduleId) {
// 使API
async viewScheduleDetail(scheduleId) {
try {
//
this.selectedScheduleId = scheduleId;
this.showScheduleDetail = true;
// 使APIadmin
console.log('调用统一API获取课程安排详情:', scheduleId);
// APIScheduleDetailfetchScheduleDetail
// ScheduleDetailapi.getCourseArrangementDetail()admin
// 使
} catch (error) {
console.error('获取课程安排详情失败:', error);
uni.showToast({
title: '获取课程详情失败',
icon: 'none'
});
}
},
//

Loading…
Cancel
Save