From cd999bed76a5665fc82968491994482abc8fc517 Mon Sep 17 00:00:00 2001 From: taocong Date: Thu, 14 May 2026 16:21:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E8=AF=BE=E6=97=B6?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=A1=B5=E9=9D=A2=E7=9A=84=E4=B8=A4=E5=88=97?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E6=94=B9=E9=80=A0=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BC=80=E5=A7=8B/=E7=BB=93=E6=9D=9F=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=99=A8=E5=B9=B6=E8=87=AA=E5=8A=A8=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E6=97=B6=E9=95=BF=EF=BC=8C=E5=AE=9E=E7=8E=B0=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=BD=95=E5=85=A5=E6=A3=80=E6=B5=8B=E3=80=81=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=87=AA=E5=8A=A8=E9=9A=90=E8=97=8F=E3=80=81=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=88=90=E5=8A=9F=E8=87=AA=E5=8A=A8=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=EF=BC=8C=E4=BB=A5=E5=8F=8A=E5=A4=9A=E9=A1=B5=E9=9D=A2=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=81=94=E5=8A=A8=E6=9B=B4=E6=96=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/app.module.ts | 9 +- server/src/controllers/index.ts | 1 + server/src/dto/index.ts | 1 + server/src/entities/index.ts | 1 + server/src/services/index.ts | 1 + src/pages/course/index.css | 574 +++++++++++++++++++++----------- src/pages/course/index.tsx | 330 +++++++++++++----- src/pages/record/index.css | 13 +- src/pages/record/index.tsx | 308 ++++++++++++----- src/pages/schedule/index.tsx | 67 ++-- src/pages/student/index.css | 55 +++ src/pages/student/index.tsx | 31 +- 12 files changed, 962 insertions(+), 429 deletions(-) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 1513bff..7b85264 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common' -import { AuthController, StudentController, RecordController } from '@/controllers' -import { AuthService, StudentService, RecordService } from '@/services' +import { AuthController, StudentController, RecordController, ScheduleController } from '@/controllers' +import { AuthService, StudentService, RecordService, ScheduleService } from '@/services' @Module({ - controllers: [AuthController, StudentController, RecordController], + controllers: [AuthController, StudentController, RecordController, ScheduleController], providers: [ AuthService, - RecordService, + RecordService, + ScheduleService, { provide: StudentService, useFactory: (recordService: RecordService) => { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index bfacc0c..a864286 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -1,3 +1,4 @@ export * from './auth.controller' export * from './student.controller' export * from './record.controller' +export * from './schedule.controller' diff --git a/server/src/dto/index.ts b/server/src/dto/index.ts index 5a99af1..90ae085 100644 --- a/server/src/dto/index.ts +++ b/server/src/dto/index.ts @@ -1,3 +1,4 @@ export * from './auth.dto' export * from './student.dto' export * from './record.dto' +export * from './schedule.dto' diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index eeaf728..b07629c 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -1,3 +1,4 @@ export * from './user.entity' export * from './student.entity' export * from './record.entity' +export * from './schedule.entity' diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 79b0842..6a7476f 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,4 +1,5 @@ export * from './auth.service' export * from './student.service' export * from './record.service' +export * from './schedule.service' export { RecordQueryResult, RecordStatistics } from './record.service' diff --git a/src/pages/course/index.css b/src/pages/course/index.css index 23d8343..f2429a2 100644 --- a/src/pages/course/index.css +++ b/src/pages/course/index.css @@ -1,16 +1,15 @@ .course-page { min-height: 100vh; - background-color: #f0f4f8; - padding-bottom: 32px; + background-color: #ffffff; + padding-bottom: 100rpx; } -/* 课程日历区域 */ .calendar-section { background-color: #ffffff; - margin-bottom: 24px; - border-radius: 16px; + margin-bottom: 24rpx; + border-radius: 16rpx; overflow: hidden; - margin: 16px; + margin: 16rpx; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } @@ -19,63 +18,61 @@ flex-direction: row; align-items: center; justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid #f0f0f0; + padding: 24rpx; + border-bottom: 1px solid #f3f4f6; } .calendar-title-row { display: flex; flex-direction: row; align-items: center; - gap: 10px; + gap: 12rpx; } .calendar-title { - font-size: 17px; + font-size: 32rpx; font-weight: 600; color: #1f2937; } .calendar-toggle { - font-size: 14px; - color: #3B82F6; + font-size: 26rpx; + color: #6b7280; } .calendar-body { - padding: 16px 16px 24px; + padding: 24rpx; } -/* 月份导航 */ .month-nav { display: flex; flex-direction: row; align-items: center; justify-content: space-between; - margin-bottom: 20px; - padding: 0 8px; + margin-bottom: 24rpx; + padding: 0 8rpx; } .nav-btn { - width: 36px; - height: 36px; + width: 64rpx; + height: 64rpx; display: flex; align-items: center; justify-content: center; - background-color: #f5f5f5; - border-radius: 8px; + background-color: #f3f4f6; + border-radius: 12rpx; } .month-label { - font-size: 16px; + font-size: 30rpx; font-weight: 600; color: #1f2937; } -/* 星期标题 */ .week-header { display: flex; flex-direction: row; - margin-bottom: 8px; + margin-bottom: 16rpx; } .week-day { @@ -83,16 +80,14 @@ display: flex; align-items: center; justify-content: center; - padding: 8px 0; + padding: 12rpx 0; } .week-day-text { - font-size: 12px; - color: #9CA3AF; - font-weight: 500; + font-size: 24rpx; + color: #9ca3af; } -/* 日期网格 */ .date-grid { display: flex; flex-direction: row; @@ -100,11 +95,12 @@ } .date-cell { - width: 14.28%; + width: calc(100% / 7); display: flex; flex-direction: column; align-items: center; - padding: 8px 0; + padding: 16rpx 0; + border-radius: 12rpx; cursor: pointer; } @@ -112,76 +108,44 @@ opacity: 0.3; } -.date-cell.is-today .date-text { - border: 2px solid #3B82F6; - border-radius: 50%; - color: #3B82F6; +.date-cell.is-today { + background-color: rgba(59, 130, 246, 0.1); } .date-cell.is-selected { background-color: #3B82F6; - border-radius: 10px; - margin: 2px; +} + +.date-text { + font-size: 28rpx; + color: #1f2937; + margin-bottom: 8rpx; } .date-cell.is-selected .date-text { color: #ffffff; -} - -.date-cell.is-selected .week-day-text { - color: #ffffff; -} - -.date-cell.is-selected .other-month { - opacity: 0.5; -} - -.date-text { - font-size: 15px; - color: #1f2937; - font-weight: 500; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; -} - -/* 课程圆点标记 */ -.course-dots { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - margin-top: 4px; - height: 12px; -} - -.course-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background-color: #3B82F6; - margin: 0 1px; -} - -.course-dot-text { - font-size: 8px; - color: #3B82F6; font-weight: 600; } -.date-cell.is-selected .course-dot { - background-color: #ffffff; +.course-dots { + display: flex; + gap: 4rpx; } -.date-cell.is-selected .course-dot-text { - color: #ffffff; +.course-dot { + width: 8rpx; + height: 8rpx; + border-radius: 50%; + background-color: #3B82F6; +} + +.course-dot-text { + font-size: 18rpx; + color: #3B82F6; } -/* 课程列表区域 */ .course-list-section { - margin: 0 16px; + padding: 0 16rpx; } .list-header { @@ -189,157 +153,373 @@ flex-direction: row; align-items: center; justify-content: space-between; - margin-bottom: 16px; + margin-bottom: 20rpx; +} + +.list-title-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 10rpx; } .list-title { - font-size: 17px; + font-size: 32rpx; font-weight: 600; color: #1f2937; } -.student-count { +.student-count-badge { display: flex; flex-direction: row; align-items: center; - gap: 4px; - background-color: #EEF2FF; - padding: 6px 12px; - border-radius: 16px; + background-color: #3B82F6; + padding: 8rpx 16rpx; + border-radius: 20rpx; } .count-number { - font-size: 15px; - font-weight: 700; - color: #3B82F6; + font-size: 26rpx; + font-weight: 600; + color: #ffffff; } .count-label { - font-size: 13px; - color: #6366F1; + font-size: 24rpx; + color: rgba(255, 255, 255, 0.8); + margin-left: 4rpx; } -/* 课程卡片 */ .course-list { display: flex; flex-direction: column; - gap: 12px; + 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: 14px; - padding: 16px 18px; + 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; +} + +.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; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + padding: 24rpx 28rpx; + border-bottom: 1px solid #f3f4f6; } -.course-info { - display: flex; - flex-direction: column; - gap: 8px; -} - -.student-name { - font-size: 16px; +.record-title { + font-size: 32rpx; font-weight: 600; color: #1f2937; } -.time-row { +.close-btn { + width: 48rpx; + height: 48rpx; display: flex; - flex-direction: row; - align-items: center; - gap: 6px; -} - -.time-text { - font-size: 14px; - color: #6B7280; -} - -.duration-tag { - background-color: #F3F4F6; - padding: 2px 8px; - border-radius: 10px; - margin-left: 8px; -} - -.duration-text { - font-size: 12px; - color: #6B7280; - font-weight: 500; -} - -.course-actions { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 8px; -} - -.status-badge { - padding: 4px 10px; - border-radius: 12px; -} - -.status-completed { - background-color: #D1FAE5; -} - -.status-completed .status-text { - color: #059669; -} - -.status-pending { - background-color: #FEF3C7; -} - -.status-pending .status-text { - color: #D97706; -} - -.status-cancelled { - background-color: #F3F4F6; -} - -.status-cancelled .status-text { - color: #9CA3AF; -} - -.status-text { - font-size: 12px; - font-weight: 500; -} - -.recorded-badge { - display: flex; - flex-direction: row; - align-items: center; - gap: 4px; -} - -.recorded-text { - font-size: 12px; - color: #10B981; - font-weight: 500; -} - -/* 空状态 */ -.empty-state { - display: flex; - flex-direction: column; align-items: center; justify-content: center; - padding: 48px 24px; - background-color: #ffffff; - border-radius: 14px; } -.empty-text { - font-size: 15px; - color: #9CA3AF; +.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; +} \ No newline at end of file diff --git a/src/pages/course/index.tsx b/src/pages/course/index.tsx index bd0fd56..1c31449 100644 --- a/src/pages/course/index.tsx +++ b/src/pages/course/index.tsx @@ -1,22 +1,30 @@ -import { View, Text } from '@tarojs/components' -import { useState, useEffect } from 'react' -import { Calendar, ChevronLeft, ChevronRight, CircleCheck, Clock } from 'lucide-react-taro' +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' -// 课程状态枚举 type CourseStatus = 'completed' | 'pending' | 'cancelled' -// 课程接口 interface Course { id: number studentName: string date: string startTime: string endTime: string - duration: number // 课时时长(小时) + duration: number status: CourseStatus - recorded: boolean // 是否已录入课时 + recorded: boolean +} + +interface RecordForm { + studentName: string + date: string + startTime: string + endTime: string + duration: number + fee: number } export default function CoursePage() { @@ -24,8 +32,17 @@ export default function CoursePage() { const [currentMonth, setCurrentMonth] = useState(new Date()) const [selectedDate, setSelectedDate] = useState(new Date()) const [courses, setCourses] = useState([]) + const [recordDialogOpen, setRecordDialogOpen] = useState(false) + const [currentCourse, setCurrentCourse] = useState(null) + const [recordForm, setRecordForm] = useState({ + studentName: '', + date: '', + startTime: '', + endTime: '', + duration: 1, + fee: 0 + }) - // 格式化日期为 YYYY-MM-DD const formatDate = (date: Date): string => { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') @@ -33,7 +50,6 @@ export default function CoursePage() { 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}`) @@ -41,51 +57,64 @@ export default function CoursePage() { return parseFloat(diffHours.toFixed(1)) } - // 加载课程数据 - useEffect(() => { + const loadCourses = useCallback(() => { const savedSchedules = Taro.getStorageSync('schedules') + const savedStudents = Taro.getStorageSync('students') + if (savedSchedules && Array.isArray(savedSchedules)) { - const courseList: Course[] = savedSchedules.map(s => ({ - id: s.id, - studentName: s.studentName, - date: s.date, - startTime: s.startTime, - endTime: s.endTime, - duration: calculateDuration(s.startTime, s.endTime), - status: 'pending' as CourseStatus, - recorded: false - })) + 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 }) } - // 下月日期,补满6行 const remaining = 42 - days.length for (let i = 1; i <= remaining; i++) { const date = new Date(year, month + 1, i) @@ -95,42 +124,98 @@ export default function CoursePage() { 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 getStatusLabel = (status: CourseStatus): { text: string; className: string } => { - switch (status) { - case 'completed': - return { text: '已上课', className: 'status-completed' } - case 'pending': - return { text: '未上课', className: 'status-pending' } - case 'cancelled': - return { text: '已取消', className: 'status-cancelled' } + 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)) } @@ -141,26 +226,21 @@ export default function CoursePage() { const monthData = getMonthData() const selectedCourses = getSelectedDateCourses() - const weekDays = ['一', '二', '三', '四', '五', '六', '日'] + const weekDays = ['日', '一', '二', '三', '四', '五', '六'] return ( - {/* 课程日历 */} - setCalendarExpanded(!calendarExpanded)} - > + setCalendarExpanded(!calendarExpanded)}> 课程日历 - {calendarExpanded ? '收起' : '展开'} + {calendarExpanded ? : } {calendarExpanded && ( - {/* 月份导航 */} @@ -173,7 +253,6 @@ export default function CoursePage() { - {/* 星期标题 */} {weekDays.map((day, index) => ( @@ -182,7 +261,6 @@ export default function CoursePage() { ))} - {/* 日期网格 */} {monthData.map((item, index) => { const courseCount = hasCourse(item.date) @@ -212,15 +290,15 @@ export default function CoursePage() { )} - {/* 今日课程列表 */} - - {selectedDate.getMonth() + 1}月{selectedDate.getDate()}日 课程 - - + + + {getListTitle()} + + {selectedCourses.length} - 位学员 + 节课 @@ -231,31 +309,51 @@ export default function CoursePage() { ) : ( selectedCourses.map(course => { - const statusInfo = getStatusLabel(course.status) + const progress = getStudentProgress(course.studentName) + const progressPercent = progress.total > 0 ? (progress.used / progress.total) * 100 : 0 + return ( - - {course.studentName} - - - - {course.startTime} - {course.endTime} - - - {course.duration}h + + + {course.studentName} + + + {course.startTime} - {course.endTime} + + {course.duration}h + + + + {course.status === 'completed' ? '已上课' : '未上课'} + + {course.recorded ? ( + + + 已录入 + + ) : ( + <> + {!isCourseTimeValid(course.date, course.endTime) && ( + handleRecordClick(course)}> + + 录入课时 + + )} + + )} + - - - {statusInfo.text} - - {course.recorded && ( - - - 已录入 + + 课时进度 + + + - )} + {progress.used.toFixed(1)}/{progress.total}h + ) @@ -263,6 +361,80 @@ export default function CoursePage() { )} + + {recordDialogOpen && ( + setRecordDialogOpen(false)}> + e.stopPropagation()}> + + 录入课时 + setRecordDialogOpen(false)}> + + + + + + + 学员: + {recordForm.studentName} + + + 日期: + {recordForm.date} + + + + 上课时间段 + + + {recordForm.startTime} + + + + {recordForm.endTime} + + + + + + 上课时长 (小时) + { + const val = parseFloat(e.detail.value) || 0 + setRecordForm(prev => ({ ...prev, duration: val })) + }} + placeholder="请输入时长" + /> + + + + 单次费用 (元) + { + const val = parseFloat(e.detail.value) || 0 + setRecordForm(prev => ({ ...prev, fee: val })) + }} + placeholder="请输入费用" + /> + + + + + setRecordDialogOpen(false)}> + 取消 + + + 保存 + + + + + )} ) -} +} \ No newline at end of file diff --git a/src/pages/record/index.css b/src/pages/record/index.css index f529d7b..cbce5da 100644 --- a/src/pages/record/index.css +++ b/src/pages/record/index.css @@ -20,8 +20,19 @@ padding: 20px; } +.form-row { + display: flex; + flex-direction: row; + gap: 12px; + margin-bottom: 16px; +} + .form-item { - margin-bottom: 20px; + flex: 1; +} + +.form-item.half { + width: calc(50% - 6px); } .form-label { diff --git a/src/pages/record/index.tsx b/src/pages/record/index.tsx index eda9adc..5085fa0 100644 --- a/src/pages/record/index.tsx +++ b/src/pages/record/index.tsx @@ -28,10 +28,20 @@ export default function RecordPage() { const [students, setStudents] = useState([]) const [selectedDate, setSelectedDate] = useState('') const [selectedStudent, setSelectedStudent] = useState(-1) + const [startTime, setStartTime] = useState('') + const [endTime, setEndTime] = useState('') const [duration, setDuration] = useState('') const [fee, setFee] = useState('') const [loading, setLoading] = useState(false) 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('') useEffect(() => { @@ -83,9 +93,35 @@ export default function RecordPage() { setSelectedStudent(index) } - // 课时时长输入 - const onDurationChange = (e: any) => { - setDuration(e.detail.value) + // 计算时长(小时) + const calculateDuration = (start: string, end: string) => { + 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) } // 费用输入 @@ -97,21 +133,43 @@ export default function RecordPage() { const handleSubmit = async () => { // 校验 if (!selectedDate) { - setMessage({ type: 'error', text: '请选择上课日期' }) + showMessage('error', '请选择上课日期') return } if (selectedStudent < 0 || !students[selectedStudent]) { - setMessage({ type: 'error', text: '请选择学员' }) + showMessage('error', '请选择学员') + return + } + if (!startTime) { + showMessage('error', '请选择开始时间') + return + } + if (!endTime) { + showMessage('error', '请选择结束时间') return } const durationNum = parseFloat(duration) if (Number.isNaN(durationNum) || durationNum <= 0) { - setMessage({ type: 'error', text: '课时时长需为正数' }) + showMessage('error', '课时时长需为正数') return } const feeNum = parseFloat(fee) 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 } @@ -120,34 +178,63 @@ export default function RecordPage() { try { const student = students[selectedStudent] - const res = await Network.request({ - url: '/api/records', - method: 'POST', - data: { - date: selectedDate, - name: student.studentName, - duration: durationNum, - fee: feeNum, - openid - } - }) - 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 record = { + id: Date.now(), + date: selectedDate, + name: student.studentName, + startTime, + endTime, + duration: durationNum, + fee: feeNum, + openid, + createTime: new Date().toISOString() } + + // 保存课时记录 + 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) { console.error('[课时录入] 提交失败:', err) - setMessage({ type: 'error', text: '网络错误,请重试' }) + showMessage('error', '保存失败,请重试') } finally { setLoading(false) } @@ -169,77 +256,114 @@ export default function RecordPage() { return ( - 课时录入 + 新增课时记录 - {/* 上课日期 */} - - 上课日期 - - - - {selectedDate || '请选择日期'} - {getWeekday(selectedDate)} - - + + {/* 上课日期 */} + + 上课日期 + + + + {selectedDate || '请选择日期'} + + + + + + {/* 学员选择 */} + + 学员姓名 + + + + = 0 && activeStudents[selectedStudent] ? 'picker-text' : 'picker-placeholder'}> + {selectedStudent >= 0 && activeStudents[selectedStudent] ? activeStudents[selectedStudent].studentName : '请选择学员'} + + + + - {/* 学员选择 */} - - 学员 - - + + {/* 开始时间 */} + + 开始时间 + + + + + {startTime || '--:--'} + + + + + + + {/* 结束时间 */} + + 结束时间 + + + + + {endTime || '--:--'} + + + + + + + + + {/* 上课时长 */} + + 上课时长(小时) + - = 0 && activeStudents[selectedStudent] ? 'picker-text' : 'picker-placeholder'}> - {selectedStudent >= 0 && activeStudents[selectedStudent] ? activeStudents[selectedStudent].studentName : '请选择学员'} + + {duration || '--'} - - {studentNames.length === 0 && ( - 暂无在读学员 - )} + - - {/* 课时时长 */} - - 课时时长(小时) - - {}} - /> - - - - {/* 单次费用 */} - - 单次费用(元) - - + {/* 课时费 */} + + 课时费(元) + + + diff --git a/src/pages/schedule/index.tsx b/src/pages/schedule/index.tsx index 79e95d5..cda5b84 100644 --- a/src/pages/schedule/index.tsx +++ b/src/pages/schedule/index.tsx @@ -14,6 +14,8 @@ interface ScheduleRecord { date: string startTime: string endTime: string + status?: 'completed' | 'pending' | 'cancelled' + recorded?: boolean } // 学员类型 @@ -68,58 +70,23 @@ export default function SchedulePage() { setSelectedDate(today) setAddForm(prev => ({ ...prev, date: today })) - if (userInfo && userInfo.openid) { - setOpenid(userInfo.openid) - loadStudents(userInfo.openid) - loadSchedules(userInfo.openid) - } else { - const savedStudents = Taro.getStorageSync('students') - if (savedStudents && Array.isArray(savedStudents)) { - setStudents(savedStudents) - } - - const savedSchedules = Taro.getStorageSync('schedules') - if (savedSchedules && Array.isArray(savedSchedules)) { - setSchedules(savedSchedules) - } - } + loadStudents() + loadSchedules() }, []) // 加载排课数据 - const loadSchedules = async (oid: string) => { - try { - const res = await Network.request({ - url: '/api/schedules', - method: 'GET', - data: { openid: oid } - }) - console.log('[排课] 加载排课数据:', res.data) - if (res.data && res.data.code === 200) { - const serverSchedules = res.data.data || [] - setSchedules(serverSchedules) - Taro.setStorageSync('schedules', serverSchedules) - } - } catch (err) { - console.error('[排课] 加载失败:', err) + const loadSchedules = () => { + const savedSchedules = Taro.getStorageSync('schedules') + if (savedSchedules && Array.isArray(savedSchedules)) { + setSchedules(savedSchedules) } } // 加载学员数据 - const loadStudents = async (oid: string) => { - try { - const res = await Network.request({ - url: '/api/students', - method: 'GET', - data: { openid: oid, activeOnly: 1 } - }) - console.log('[排课] 加载学员列表:', res.data) - if (res.data && res.data.code === 200) { - const serverStudents = res.data.data || [] - setStudents(serverStudents) - Taro.setStorageSync('students', serverStudents) - } - } catch (err) { - console.error('[排课] 加载学员失败:', err) + const loadStudents = () => { + const savedStudents = Taro.getStorageSync('students') + if (savedStudents && Array.isArray(savedStudents)) { + setStudents(savedStudents.filter((s: Student) => s.status === 0)) } } @@ -221,7 +188,9 @@ export default function SchedulePage() { studentName: addForm.studentName, date: addForm.date, startTime: addForm.startTime, - endTime: addForm.endTime + endTime: addForm.endTime, + status: 'pending', + recorded: false } const conflicts = checkTimeConflict(newSchedule) @@ -262,13 +231,17 @@ export default function SchedulePage() { 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 + endTime: editForm.endTime, + status: existingRecord?.status || 'pending', + recorded: existingRecord?.recorded || false } const conflicts = checkTimeConflict(updatedSchedule, editForm.id) diff --git a/src/pages/student/index.css b/src/pages/student/index.css index ed815d7..29eedc6 100644 --- a/src/pages/student/index.css +++ b/src/pages/student/index.css @@ -197,6 +197,24 @@ .student-name { font-size: 30rpx; font-weight: 600; +} + +.student-status-tag { + padding: 6rpx 16rpx; + border-radius: 8rpx; +} + +.student-status-tag.completed { + background-color: rgba(239, 68, 68, 0.15); +} + +.student-status-tag .status-tag-text { + font-size: 22rpx; + color: #dc2626; + font-weight: 500; +} + +.student-name { color: #1f2937; } @@ -229,6 +247,10 @@ background: #ef4444; } +.student-action-btn.restore-btn { + background: #10b981; +} + .student-action-text { font-size: 24rpx; color: #ffffff; @@ -585,6 +607,39 @@ font-weight: 500; } +.course-info { + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + gap: 16rpx; +} + +.course-status { + padding: 8rpx 20rpx; + border-radius: 8rpx; +} + +.course-status.pending { + background-color: rgba(251, 191, 36, 0.15); +} + +.course-status.completed { + background-color: rgba(16, 185, 129, 0.15); +} + +.course-status .status-text { + font-size: 24rpx; +} + +.course-status.pending .status-text { + color: #d97706; +} + +.course-status.completed .status-text { + color: #059669; +} + .course-summary { display: flex; align-items: center; diff --git a/src/pages/student/index.tsx b/src/pages/student/index.tsx index 242b7eb..6c3cabf 100644 --- a/src/pages/student/index.tsx +++ b/src/pages/student/index.tsx @@ -231,18 +231,26 @@ export default function StudentPage() { 👤 {student.studentName} + {student.status === 1 && ( + + 已结课 + + )} openCourseDialog(student)}> 课程 - { - setStudents(prev => prev.map(s => - s.id === student.id ? { ...s, status: 1 } : s - )) - Taro.showToast({ title: '已结课', icon: 'success' }) - }}> - 结课 + { + const newStatus = student.status === 1 ? 0 : 1 + setStudents(prev => prev.map(s => + s.id === student.id ? { ...s, status: newStatus } : s + )) + Taro.showToast({ title: newStatus === 1 ? '已结课' : '已恢复', icon: 'success' }) + }}> + {student.status === 1 ? '恢复' : '结课'} openDeleteDialog(student)}> 删除 @@ -418,8 +426,13 @@ export default function StudentPage() { {studentSchedules.map((schedule, index) => ( - {schedule.date} - {schedule.startTime} - {schedule.endTime} + + {schedule.date} + {schedule.startTime} - {schedule.endTime} + + + {schedule.status === 'completed' ? '已上课' : '未上课'} + ))}