Compare commits

...
2 Commits
13 changed files with 3532 additions and 982 deletions
+5 -4
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { AuthController, StudentController, RecordController } from '@/controllers' import { AuthController, StudentController, RecordController, ScheduleController } from '@/controllers'
import { AuthService, StudentService, RecordService } from '@/services' import { AuthService, StudentService, RecordService, ScheduleService } from '@/services'
@Module({ @Module({
controllers: [AuthController, StudentController, RecordController], controllers: [AuthController, StudentController, RecordController, ScheduleController],
providers: [ providers: [
AuthService, AuthService,
RecordService, RecordService,
ScheduleService,
{ {
provide: StudentService, provide: StudentService,
useFactory: (recordService: RecordService) => { useFactory: (recordService: RecordService) => {
+1
View File
@@ -1,3 +1,4 @@
export * from './auth.controller' export * from './auth.controller'
export * from './student.controller' export * from './student.controller'
export * from './record.controller' export * from './record.controller'
export * from './schedule.controller'
+1
View File
@@ -1,3 +1,4 @@
export * from './auth.dto' export * from './auth.dto'
export * from './student.dto' export * from './student.dto'
export * from './record.dto' export * from './record.dto'
export * from './schedule.dto'
+1
View File
@@ -1,3 +1,4 @@
export * from './user.entity' export * from './user.entity'
export * from './student.entity' export * from './student.entity'
export * from './record.entity' export * from './record.entity'
export * from './schedule.entity'
+1
View File
@@ -1,4 +1,5 @@
export * from './auth.service' export * from './auth.service'
export * from './student.service' export * from './student.service'
export * from './record.service' export * from './record.service'
export * from './schedule.service'
export { RecordQueryResult, RecordStatistics } from './record.service' export { RecordQueryResult, RecordStatistics } from './record.service'
+507 -14
View File
@@ -1,32 +1,525 @@
.course-page { .course-page {
min-height: 100vh; min-height: 100vh;
background-color: #f5f5f5;
}
.page-header {
padding: 32px 32px 16px;
background-color: #ffffff; background-color: #ffffff;
padding-bottom: 100rpx;
} }
.page-title { .calendar-section {
font-size: 20px; background-color: #ffffff;
margin-bottom: 24rpx;
border-radius: 16rpx;
overflow: hidden;
margin: 16rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.calendar-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 24rpx;
border-bottom: 1px solid #f3f4f6;
}
.calendar-title-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 12rpx;
}
.calendar-title {
font-size: 32rpx;
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
} }
.page-content { .calendar-toggle {
padding: 16px; font-size: 26rpx;
color: #6b7280;
} }
.coming-soon { .calendar-body {
padding: 24rpx;
}
.month-nav {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
padding: 0 8rpx;
}
.nav-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
border-radius: 12rpx;
}
.month-label {
font-size: 30rpx;
font-weight: 600;
color: #1f2937;
}
.week-header {
display: flex;
flex-direction: row;
margin-bottom: 16rpx;
}
.week-day {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12rpx 0;
}
.week-day-text {
font-size: 24rpx;
color: #9ca3af;
}
.date-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.date-cell {
width: calc(100% / 7);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; padding: 16rpx 0;
padding: 80px 32px; border-radius: 12rpx;
cursor: pointer;
} }
.coming-soon-text { .date-cell.other-month {
font-size: 16px; opacity: 0.3;
}
.date-cell.is-today {
background-color: rgba(59, 130, 246, 0.1);
}
.date-cell.is-selected {
background-color: #3B82F6;
}
.date-text {
font-size: 28rpx;
color: #1f2937;
margin-bottom: 8rpx;
}
.date-cell.is-selected .date-text {
color: #ffffff;
font-weight: 600;
}
.course-dots {
display: flex;
gap: 4rpx;
}
.course-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background-color: #3B82F6;
}
.course-dot-text {
font-size: 18rpx;
color: #3B82F6;
}
.course-list-section {
padding: 0 16rpx;
}
.list-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.list-title-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 10rpx;
}
.list-title {
font-size: 32rpx;
font-weight: 600;
color: #1f2937;
}
.student-count-badge {
display: flex;
flex-direction: row;
align-items: center;
background-color: #3B82F6;
padding: 8rpx 16rpx;
border-radius: 20rpx;
}
.count-number {
font-size: 26rpx;
font-weight: 600;
color: #ffffff;
}
.count-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-left: 4rpx;
}
.course-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #9ca3af;
}
.course-card {
background-color: #ffffff;
border-radius: 16rpx;
padding: 20rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.course-main {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16rpx;
}
.course-info {
flex: 1;
}
.student-name {
font-size: 30rpx;
font-weight: 600;
color: #1f2937;
margin-bottom: 8rpx;
}
.time-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.time-text {
font-size: 26rpx;
color: #6b7280; color: #6b7280;
} }
.duration-badge {
background-color: #f3f4f6;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.duration-text {
font-size: 22rpx;
color: #6b7280;
}
.course-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12rpx;
}
.status-tag {
padding: 6rpx 16rpx;
border-radius: 8rpx;
}
.status-tag.pending {
background-color: rgba(251, 191, 36, 0.15);
}
.status-tag.completed {
background-color: rgba(16, 185, 129, 0.15);
}
.status-text {
font-size: 22rpx;
}
.status-tag.pending .status-text {
color: #d97706;
}
.status-tag.completed .status-text {
color: #059669;
}
.recorded-tag {
display: flex;
flex-direction: row;
align-items: center;
gap: 4rpx;
background-color: rgba(16, 185, 129, 0.15);
padding: 6rpx 12rpx;
border-radius: 8rpx;
}
.recorded-text {
font-size: 22rpx;
color: #059669;
}
.course-progress {
margin-bottom: 16rpx;
}
.progress-label {
font-size: 24rpx;
color: #6b7280;
margin-bottom: 8rpx;
}
.progress-bar-wrapper {
display: flex;
flex-direction: row;
align-items: center;
gap: 12rpx;
}
.progress-bar-bg {
flex: 1;
height: 8rpx;
background-color: #e5e7eb;
border-radius: 4rpx;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background-color: #3B82F6;
border-radius: 4rpx;
transition: width 0.3s ease;
}
.progress-text {
font-size: 24rpx;
color: #6b7280;
white-space: nowrap;
}
.record-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
background-color: #10b981;
padding: 10rpx 20rpx;
border-radius: 8rpx;
}
.record-btn.recorded {
background-color: rgba(16, 185, 129, 0.15);
}
.record-btn-text {
font-size: 24rpx;
font-weight: 500;
color: #ffffff;
}
.record-btn.recorded .record-btn-text {
color: #059669;
}
.record-btn.recorded svg {
filter: none;
}
.record-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.record-dialog {
width: 90%;
max-width: 640rpx;
background-color: #ffffff;
border-radius: 24rpx;
overflow: hidden;
}
.record-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
border-bottom: 1px solid #f3f4f6;
}
.record-title {
font-size: 32rpx;
font-weight: 600;
color: #1f2937;
}
.close-btn {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.record-body {
padding: 28rpx;
}
.record-info-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 16rpx 20rpx;
background-color: #f9fafb;
border-radius: 12rpx;
margin-bottom: 16rpx;
}
.info-label {
font-size: 26rpx;
color: #6b7280;
margin-right: 12rpx;
}
.info-value {
font-size: 26rpx;
color: #1f2937;
font-weight: 500;
}
.record-section {
margin-bottom: 24rpx;
}
.section-title {
font-size: 26rpx;
color: #6b7280;
margin-bottom: 12rpx;
}
.time-picker-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
.time-picker {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 20rpx;
background-color: #f9fafb;
border-radius: 12rpx;
}
.picker-value {
font-size: 28rpx;
color: #1f2937;
}
.time-separator {
font-size: 26rpx;
color: #9ca3af;
}
.record-input {
width: 100%;
height: 88rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1f2937;
background-color: #f9fafb;
border-radius: 12rpx;
box-sizing: border-box;
}
.record-footer {
display: flex;
flex-direction: row;
gap: 16rpx;
padding: 0 28rpx 28rpx;
}
.dialog-btn {
flex: 1;
padding: 24rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-btn.cancel {
background-color: #f3f4f6;
}
.dialog-btn.cancel .btn-text {
color: #6b7280;
}
.dialog-btn.confirm {
background-color: #3B82F6;
}
.btn-text {
font-size: 28rpx;
font-weight: 500;
color: #ffffff;
}
+430 -7
View File
@@ -1,17 +1,440 @@
import { View, Text } from '@tarojs/components' import { View, Text, Input } from '@tarojs/components'
import { useState, useEffect, useCallback } from 'react'
import { useDidShow } from '@tarojs/taro'
import { Calendar, ChevronLeft, ChevronRight, CircleCheck, Clock, X, Pencil, ChevronDown, ChevronUp } from 'lucide-react-taro'
import Taro from '@tarojs/taro'
import './index.css' import './index.css'
type CourseStatus = 'completed' | 'pending' | 'cancelled'
interface Course {
id: number
studentName: string
date: string
startTime: string
endTime: string
duration: number
status: CourseStatus
recorded: boolean
}
interface RecordForm {
studentName: string
date: string
startTime: string
endTime: string
duration: number
fee: number
}
export default function CoursePage() { export default function CoursePage() {
const [calendarExpanded, setCalendarExpanded] = useState(true)
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(new Date())
const [courses, setCourses] = useState<Course[]>([])
const [recordDialogOpen, setRecordDialogOpen] = useState(false)
const [currentCourse, setCurrentCourse] = useState<Course | null>(null)
const [recordForm, setRecordForm] = useState<RecordForm>({
studentName: '',
date: '',
startTime: '',
endTime: '',
duration: 1,
fee: 0
})
const formatDate = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const calculateDuration = (startTime: string, endTime: string): number => {
const start = new Date(`2000-01-01T${startTime}`)
const end = new Date(`2000-01-01T${endTime}`)
const diffHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60)
return parseFloat(diffHours.toFixed(1))
}
const loadCourses = useCallback(() => {
const savedSchedules = Taro.getStorageSync('schedules')
const savedStudents = Taro.getStorageSync('students')
if (savedSchedules && Array.isArray(savedSchedules)) {
const courseList: Course[] = savedSchedules.map(s => {
const duration = calculateDuration(s.startTime, s.endTime)
const student = savedStudents?.find((st: any) => st.id === s.studentId)
const usedHours = student?.usedHours || 0
const totalHours = student?.totalHours || 0
let status: CourseStatus = s.status || 'pending'
let recorded = s.recorded || false
return {
id: s.id,
studentName: s.studentName,
date: s.date,
startTime: s.startTime,
endTime: s.endTime,
duration,
status,
recorded
}
})
setCourses(courseList)
} else {
setCourses([])
}
}, [])
useEffect(() => {
loadCourses()
}, [loadCourses])
useDidShow(() => {
loadCourses()
})
const getMonthData = () => {
const year = currentMonth.getFullYear()
const month = currentMonth.getMonth()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const startWeek = firstDay.getDay() || 7
const days: { date: Date; isCurrentMonth: boolean }[] = []
for (let i = startWeek - 1; i > 0; i--) {
const date = new Date(year, month, 1 - i)
days.push({ date, isCurrentMonth: false })
}
for (let i = 1; i <= lastDay.getDate(); i++) {
const date = new Date(year, month, i)
days.push({ date, isCurrentMonth: true })
}
const remaining = 42 - days.length
for (let i = 1; i <= remaining; i++) {
const date = new Date(year, month + 1, i)
days.push({ date, isCurrentMonth: false })
}
return days
}
const hasCourse = (date: Date): number => {
const dateStr = formatDate(date)
return courses.filter(c => c.date === dateStr).length
}
const isToday = (date: Date): boolean => {
const today = new Date()
return formatDate(date) === formatDate(today)
}
const isCourseTimeValid = (dateStr: string, endTime: string): boolean => {
const now = new Date()
const courseEnd = new Date(`${dateStr}T${endTime}`)
return courseEnd >= now
}
const isSelected = (date: Date): boolean => {
return formatDate(date) === formatDate(selectedDate)
}
const getSelectedDateCourses = (): Course[] => {
const dateStr = formatDate(selectedDate)
return courses.filter(c => c.date === dateStr)
}
const getStudentProgress = (studentName: string): { used: number; total: number } => {
const savedStudents = Taro.getStorageSync('students')
const student = savedStudents?.find((s: any) => s.studentName === studentName)
return {
used: student?.usedHours || 0,
total: student?.totalHours || 20
}
}
const getListTitle = (): string => {
if (isToday(selectedDate)) {
return '今日课程'
}
const year = selectedDate.getFullYear()
const month = selectedDate.getMonth() + 1
const day = selectedDate.getDate()
const weekDay = ['日', '一', '二', '三', '四', '五', '六'][selectedDate.getDay()]
return `${year}年${month}月${day}日 星期${weekDay}`
}
const handleRecordClick = (course: Course) => {
setCurrentCourse(course)
setRecordForm({
studentName: course.studentName,
date: course.date,
startTime: course.startTime,
endTime: course.endTime,
duration: course.duration,
fee: 0
})
setRecordDialogOpen(true)
}
const handleSaveRecord = () => {
const savedStudents = Taro.getStorageSync('students') || []
const updatedStudents = savedStudents.map((student: any) => {
if (student.studentName === recordForm.studentName) {
return {
...student,
usedHours: (student.usedHours || 0) + recordForm.duration
}
}
return student
})
Taro.setStorageSync('students', updatedStudents)
const updatedCourses = courses.map(c => {
if (c.id === currentCourse?.id) {
return { ...c, status: 'completed' as CourseStatus, recorded: true }
}
return c
})
setCourses(updatedCourses)
const savedSchedules = Taro.getStorageSync('schedules') || []
const updatedSchedules = savedSchedules.map((s: any) => {
if (s.id === currentCourse?.id) {
return { ...s, status: 'completed', recorded: true }
}
return s
})
Taro.setStorageSync('schedules', updatedSchedules)
Taro.showToast({ title: '录入成功', icon: 'success' })
setRecordDialogOpen(false)
}
const prevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))
}
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))
}
const monthData = getMonthData()
const selectedCourses = getSelectedDateCourses()
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
return ( return (
<View className="course-page"> <View className="course-page">
<View className="page-header"> <View className="calendar-section">
<Text className="page-title">课程管理</Text> <View className="calendar-header" onClick={() => setCalendarExpanded(!calendarExpanded)}>
<View className="calendar-title-row">
<Calendar size={20} color="#3B82F6" />
<Text className="calendar-title">课程日历</Text>
</View>
{calendarExpanded ? <ChevronUp size={20} color="#6b7280" /> : <ChevronDown size={20} color="#6b7280" />}
</View>
{calendarExpanded && (
<View className="calendar-body">
<View className="month-nav">
<View className="nav-btn" onClick={prevMonth}>
<ChevronLeft size={20} color="#6B7280" />
</View>
<Text className="month-label">
{currentMonth.getFullYear()}年{currentMonth.getMonth() + 1}月
</Text>
<View className="nav-btn" onClick={nextMonth}>
<ChevronRight size={20} color="#6B7280" />
</View>
</View>
<View className="week-header">
{weekDays.map((day, index) => (
<View key={index} className="week-day">
<Text className="week-day-text">{day}</Text>
</View>
))}
</View>
<View className="date-grid">
{monthData.map((item, index) => {
const courseCount = hasCourse(item.date)
return (
<View
key={index}
className={`date-cell ${!item.isCurrentMonth ? 'other-month' : ''} ${isToday(item.date) ? 'is-today' : ''} ${isSelected(item.date) ? 'is-selected' : ''}`}
onClick={() => item.isCurrentMonth && setSelectedDate(item.date)}
>
<Text className="date-text">{item.date.getDate()}</Text>
{item.isCurrentMonth && courseCount > 0 && (
<View className="course-dots">
{courseCount <= 3 ? (
Array.from({ length: Math.min(courseCount, 3) }).map((_, i) => (
<View key={i} className="course-dot" />
))
) : (
<Text className="course-dot-text">●●●+{courseCount - 3}</Text>
)}
</View>
)}
</View>
)
})}
</View>
</View>
)}
</View> </View>
<View className="page-content">
<View className="coming-soon"> <View className="course-list-section">
<Text className="coming-soon-text">功能开发中...</Text> <View className="list-header">
<View className="list-title-row">
<Calendar size={18} color="#3B82F6" />
<Text className="list-title">{getListTitle()}</Text>
</View>
<View className="student-count-badge">
<Text className="count-number">{selectedCourses.length}</Text>
<Text className="count-label">节课</Text>
</View>
</View>
<View className="course-list">
{selectedCourses.length === 0 ? (
<View className="empty-state">
<Text className="empty-text">当日暂无课程安排</Text>
</View>
) : (
selectedCourses.map(course => {
const progress = getStudentProgress(course.studentName)
const progressPercent = progress.total > 0 ? (progress.used / progress.total) * 100 : 0
return (
<View key={course.id} className="course-card">
<View className="course-main">
<View className="course-info">
<Text className="student-name">{course.studentName}</Text>
<View className="time-info">
<Clock size={14} color="#6B7280" />
<Text className="time-text">{course.startTime} - {course.endTime}</Text>
<View className="duration-badge">
<Text className="duration-text">{course.duration}h</Text>
</View>
</View>
</View>
<View className="course-status">
<View className={`status-tag ${course.status === 'completed' ? 'completed' : 'pending'}`}>
<Text className="status-text">{course.status === 'completed' ? '已上课' : '未上课'}</Text>
</View>
{course.recorded ? (
<View className="record-btn recorded">
<CircleCheck size={12} color="#10B981" />
<Text className="record-btn-text">已录入</Text>
</View>
) : (
<>
{!isCourseTimeValid(course.date, course.endTime) && (
<View className="record-btn" onClick={() => handleRecordClick(course)}>
<Pencil size={12} color="#ffffff" />
<Text className="record-btn-text">录入课时</Text>
</View>
)}
</>
)}
</View>
</View>
<View className="course-progress">
<Text className="progress-label">课时进度</Text>
<View className="progress-bar-wrapper">
<View className="progress-bar-bg">
<View className="progress-bar-fill" style={{ width: `${Math.min(progressPercent, 100)}%` }} />
</View>
<Text className="progress-text">{progress.used.toFixed(1)}/{progress.total}h</Text>
</View>
</View>
</View>
)
})
)}
</View> </View>
</View> </View>
{recordDialogOpen && (
<View className="record-overlay" onClick={() => setRecordDialogOpen(false)}>
<View className="record-dialog" onClick={e => e.stopPropagation()}>
<View className="record-header">
<Text className="record-title">录入课时</Text>
<View className="close-btn" onClick={() => setRecordDialogOpen(false)}>
<X size={18} color="#6B7280" />
</View>
</View>
<View className="record-body">
<View className="record-info-row">
<Text className="info-label">学员:</Text>
<Text className="info-value">{recordForm.studentName}</Text>
</View>
<View className="record-info-row">
<Text className="info-label">日期:</Text>
<Text className="info-value">{recordForm.date}</Text>
</View>
<View className="record-section">
<Text className="section-title">上课时间段</Text>
<View className="time-picker-row">
<View className="time-picker">
<Text className="picker-value">{recordForm.startTime}</Text>
</View>
<Text className="time-separator">至</Text>
<View className="time-picker">
<Text className="picker-value">{recordForm.endTime}</Text>
</View>
</View>
</View>
<View className="record-section">
<Text className="section-title">上课时长 (小时)</Text>
<Input
className="record-input"
type="number"
value={recordForm.duration.toString()}
onInput={(e: any) => {
const val = parseFloat(e.detail.value) || 0
setRecordForm(prev => ({ ...prev, duration: val }))
}}
placeholder="请输入时长"
/>
</View>
<View className="record-section">
<Text className="section-title">单次费用 (元)</Text>
<Input
className="record-input"
type="number"
value={recordForm.fee.toString()}
onInput={(e: any) => {
const val = parseFloat(e.detail.value) || 0
setRecordForm(prev => ({ ...prev, fee: val }))
}}
placeholder="请输入费用"
/>
</View>
</View>
<View className="record-footer">
<View className="dialog-btn cancel" onClick={() => setRecordDialogOpen(false)}>
<Text className="btn-text">取消</Text>
</View>
<View className="dialog-btn confirm" onClick={handleSaveRecord}>
<Text className="btn-text">保存</Text>
</View>
</View>
</View>
</View>
)}
</View> </View>
) )
} }
+12 -1
View File
@@ -20,8 +20,19 @@
padding: 20px; padding: 20px;
} }
.form-row {
display: flex;
flex-direction: row;
gap: 12px;
margin-bottom: 16px;
}
.form-item { .form-item {
margin-bottom: 20px; flex: 1;
}
.form-item.half {
width: calc(50% - 6px);
} }
.form-label { .form-label {
+221 -102
View File
@@ -28,26 +28,31 @@ export default function RecordPage() {
const [students, setStudents] = useState<Student[]>([]) const [students, setStudents] = useState<Student[]>([])
const [selectedDate, setSelectedDate] = useState<string>('') const [selectedDate, setSelectedDate] = useState<string>('')
const [selectedStudent, setSelectedStudent] = useState<number>(-1) const [selectedStudent, setSelectedStudent] = useState<number>(-1)
const [startTime, setStartTime] = useState<string>('')
const [endTime, setEndTime] = useState<string>('')
const [duration, setDuration] = useState<string>('') const [duration, setDuration] = useState<string>('')
const [fee, setFee] = useState<string>('') const [fee, setFee] = useState<string>('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// 显示提示信息并自动隐藏
const showMessage = (type: 'success' | 'error', text: string) => {
setMessage({ type, text })
setTimeout(() => {
setMessage(null)
}, 3000)
}
const [openid, setOpenid] = useState<string>('') const [openid, setOpenid] = useState<string>('')
useEffect(() => { useEffect(() => {
// 获取登录状态
const userInfo = Taro.getStorageSync('userInfo')
if (userInfo && userInfo.openid) {
setOpenid(userInfo.openid)
loadStudents(userInfo.openid)
} else {
Taro.redirectTo({ url: '/pages/login/index' })
}
// 设置默认日期为今天
const today = new Date() const today = new Date()
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
setSelectedDate(dateStr) setSelectedDate(dateStr)
const savedStudents = Taro.getStorageSync('students')
if (savedStudents && Array.isArray(savedStudents)) {
setStudents(savedStudents)
}
}, []) }, [])
// 加载学员列表(只加载未毕业的) // 加载学员列表(只加载未毕业的)
@@ -88,9 +93,35 @@ export default function RecordPage() {
setSelectedStudent(index) setSelectedStudent(index)
} }
// 课时时长输入 // 计算时长(小时)
const onDurationChange = (e: any) => { const calculateDuration = (start: string, end: string) => {
setDuration(e.detail.value) if (!start || !end) return ''
const [startHour, startMin] = start.split(':').map(Number)
const [endHour, endMin] = end.split(':').map(Number)
const startTotalMinutes = startHour * 60 + startMin
const endTotalMinutes = endHour * 60 + endMin
let diffMinutes = endTotalMinutes - startTotalMinutes
if (diffMinutes < 0) {
diffMinutes += 24 * 60
}
const hours = diffMinutes / 60
return hours.toFixed(1)
}
// 开始时间变化
const onStartTimeChange = (e: any) => {
const newStartTime = e.detail.value
setStartTime(newStartTime)
const newDuration = calculateDuration(newStartTime, endTime)
setDuration(newDuration)
}
// 结束时间变化
const onEndTimeChange = (e: any) => {
const newEndTime = e.detail.value
setEndTime(newEndTime)
const newDuration = calculateDuration(startTime, newEndTime)
setDuration(newDuration)
} }
// 费用输入 // 费用输入
@@ -102,21 +133,43 @@ export default function RecordPage() {
const handleSubmit = async () => { const handleSubmit = async () => {
// 校验 // 校验
if (!selectedDate) { if (!selectedDate) {
setMessage({ type: 'error', text: '请选择上课日期' }) showMessage('error', '请选择上课日期')
return return
} }
if (selectedStudent < 0 || !students[selectedStudent]) { if (selectedStudent < 0 || !students[selectedStudent]) {
setMessage({ type: 'error', text: '请选择学员' }) showMessage('error', '请选择学员')
return
}
if (!startTime) {
showMessage('error', '请选择开始时间')
return
}
if (!endTime) {
showMessage('error', '请选择结束时间')
return return
} }
const durationNum = parseFloat(duration) const durationNum = parseFloat(duration)
if (Number.isNaN(durationNum) || durationNum <= 0) { if (Number.isNaN(durationNum) || durationNum <= 0) {
setMessage({ type: 'error', text: '课时时长需为正数' }) showMessage('error', '课时时长需为正数')
return return
} }
const feeNum = parseFloat(fee) const feeNum = parseFloat(fee)
if (Number.isNaN(feeNum) || feeNum < 0) { if (Number.isNaN(feeNum) || feeNum < 0) {
setMessage({ type: 'error', text: '单次费用需为非负数' }) showMessage('error', '单次费用需为非负数')
return
}
// 检查是否已存在相同记录
const savedRecords = Taro.getStorageSync('records') || []
const student = students[selectedStudent]
const existsRecord = savedRecords.some(
(r: any) => r.name === student.studentName &&
r.date === selectedDate &&
r.startTime === startTime &&
r.endTime === endTime
)
if (existsRecord) {
showMessage('error', '该时间段已录入课时记录')
return return
} }
@@ -125,34 +178,63 @@ export default function RecordPage() {
try { try {
const student = students[selectedStudent] const student = students[selectedStudent]
const res = await Network.request({ const record = {
url: '/api/records', id: Date.now(),
method: 'POST', date: selectedDate,
data: { name: student.studentName,
date: selectedDate, startTime,
name: student.studentName, endTime,
duration: durationNum, duration: durationNum,
fee: feeNum, fee: feeNum,
openid openid,
} createTime: new Date().toISOString()
})
console.log('[课时录入] 提交结果:', res.data)
if (res.data && res.data.code === 200) {
setMessage({ type: 'success', text: '记录提交成功' })
// 清空课时相关输入
setDuration('')
setFee('')
// 设置日期为今天
const today = new Date()
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
setSelectedDate(dateStr)
} else {
setMessage({ type: 'error', text: res.data?.msg || '提交失败' })
} }
// 保存课时记录
const savedRecords = Taro.getStorageSync('records') || []
savedRecords.push(record)
Taro.setStorageSync('records', savedRecords)
// 更新学员已上课时数
const savedStudents = Taro.getStorageSync('students') || []
const updatedStudents = savedStudents.map((s: any) => {
if (s.studentName === student.studentName) {
return {
...s,
usedHours: (s.usedHours || 0) + durationNum
}
}
return s
})
Taro.setStorageSync('students', updatedStudents)
// 更新课程状态(标记为已完成和已录入)
const savedSchedules = Taro.getStorageSync('schedules') || []
const updatedSchedules = savedSchedules.map((sch: any) => {
if (sch.studentName === student.studentName &&
sch.date === selectedDate &&
sch.startTime === startTime &&
sch.endTime === endTime) {
return {
...sch,
status: 'completed',
recorded: true
}
}
return sch
})
Taro.setStorageSync('schedules', updatedSchedules)
console.log('[课时录入] 提交成功:', record)
showMessage('success', '记录提交成功')
// 2秒后自动返回上一页
setTimeout(() => {
Taro.navigateBack()
}, 2000)
} catch (err) { } catch (err) {
console.error('[课时录入] 提交失败:', err) console.error('[课时录入] 提交失败:', err)
setMessage({ type: 'error', text: '网络错误,请重试' }) showMessage('error', '保存失败,请重试')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -174,77 +256,114 @@ export default function RecordPage() {
return ( return (
<View className="page-container"> <View className="page-container">
<View className="page-header"> <View className="page-header">
<Text className="page-title">课时录入</Text> <Text className="page-title">新增课时记录</Text>
</View> </View>
<View className="form-container"> <View className="form-container">
{/* 上课日期 */} <View className="form-row">
<View className="form-item"> {/* 上课日期 */}
<Text className="form-label">上课日期</Text> <View className="form-item half">
<View className="form-input-wrapper"> <Text className="form-label">上课日期</Text>
<Picker <View className="form-input-wrapper">
mode="date" <Picker
value={selectedDate} mode="date"
onChange={onDateChange} value={selectedDate}
className="date-picker" onChange={onDateChange}
> className="date-picker"
<View className="picker-content"> >
<Text className="picker-text">{selectedDate || '请选择日期'}</Text> <View className="picker-content">
<Text className="picker-weekday">{getWeekday(selectedDate)}</Text> <Text className="picker-text">{selectedDate || '请选择日期'}</Text>
</View> </View>
</Picker> </Picker>
</View>
</View>
{/* 学员选择 */}
<View className="form-item half">
<Text className="form-label">学员姓名</Text>
<View className="form-input-wrapper">
<Picker
mode="selector"
range={studentNames}
disabled={studentNames.length === 0}
onChange={onStudentChange}
className="student-picker"
>
<View className="picker-content">
<Text className={selectedStudent >= 0 && activeStudents[selectedStudent] ? 'picker-text' : 'picker-placeholder'}>
{selectedStudent >= 0 && activeStudents[selectedStudent] ? activeStudents[selectedStudent].studentName : '请选择学员'}
</Text>
</View>
</Picker>
</View>
</View> </View>
</View> </View>
{/* 学员选择 */} <View className="form-row">
<View className="form-item"> {/* 开始时间 */}
<Text className="form-label">学员</Text> <View className="form-item half">
<View className="form-input-wrapper"> <Text className="form-label">开始时间</Text>
<Picker <View className="form-input-wrapper">
mode="selector" <Picker
range={studentNames} mode="time"
disabled={studentNames.length === 0} value={startTime}
onChange={onStudentChange} onChange={onStartTimeChange}
className="student-picker" className="time-picker"
> >
<View className="picker-content">
<Text className={startTime ? 'picker-text' : 'picker-placeholder'}>
{startTime || '--:--'}
</Text>
</View>
</Picker>
</View>
</View>
{/* 结束时间 */}
<View className="form-item half">
<Text className="form-label">结束时间</Text>
<View className="form-input-wrapper">
<Picker
mode="time"
value={endTime}
onChange={onEndTimeChange}
className="time-picker"
>
<View className="picker-content">
<Text className={endTime ? 'picker-text' : 'picker-placeholder'}>
{endTime || '--:--'}
</Text>
</View>
</Picker>
</View>
</View>
</View>
<View className="form-row">
{/* 上课时长 */}
<View className="form-item half">
<Text className="form-label">上课时长(小时)</Text>
<View className="form-input-wrapper">
<View className="picker-content"> <View className="picker-content">
<Text className={selectedStudent >= 0 && activeStudents[selectedStudent] ? 'picker-text' : 'picker-placeholder'}> <Text className={duration ? 'picker-text' : 'picker-placeholder'}>
{selectedStudent >= 0 && activeStudents[selectedStudent] ? activeStudents[selectedStudent].studentName : '请选择学员'} {duration || '--'}
</Text> </Text>
</View> </View>
</Picker> </View>
{studentNames.length === 0 && (
<Text className="form-hint error">暂无在读学员</Text>
)}
</View> </View>
</View>
{/* 课时时长 */} {/* 课时费 */}
<View className="form-item"> <View className="form-item half">
<Text className="form-label">课时时长(小时)</Text> <Text className="form-label">课时费(元)</Text>
<View className="form-input-wrapper"> <View className="form-input-wrapper">
<Input <Input
className="simple-input" className="simple-input"
type="digit" type="digit"
placeholder="请输入课时时长" placeholder="如 200"
value={duration} value={fee}
onInput={onDurationChange} onInput={onFeeChange}
onBlur={() => {}} />
/> </View>
</View>
</View>
{/* 单次费用 */}
<View className="form-item">
<Text className="form-label">单次费用(元)</Text>
<View className="form-input-wrapper">
<Input
className="simple-input"
type="digit"
placeholder="请输入单次费用"
value={fee}
onInput={onFeeChange}
/>
</View> </View>
</View> </View>
+573 -11
View File
@@ -1,32 +1,594 @@
/* 排课页面样式 */
.schedule-page { .schedule-page {
min-height: 100vh; min-height: 100vh;
background-color: #f5f5f5; background: #f5f7fa;
padding-bottom: 40px;
} }
/* 页面头部 */
.page-header { .page-header {
padding: 32px 32px 16px; display: flex;
background-color: #ffffff; align-items: center;
justify-content: space-between;
padding: 20px 16px;
background: linear-gradient(135deg, #3B82F6 0%, #6366F1 100%);
} }
.page-title { .page-title {
font-size: 20px; font-size: 18px;
font-weight: 600;
color: #ffffff;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.add-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
/* 日历区域 */
.calendar-section {
background: #ffffff;
margin: 12px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: #ffffff;
}
.calendar-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
} }
.page-content { .expand-icon {
padding: 16px; font-size: 12px;
color: #9ca3af;
} }
.coming-soon { .calendar-content {
padding: 0 12px 16px;
}
/* 星期标题 */
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 8px;
}
.weekday-cell {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0;
}
.weekday-text {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
}
/* 日期网格 */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.day-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 80px 32px; padding: 8px 4px;
min-height: 48px;
border-radius: 12px;
position: relative;
} }
.coming-soon-text { .day-cell.empty {
font-size: 16px; background: transparent;
color: #6b7280; }
.day-cell.selected {
background: #3B82F6;
}
.day-cell.today {
border: 2px solid #3B82F6;
box-sizing: border-box;
}
.day-text {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
.day-cell.selected .day-text {
color: #ffffff;
}
.schedule-dots {
display: flex;
align-items: center;
gap: 2px;
margin-top: 4px;
height: 12px;
}
.dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: #3B82F6;
}
.day-cell.selected .dot {
background: #ffffff;
}
.dots-text {
font-size: 8px;
color: #3B82F6;
font-weight: 600;
}
.day-cell.selected .dots-text {
color: #ffffff;
}
/* 月份导航 */
.month-nav {
display: flex;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.nav-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
font-size: 14px;
color: #3B82F6;
}
/* 排课列表区域 */
.schedule-list-section {
background: #ffffff;
margin: 12px;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.schedule-count {
font-size: 13px;
color: #64748b;
background: #f3f4f6;
padding: 4px 10px;
border-radius: 12px;
}
/* 排课卡片 */
.schedule-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.schedule-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px;
background: #f9fafb;
border-radius: 12px;
border-left: 4px solid #3B82F6;
}
.card-left {
display: flex;
align-items: center;
gap: 12px;
}
.card-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #3B82F6;
}
.card-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.student-name {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.time-range {
display: flex;
align-items: center;
gap: 6px;
}
.time-text {
font-size: 13px;
color: #64748b;
}
.card-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}
.action-btn.edit {
border: 1px solid #e5e7eb;
}
.action-btn.delete {
border: 1px solid #fee2e2;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px;
}
.empty-text {
font-size: 14px;
color: #9ca3af;
margin-bottom: 16px;
}
.add-first-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
background: #eff6ff;
border-radius: 20px;
font-size: 14px;
color: #3B82F6;
}
/* Dialog 样式 */
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f3f4f6;
}
.dialog-title {
font-size: 17px;
font-weight: 600;
color: #1f2937;
}
.close-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-form {
padding: 20px;
}
.form-item {
margin-bottom: 24px;
}
.form-item-label {
font-size: 14px;
font-weight: 500;
color: #374151;
margin-bottom: 10px;
display: block;
}
.form-input-wrapper {
width: 100%;
background: #ffffff;
border: 2px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
.form-input-wrapper:focus-within {
border-color: #3B82F6;
}
.date-display {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
}
.date-text {
font-size: 15px;
color: #1f2937;
}
.picker-trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
min-height: 50px;
}
.picker-value {
font-size: 15px;
color: #1f2937;
}
.picker-placeholder {
font-size: 15px;
color: #9ca3af;
}
.picker-arrow {
font-size: 12px;
color: #9ca3af;
}
.dialog-footer {
display: flex;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #f3f4f6;
}
.cancel-btn {
flex: 1;
padding: 14px;
background: #f3f4f6;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
color: #4b5563;
}
.confirm-btn {
flex: 1;
padding: 14px;
background: #3B82F6;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
color: #ffffff;
font-weight: 500;
}
.delete-confirm-btn {
flex: 1;
padding: 14px;
background: #EF4444;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
color: #ffffff;
font-weight: 500;
}
/* 删除确认内容 */
.delete-content {
padding: 24px 20px;
text-align: center;
}
.delete-message {
font-size: 15px;
color: #1f2937;
margin-bottom: 8px;
}
.delete-detail {
font-size: 13px;
color: #64748b;
}
/* 时间冲突弹窗 */
.conflict-dialog {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}
.conflict-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px;
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
}
.conflict-icon {
font-size: 32px;
}
.conflict-title {
font-size: 18px;
font-weight: 600;
color: #ffffff;
}
.conflict-content {
padding: 20px;
background: #ffffff;
}
.new-schedule-box {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.new-schedule-label {
font-size: 13px;
color: #64748b;
margin-bottom: 6px;
}
.new-schedule-value {
font-size: 15px;
color: #1f2937;
font-weight: 500;
}
.conflict-divider {
height: 1px;
background: #e2e8f0;
margin: 16px 0;
}
.existing-label {
font-size: 14px;
color: #374151;
font-weight: 500;
margin-bottom: 12px;
}
.existing-schedules {
display: flex;
flex-direction: column;
gap: 12px;
}
.existing-schedule-item {
display: flex;
align-items: center;
gap: 12px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
}
.existing-icon {
font-size: 24px;
}
.existing-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.existing-student {
font-size: 15px;
color: #1f2937;
font-weight: 500;
}
.existing-time {
font-size: 13px;
color: #64748b;
}
.conflict-tip {
margin-top: 16px;
padding: 12px 16px;
background: #ffffff;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.tip-text {
font-size: 13px;
color: #64748b;
text-align: center;
}
.conflict-footer {
padding: 16px 20px;
padding-top: 0;
background: #ffffff;
}
.conflict-confirm-btn {
width: 100%;
padding: 16px;
background: #3B82F6;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #ffffff;
font-weight: 500;
} }
+671 -5
View File
@@ -1,17 +1,683 @@
import { View, Text } from '@tarojs/components' import { View, Text, Picker } from '@tarojs/components'
import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro'
import { Plus, ChevronLeft, ChevronRight, Clock, Trash2, Pencil, X, Calendar as CalendarIcon } from 'lucide-react-taro'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Network } from '@/network'
import './index.css' import './index.css'
// 排课记录类型
interface ScheduleRecord {
id: number
studentId: number
studentName: string
date: string
startTime: string
endTime: string
status?: 'completed' | 'pending' | 'cancelled'
recorded?: boolean
}
// 学员类型
interface Student {
id: number
studentName: string
status: number
}
// 周几名称
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']
export default function SchedulePage() { export default function SchedulePage() {
const [openid, setOpenid] = useState<string>('')
const [selectedDate, setSelectedDate] = useState<string>('')
const [currentMonth, setCurrentMonth] = useState<Date>(new Date())
const [calendarExpanded, setCalendarExpanded] = useState(true)
const [schedules, setSchedules] = useState<ScheduleRecord[]>([])
const [students, setStudents] = useState<Student[]>([])
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [currentRecord, setCurrentRecord] = useState<ScheduleRecord | null>(null)
const [conflictDialogOpen, setConflictDialogOpen] = useState(false)
const [conflictInfo, setConflictInfo] = useState<{
newSchedule: ScheduleRecord | null
existingSchedules: ScheduleRecord[]
}>({
newSchedule: null,
existingSchedules: []
})
const [addForm, setAddForm] = useState({
date: '',
studentId: '',
studentName: '',
startTime: '09:00',
endTime: '10:00'
})
const [editForm, setEditForm] = useState({
id: 0,
studentId: '',
studentName: '',
date: '',
startTime: '09:00',
endTime: '10:00'
})
// 初始化
useEffect(() => {
const userInfo = Taro.getStorageSync('userInfo')
const today = formatDate(new Date())
setSelectedDate(today)
setAddForm(prev => ({ ...prev, date: today }))
loadStudents()
loadSchedules()
}, [])
// 加载排课数据
const loadSchedules = () => {
const savedSchedules = Taro.getStorageSync('schedules')
if (savedSchedules && Array.isArray(savedSchedules)) {
setSchedules(savedSchedules)
}
}
// 加载学员数据
const loadStudents = () => {
const savedStudents = Taro.getStorageSync('students')
if (savedStudents && Array.isArray(savedStudents)) {
setStudents(savedStudents.filter((s: Student) => s.status === 0))
}
}
// 格式化日期为 YYYY-MM-DD
const formatDate = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 获取月份日历数据
const getMonthDays = () => {
const year = currentMonth.getFullYear()
const month = currentMonth.getMonth()
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const days: (number | null)[] = []
// 填充空白
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
// 填充日期
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return days
}
// 获取某天的排课数量
const getScheduleCount = (date: string): number => {
return schedules.filter(s => s.date === date).length
}
// 获取某天的排课列表
const getDaySchedules = (date: string): ScheduleRecord[] => {
return schedules.filter(s => s.date === date).sort((a, b) => a.startTime.localeCompare(b.startTime))
}
// 切换月份
const prevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))
}
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))
}
// 选择日期
const selectDate = (day: number) => {
const date = formatDate(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day))
setSelectedDate(date)
}
// 打开新增弹窗
const openAddDialog = () => {
const savedStudents = Taro.getStorageSync('students')
if (savedStudents && Array.isArray(savedStudents)) {
setStudents(savedStudents)
}
setAddForm({
date: selectedDate,
studentId: '',
studentName: '',
startTime: '09:00',
endTime: '10:00'
})
setAddDialogOpen(true)
}
// 检查时间冲突
const checkTimeConflict = (schedule: ScheduleRecord, excludeId?: number): ScheduleRecord[] => {
return schedules.filter(s => {
if (excludeId !== undefined && s.id === excludeId) return false
if (s.date !== schedule.date) return false
if (s.studentId !== schedule.studentId) return false
const newStart = schedule.startTime
const newEnd = schedule.endTime
const existingStart = s.startTime
const existingEnd = s.endTime
const hasConflict = newStart < existingEnd && newEnd > existingStart
return hasConflict
})
}
// 提交新增
const submitAdd = () => {
if (!addForm.studentId) {
Taro.showToast({ title: '请选择学员', icon: 'none' })
return
}
const newSchedule: ScheduleRecord = {
id: Date.now(),
studentId: parseInt(addForm.studentId),
studentName: addForm.studentName,
date: addForm.date,
startTime: addForm.startTime,
endTime: addForm.endTime,
status: 'pending',
recorded: false
}
const conflicts = checkTimeConflict(newSchedule)
if (conflicts.length > 0) {
setConflictInfo({
newSchedule,
existingSchedules: conflicts
})
setConflictDialogOpen(true)
return
}
const updatedSchedules = [...schedules, newSchedule]
setSchedules(updatedSchedules)
Taro.setStorageSync('schedules', updatedSchedules)
Taro.showToast({ title: '添加成功', icon: 'success' })
setAddDialogOpen(false)
}
// 打开编辑弹窗
const openEditDialog = (record: ScheduleRecord) => {
setEditForm({
id: record.id,
studentId: record.studentId.toString(),
studentName: record.studentName,
date: record.date,
startTime: record.startTime,
endTime: record.endTime
})
setCurrentRecord(record)
setEditDialogOpen(true)
}
// 提交编辑
const submitEdit = () => {
if (!editForm.studentId) {
Taro.showToast({ title: '请选择学员', icon: 'none' })
return
}
const existingRecord = schedules.find(s => s.id === editForm.id)
const updatedSchedule: ScheduleRecord = {
id: editForm.id,
studentId: parseInt(editForm.studentId),
studentName: editForm.studentName,
date: editForm.date,
startTime: editForm.startTime,
endTime: editForm.endTime,
status: existingRecord?.status || 'pending',
recorded: existingRecord?.recorded || false
}
const conflicts = checkTimeConflict(updatedSchedule, editForm.id)
if (conflicts.length > 0) {
setConflictInfo({
newSchedule: updatedSchedule,
existingSchedules: conflicts
})
setConflictDialogOpen(true)
return
}
const updatedSchedules = schedules.map(s =>
s.id === editForm.id
? updatedSchedule
: s
)
setSchedules(updatedSchedules)
Taro.setStorageSync('schedules', updatedSchedules)
Taro.showToast({ title: '修改成功', icon: 'success' })
setEditDialogOpen(false)
}
// 打开删除确认
const openDeleteDialog = (record: ScheduleRecord) => {
setCurrentRecord(record)
setDeleteDialogOpen(true)
}
// 确认删除
const confirmDelete = () => {
if (!currentRecord) return
const updatedSchedules = schedules.filter(s => s.id !== currentRecord.id)
setSchedules(updatedSchedules)
Taro.setStorageSync('schedules', updatedSchedules)
Taro.showToast({ title: '删除成功', icon: 'success' })
setDeleteDialogOpen(false)
}
const monthLabel = `${currentMonth.getFullYear()}年${currentMonth.getMonth() + 1}月`
const selectedSchedules = getDaySchedules(selectedDate)
const todayStr = formatDate(new Date())
return ( return (
<View className="schedule-page"> <View className="schedule-page">
{/* 页面标题 */}
<View className="page-header"> <View className="page-header">
<Text className="page-title">排课管理</Text> <Text className="page-title">排课管理</Text>
</View> <View className="header-actions">
<View className="page-content"> <View className="add-btn" onClick={openAddDialog}>
<View className="coming-soon"> <Plus size={20} color="#ffffff" />
<Text className="coming-soon-text">功能开发中...</Text> </View>
</View> </View>
</View> </View>
{/* 日历区域 */}
<View className="calendar-section">
{/* 日历头部 */}
<View className="calendar-header" onClick={() => setCalendarExpanded(!calendarExpanded)}>
<View className="calendar-title">
<CalendarIcon size={18} color="#3B82F6" />
<Text className="block">{monthLabel}</Text>
</View>
<Text className="block expand-icon">{calendarExpanded ? '▼' : '▶'}</Text>
</View>
{/* 日历内容 */}
{calendarExpanded && (
<View className="calendar-content">
{/* 星期标题 */}
<View className="calendar-weekdays">
{WEEKDAYS.map((day, idx) => (
<View key={idx} className="weekday-cell">
<Text className="block weekday-text">{day}</Text>
</View>
))}
</View>
{/* 日期网格 */}
<View className="calendar-grid">
{getMonthDays().map((day, idx) => {
if (day === null) {
return <View key={idx} className="day-cell empty" />
}
const dateStr = formatDate(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day))
const isSelected = dateStr === selectedDate
const isToday = dateStr === todayStr
const scheduleCount = getScheduleCount(dateStr)
return (
<View
key={idx}
className={`day-cell ${isSelected ? 'selected' : ''} ${isToday ? 'today' : ''}`}
onClick={() => selectDate(day)}
>
<Text className="block day-text">{day}</Text>
{scheduleCount > 0 && (
<View className="schedule-dots">
{scheduleCount <= 3 ? (
Array.from({ length: scheduleCount }).map((_, i) => (
<View key={i} className="dot" />
))
) : (
<Text className="block dots-text">●●●+{scheduleCount}</Text>
)}
</View>
)}
</View>
)
})}
</View>
{/* 月份导航 */}
<View className="month-nav">
<View className="nav-btn" onClick={prevMonth}>
<ChevronLeft size={20} color="#3B82F6" />
<Text className="block">上月</Text>
</View>
<View className="nav-btn" onClick={nextMonth}>
<Text className="block">下月</Text>
<ChevronRight size={20} color="#3B82F6" />
</View>
</View>
</View>
)}
</View>
{/* 选中日期的排课列表 */}
<View className="schedule-list-section">
<View className="section-header">
<Text className="block section-title">{selectedDate} 排课</Text>
<Text className="block schedule-count">{selectedSchedules.length} 节</Text>
</View>
{selectedSchedules.length > 0 ? (
<View className="schedule-list">
{selectedSchedules.map((record) => (
<View key={record.id} className="schedule-card">
<View className="card-left">
<View className="card-indicator" />
<View className="card-info">
<Text className="block student-name">{record.studentName}</Text>
<View className="time-range">
<Clock size={14} color="#64748b" />
<Text className="block time-text">{record.startTime} - {record.endTime}</Text>
</View>
</View>
</View>
<View className="card-actions">
<View className="action-btn edit" onClick={() => openEditDialog(record)}>
<Pencil size={16} color="#3B82F6" />
</View>
<View className="action-btn delete" onClick={() => openDeleteDialog(record)}>
<Trash2 size={16} color="#EF4444" />
</View>
</View>
</View>
))}
</View>
) : (
<View className="empty-state">
<Text className="block empty-text">暂无排课</Text>
<View className="add-first-btn" onClick={openAddDialog}>
<Plus size={16} color="#3B82F6" />
<Text className="block">添加第一课</Text>
</View>
</View>
)}
</View>
{/* 新增排课弹窗 */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen} closeOnOverlayClick={false}>
<DialogContent>
<View className="dialog-header">
<Text className="block dialog-title">新增排课</Text>
</View>
<View className="dialog-form">
{/* 上课日期 */}
<View className="form-item">
<Text className="form-item-label">上课日期</Text>
<Picker
mode="date"
value={addForm.date}
onChange={(e: any) => {
setAddForm(prev => ({ ...prev, date: e.detail.value }))
}}
>
<View className="form-input-wrapper picker-trigger">
<View className="date-display">
<CalendarIcon size={16} color="#64748b" />
<Text className="block date-text">{addForm.date}</Text>
</View>
<Text className="block picker-arrow">▼</Text>
</View>
</Picker>
</View>
{/* 选择学员 */}
<View className="form-item">
<Text className="form-item-label">选择学员 *</Text>
<Picker
mode="selector"
range={students.filter(s => s.status === 0)}
rangeKey="studentName"
onChange={(e: any) => {
const idx = e.detail.value
const activeStudents = students.filter(s => s.status === 0)
if (activeStudents[idx]) {
setAddForm(prev => ({
...prev,
studentId: activeStudents[idx].id.toString(),
studentName: activeStudents[idx].studentName
}))
}
}}
>
<View className="form-input-wrapper picker-trigger">
<Text className={addForm.studentName ? 'picker-value' : 'picker-placeholder'}>
{addForm.studentName || '请选择学员'}
</Text>
<Text className="block picker-arrow">▼</Text>
</View>
</Picker>
</View>
{/* 开始时间 */}
<View className="form-item">
<Text className="form-item-label">开始时间</Text>
<Picker
mode="time"
value={addForm.startTime}
onChange={(e: any) => {
setAddForm(prev => ({ ...prev, startTime: e.detail.value }))
}}
>
<View className="form-input-wrapper picker-trigger">
<Text className="picker-value">{addForm.startTime}</Text>
<Text className="block picker-arrow">▼</Text>
</View>
</Picker>
</View>
{/* 结束时间 */}
<View className="form-item">
<Text className="form-item-label">结束时间</Text>
<Picker
mode="time"
value={addForm.endTime}
onChange={(e: any) => {
setAddForm(prev => ({ ...prev, endTime: e.detail.value }))
}}
>
<View className="form-input-wrapper picker-trigger">
<Text className="picker-value">{addForm.endTime}</Text>
<Text className="block picker-arrow">▼</Text>
</View>
</Picker>
</View>
</View>
<View className="dialog-footer">
<View className="cancel-btn" onClick={() => setAddDialogOpen(false)}>
<Text className="block">取消</Text>
</View>
<View className="confirm-btn" onClick={submitAdd}>
<Text className="block">确定</Text>
</View>
</View>
</DialogContent>
</Dialog>
{/* 编辑排课弹窗 */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen} closeOnOverlayClick={false}>
<DialogContent>
<View className="dialog-header">
<Text className="block dialog-title">编辑排课</Text>
</View>
<View className="dialog-form">
{/* 上课日期 */}
<View className="form-item">
<Text className="form-item-label">上课日期</Text>
<View className="form-input-wrapper">
<View className="date-display">
<CalendarIcon size={16} color="#64748b" />
<Text className="block date-text">{editForm.date}</Text>
</View>
</View>
</View>
{/* 选择学员 */}
<View className="form-item">
<Text className="form-item-label">选择学员 *</Text>
<Picker
mode="selector"
range={students.filter(s => s.status === 0)}
rangeKey="studentName"
onChange={(e: any) => {
const idx = e.detail.value
const activeStudents = students.filter(s => s.status === 0)
if (activeStudents[idx]) {
setEditForm(prev => ({
...prev,
studentId: activeStudents[idx].id.toString(),
studentName: activeStudents[idx].studentName
}))
}
}}
>
<View className="form-input-wrapper picker-trigger">
<Text className={editForm.studentName ? 'picker-value' : 'picker-placeholder'}>
{editForm.studentName || '请选择学员'}
</Text>
<Text className="block picker-arrow">▼</Text>
</View>
</Picker>
</View>
{/* 开始时间 */}
<View className="form-item">
<Text className="form-item-label">开始时间</Text>
<Picker
mode="time"
value={editForm.startTime}
onChange={(e: any) => {
setEditForm(prev => ({ ...prev, startTime: e.detail.value }))
}}
>
<View className="form-input-wrapper picker-trigger">
<Text className="picker-value">{editForm.startTime}</Text>
<Text className="block picker-arrow">▼</Text>
</View>
</Picker>
</View>
{/* 结束时间 */}
<View className="form-item">
<Text className="form-item-label">结束时间</Text>
<Picker
mode="time"
value={editForm.endTime}
onChange={(e: any) => {
setEditForm(prev => ({ ...prev, endTime: e.detail.value }))
}}
>
<View className="form-input-wrapper picker-trigger">
<Text className="picker-value">{editForm.endTime}</Text>
<Text className="block picker-arrow">▼</Text>
</View>
</Picker>
</View>
</View>
<View className="dialog-footer">
<View className="cancel-btn" onClick={() => setEditDialogOpen(false)}>
<Text className="block">取消</Text>
</View>
<View className="confirm-btn" onClick={submitEdit}>
<Text className="block">确定</Text>
</View>
</View>
</DialogContent>
</Dialog>
{/* 删除确认弹窗 */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen} closeOnOverlayClick={false}>
<DialogContent>
<View className="dialog-header">
<Text className="block dialog-title">确认删除</Text>
</View>
<View className="delete-content">
<Text className="block delete-message">
确定删除 {currentRecord?.studentName} 的排课吗?
</Text>
<Text className="block delete-detail">
{currentRecord?.date} {currentRecord?.startTime} - {currentRecord?.endTime}
</Text>
</View>
<View className="dialog-footer">
<View className="cancel-btn" onClick={() => setDeleteDialogOpen(false)}>
<Text className="block">取消</Text>
</View>
<View className="delete-confirm-btn" onClick={confirmDelete}>
<Text className="block">删除</Text>
</View>
</View>
</DialogContent>
</Dialog>
{/* 时间冲突提示弹窗 */}
<Dialog open={conflictDialogOpen} onOpenChange={setConflictDialogOpen} closeOnOverlayClick={false}>
<DialogContent className="conflict-dialog">
<View className="conflict-header">
<View className="conflict-icon">⚠</View>
<Text className="block conflict-title">排课时间冲突</Text>
</View>
<View className="conflict-content">
<View className="new-schedule-box">
<Text className="block new-schedule-label">新排课信息:</Text>
<Text className="block new-schedule-value">
{conflictInfo.newSchedule?.studentName} · {conflictInfo.newSchedule?.startTime} - {conflictInfo.newSchedule?.endTime}
</Text>
</View>
<View className="conflict-divider" />
<Text className="block existing-label">该学员已有排课:</Text>
<View className="existing-schedules">
{conflictInfo.existingSchedules.map((schedule, idx) => (
<View key={idx} className="existing-schedule-item">
<View className="existing-icon">🕐</View>
<View className="existing-info">
<Text className="block existing-student">{schedule.studentName}</Text>
<Text className="block existing-time">{schedule.startTime} - {schedule.endTime}</Text>
</View>
</View>
))}
</View>
<View className="conflict-tip">
<Text className="block tip-text">请调整时间后重新提交</Text>
</View>
</View>
<View className="conflict-footer">
<View className="conflict-confirm-btn" onClick={() => setConflictDialogOpen(false)}>
<Text className="block">我知道了</Text>
</View>
</View>
</DialogContent>
</Dialog>
</View> </View>
) )
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff