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