完成了课时记录页面的两列布局改造,添加开始/结束时间选择器并自动计算时长,实现重复录入检测、消息自动隐藏、提交成功自动返回,以及多页面数据联动更新。
This commit is contained in:
parent
0b9b0eed83
commit
cd999bed76
|
|
@ -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,
|
||||
ScheduleService,
|
||||
{
|
||||
provide: StudentService,
|
||||
useFactory: (recordService: RecordService) => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './auth.controller'
|
||||
export * from './student.controller'
|
||||
export * from './record.controller'
|
||||
export * from './schedule.controller'
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './auth.dto'
|
||||
export * from './student.dto'
|
||||
export * from './record.dto'
|
||||
export * from './schedule.dto'
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './user.entity'
|
||||
export * from './student.entity'
|
||||
export * from './record.entity'
|
||||
export * from './schedule.entity'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<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
|
||||
})
|
||||
|
||||
// 格式化日期为 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 (
|
||||
<View className="course-page">
|
||||
{/* 课程日历 */}
|
||||
<View className="calendar-section">
|
||||
<View
|
||||
className="calendar-header"
|
||||
onClick={() => setCalendarExpanded(!calendarExpanded)}
|
||||
>
|
||||
<View className="calendar-header" onClick={() => setCalendarExpanded(!calendarExpanded)}>
|
||||
<View className="calendar-title-row">
|
||||
<Calendar size={20} color="#3B82F6" />
|
||||
<Text className="calendar-title">课程日历</Text>
|
||||
</View>
|
||||
<Text className="calendar-toggle">{calendarExpanded ? '收起' : '展开'}</Text>
|
||||
{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" />
|
||||
|
|
@ -173,7 +253,6 @@ export default function CoursePage() {
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* 星期标题 */}
|
||||
<View className="week-header">
|
||||
{weekDays.map((day, index) => (
|
||||
<View key={index} className="week-day">
|
||||
|
|
@ -182,7 +261,6 @@ export default function CoursePage() {
|
|||
))}
|
||||
</View>
|
||||
|
||||
{/* 日期网格 */}
|
||||
<View className="date-grid">
|
||||
{monthData.map((item, index) => {
|
||||
const courseCount = hasCourse(item.date)
|
||||
|
|
@ -212,15 +290,15 @@ export default function CoursePage() {
|
|||
)}
|
||||
</View>
|
||||
|
||||
{/* 今日课程列表 */}
|
||||
<View className="course-list-section">
|
||||
<View className="list-header">
|
||||
<Text className="list-title">
|
||||
{selectedDate.getMonth() + 1}月{selectedDate.getDate()}日 课程
|
||||
</Text>
|
||||
<View className="student-count">
|
||||
<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>
|
||||
<Text className="count-label">节课</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
|
@ -231,31 +309,51 @@ export default function CoursePage() {
|
|||
</View>
|
||||
) : (
|
||||
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 (
|
||||
<View key={course.id} className="course-card">
|
||||
<View className="course-info">
|
||||
<Text className="student-name">{course.studentName}</Text>
|
||||
<View className="time-row">
|
||||
<Clock size={14} color="#6B7280" />
|
||||
<Text className="time-text">
|
||||
{course.startTime} - {course.endTime}
|
||||
</Text>
|
||||
<View className="duration-tag">
|
||||
<Text className="duration-text">{course.duration}h</Text>
|
||||
<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-actions">
|
||||
<View className={`status-badge ${statusInfo.className}`}>
|
||||
<Text className="status-text">{statusInfo.text}</Text>
|
||||
</View>
|
||||
{course.recorded && (
|
||||
<View className="recorded-badge">
|
||||
<CircleCheck size={14} color="#10B981" />
|
||||
<Text className="recorded-text">已录入</Text>
|
||||
<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>
|
||||
)
|
||||
|
|
@ -263,6 +361,80 @@ export default function CoursePage() {
|
|||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -28,10 +28,20 @@ export default function RecordPage() {
|
|||
const [students, setStudents] = useState<Student[]>([])
|
||||
const [selectedDate, setSelectedDate] = useState<string>('')
|
||||
const [selectedStudent, setSelectedStudent] = useState<number>(-1)
|
||||
const [startTime, setStartTime] = useState<string>('')
|
||||
const [endTime, setEndTime] = useState<string>('')
|
||||
const [duration, setDuration] = useState<string>('')
|
||||
const [fee, setFee] = useState<string>('')
|
||||
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<string>('')
|
||||
|
||||
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 (
|
||||
<View className="page-container">
|
||||
<View className="page-header">
|
||||
<Text className="page-title">课时录入</Text>
|
||||
<Text className="page-title">新增课时记录</Text>
|
||||
</View>
|
||||
|
||||
<View className="form-container">
|
||||
{/* 上课日期 */}
|
||||
<View className="form-item">
|
||||
<Text className="form-label">上课日期</Text>
|
||||
<View className="form-input-wrapper">
|
||||
<Picker
|
||||
mode="date"
|
||||
value={selectedDate}
|
||||
onChange={onDateChange}
|
||||
className="date-picker"
|
||||
>
|
||||
<View className="picker-content">
|
||||
<Text className="picker-text">{selectedDate || '请选择日期'}</Text>
|
||||
<Text className="picker-weekday">{getWeekday(selectedDate)}</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
<View className="form-row">
|
||||
{/* 上课日期 */}
|
||||
<View className="form-item half">
|
||||
<Text className="form-label">上课日期</Text>
|
||||
<View className="form-input-wrapper">
|
||||
<Picker
|
||||
mode="date"
|
||||
value={selectedDate}
|
||||
onChange={onDateChange}
|
||||
className="date-picker"
|
||||
>
|
||||
<View className="picker-content">
|
||||
<Text className="picker-text">{selectedDate || '请选择日期'}</Text>
|
||||
</View>
|
||||
</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 className="form-item">
|
||||
<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="form-row">
|
||||
{/* 开始时间 */}
|
||||
<View className="form-item half">
|
||||
<Text className="form-label">开始时间</Text>
|
||||
<View className="form-input-wrapper">
|
||||
<Picker
|
||||
mode="time"
|
||||
value={startTime}
|
||||
onChange={onStartTimeChange}
|
||||
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">
|
||||
<Text className={selectedStudent >= 0 && activeStudents[selectedStudent] ? 'picker-text' : 'picker-placeholder'}>
|
||||
{selectedStudent >= 0 && activeStudents[selectedStudent] ? activeStudents[selectedStudent].studentName : '请选择学员'}
|
||||
<Text className={duration ? 'picker-text' : 'picker-placeholder'}>
|
||||
{duration || '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</Picker>
|
||||
{studentNames.length === 0 && (
|
||||
<Text className="form-hint error">暂无在读学员</Text>
|
||||
)}
|
||||
</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={duration}
|
||||
onInput={onDurationChange}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
</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 className="form-item half">
|
||||
<Text className="form-label">课时费(元)</Text>
|
||||
<View className="form-input-wrapper">
|
||||
<Input
|
||||
className="simple-input"
|
||||
type="digit"
|
||||
placeholder="如 200"
|
||||
value={fee}
|
||||
onInput={onFeeChange}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -231,18 +231,26 @@ export default function StudentPage() {
|
|||
<View className="student-name-section">
|
||||
<Text className="student-icon">👤</Text>
|
||||
<Text className="student-name">{student.studentName}</Text>
|
||||
{student.status === 1 && (
|
||||
<View className="student-status-tag completed">
|
||||
<Text className="status-tag-text">已结课</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="student-actions">
|
||||
<View className="student-action-btn course-btn" onClick={() => openCourseDialog(student)}>
|
||||
<Text className="student-action-text">课程</Text>
|
||||
</View>
|
||||
<View className="student-action-btn end-btn" onClick={() => {
|
||||
setStudents(prev => prev.map(s =>
|
||||
s.id === student.id ? { ...s, status: 1 } : s
|
||||
))
|
||||
Taro.showToast({ title: '已结课', icon: 'success' })
|
||||
}}>
|
||||
<Text className="student-action-text">结课</Text>
|
||||
<View
|
||||
className={`student-action-btn ${student.status === 1 ? 'restore-btn' : 'end-btn'}`}
|
||||
onClick={() => {
|
||||
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' })
|
||||
}}>
|
||||
<Text className="student-action-text">{student.status === 1 ? '恢复' : '结课'}</Text>
|
||||
</View>
|
||||
<View className="student-action-btn delete-btn" onClick={() => openDeleteDialog(student)}>
|
||||
<Text className="student-action-text">删除</Text>
|
||||
|
|
@ -418,8 +426,13 @@ export default function StudentPage() {
|
|||
<View className="course-list">
|
||||
{studentSchedules.map((schedule, index) => (
|
||||
<View key={schedule.id || index} className="course-item">
|
||||
<Text className="course-date">{schedule.date}</Text>
|
||||
<Text className="course-time">{schedule.startTime} - {schedule.endTime}</Text>
|
||||
<View className="course-info">
|
||||
<Text className="course-date">{schedule.date}</Text>
|
||||
<Text className="course-time">{schedule.startTime} - {schedule.endTime}</Text>
|
||||
</View>
|
||||
<View className={`course-status ${schedule.status === 'completed' ? 'completed' : 'pending'}`}>
|
||||
<Text className="status-text">{schedule.status === 'completed' ? '已上课' : '未上课'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<View className="course-summary">
|
||||
|
|
|
|||
Loading…
Reference in New Issue