17 changed files with 5335 additions and 75 deletions
@ -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 |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
Loading…
Reference in new issue