Files

425 lines
13 KiB
TypeScript

import {
createProfile,
deleteProfile as deleteProfileRequest,
getApiErrorMessage,
getProfileDefaults,
listFeedbackRecords,
listProfiles
} from '../../utils/api'
import { addMinutes, formatUpdatedAt } from '../../utils/time'
import type { FeedbackRecord, StudentDraft, StudentProfile, StudentProfileDefaults, Term } from '../../utils/types'
const grades = ['初一', '初二', '初三']
const subjects = ['美术', '书法', '音乐', '舞蹈', '语文', '数学', '英语']
const terms: Term[] = ['春', '暑', '秋', '寒']
const years = Array.from({ length: 5 }, (_, index) => new Date().getFullYear() - 1 + index)
const initialDefaults: StudentProfileDefaults = {
grade: '初一',
subject: '数学',
lessonDurationMinutes: 60
}
const termLabels: Record<Term, string> = {
: '春季',
: '暑期',
: '秋季',
: '寒假'
}
type InputEvent = { detail: { value: string } }
type PickerEvent = { detail: { value: string | number } }
type StudentProfileView = StudentProfile & { academicTermLabel: string }
function withAcademicTermLabel(profile: StudentProfile): StudentProfileView {
return {
...profile,
academicTermLabel: `${profile.academicYear}${termLabels[profile.term]}`
}
}
function getCurrentTerm(): Term {
const month = new Date().getMonth() + 1
if (month >= 6 && month <= 8) return '暑'
if (month >= 9 && month <= 12) return '秋'
if (month <= 2) return '寒'
return '春'
}
function buildDraft(defaults: StudentProfileDefaults = initialDefaults): StudentDraft {
const startTime = '18:00'
return {
name: '',
grade: defaults.grade,
subject: defaults.subject,
academicYear: new Date().getFullYear(),
term: getCurrentTerm(),
guardianTitle: '',
startTime,
endTime: addMinutes(startTime, defaults.lessonDurationMinutes)
}
}
function filterProfiles(
profiles: StudentProfileView[],
query: string,
filterGrade: string,
filterSubject: string
): StudentProfileView[] {
const keyword = query.trim().toLocaleLowerCase()
return profiles.filter((profile) => {
const searchable = [profile.name, profile.grade, profile.subject, profile.guardianTitle]
.join(' ')
.toLocaleLowerCase()
const matchesKeyword = !keyword || searchable.includes(keyword)
const matchesGrade = !filterGrade || profile.grade === filterGrade
const matchesSubject = !filterSubject || profile.subject === filterSubject
return matchesKeyword && matchesGrade && matchesSubject
})
}
function getLastUpdatedText(profiles: StudentProfile[]): string {
if (!profiles.length) return '--'
const latest = Math.max(...profiles.map((profile) => profile.updatedAt))
return formatUpdatedAt(latest)
}
Page({
data: {
profiles: [] as StudentProfileView[],
filteredProfiles: [] as StudentProfileView[],
query: '',
filterVisible: false,
filterGrade: '',
filterSubject: '',
filterCount: 0,
expandedProfileId: '',
recentFeedbackByProfile: {} as Record<string, FeedbackRecord[]>,
feedbackLoadingByProfile: {} as Record<string, boolean>,
feedbackErrorsByProfile: {} as Record<string, string>,
grades,
subjects,
terms,
years,
sheetVisible: false,
draft: buildDraft(),
guardianAuto: true,
refreshDraftFromDefaults: false,
lastUpdatedText: '--',
loading: false,
saving: false,
deletingId: '',
errorMessage: ''
},
onShow() {
this.getTabBar()?.setData({ selected: 1, visible: !this.data.sheetVisible })
void this.refreshProfiles()
if (this.data.sheetVisible && this.data.refreshDraftFromDefaults) {
void this.refreshDraftDefaults()
}
},
async refreshProfiles(showSuccess = false) {
this.setData({ loading: true, errorMessage: '' })
try {
const profiles = (await listProfiles()).map(withAcademicTermLabel)
const filteredProfiles = filterProfiles(
profiles,
this.data.query,
this.data.filterGrade,
this.data.filterSubject
)
this.setData({
profiles,
filteredProfiles,
lastUpdatedText: getLastUpdatedText(profiles),
expandedProfileId: '',
recentFeedbackByProfile: {},
feedbackLoadingByProfile: {},
feedbackErrorsByProfile: {}
})
if (showSuccess) wx.showToast({ title: '档案已刷新', icon: 'success' })
} catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error) })
} finally {
this.setData({ loading: false })
}
},
onRefresh() {
void this.refreshProfiles(true)
},
retryLoad() {
void this.refreshProfiles()
},
onSearch(event: InputEvent) {
const query = event.detail.value
this.setData({
query,
filteredProfiles: filterProfiles(
this.data.profiles,
query,
this.data.filterGrade,
this.data.filterSubject
)
})
},
toggleFilter() {
this.setData({ filterVisible: !this.data.filterVisible })
},
selectFilter(event: WechatMiniprogram.TouchEvent) {
const { type, value } = event.currentTarget.dataset as { type: 'grade' | 'subject'; value: string }
const filterGrade = type === 'grade' ? (value === 'all' ? '' : value) : this.data.filterGrade
const filterSubject = type === 'subject' ? (value === 'all' ? '' : value) : this.data.filterSubject
this.setData({
filterGrade,
filterSubject,
filterCount: Number(Boolean(filterGrade)) + Number(Boolean(filterSubject)),
filteredProfiles: filterProfiles(this.data.profiles, this.data.query, filterGrade, filterSubject)
})
},
clearFilters() {
this.setData({
query: '',
filterGrade: '',
filterSubject: '',
filterCount: 0,
filteredProfiles: this.data.profiles
})
},
toggleProfile(event: WechatMiniprogram.TouchEvent) {
const { id } = event.currentTarget.dataset as { id: string }
if (this.data.expandedProfileId === id) {
this.setData({ expandedProfileId: '' })
return
}
const hasFeedback = Object.prototype.hasOwnProperty.call(this.data.recentFeedbackByProfile, id)
this.setData({
expandedProfileId: id,
...(hasFeedback
? {}
: {
feedbackLoadingByProfile: { ...this.data.feedbackLoadingByProfile, [id]: true },
feedbackErrorsByProfile: { ...this.data.feedbackErrorsByProfile, [id]: '' }
})
})
if (!hasFeedback) void this.loadRecentFeedback(id)
},
async loadRecentFeedback(profileId: string) {
try {
const records = await listFeedbackRecords(profileId)
this.setData({
recentFeedbackByProfile: {
...this.data.recentFeedbackByProfile,
[profileId]: records.slice(0, 3)
}
})
} catch (error) {
this.setData({
feedbackErrorsByProfile: {
...this.data.feedbackErrorsByProfile,
[profileId]: getApiErrorMessage(error)
}
})
} finally {
this.setData({
feedbackLoadingByProfile: {
...this.data.feedbackLoadingByProfile,
[profileId]: false
}
})
}
},
retryRecentFeedback(event: WechatMiniprogram.TouchEvent) {
const { id } = event.currentTarget.dataset as { id: string }
if (this.data.feedbackLoadingByProfile[id]) return
this.setData({
feedbackLoadingByProfile: { ...this.data.feedbackLoadingByProfile, [id]: true },
feedbackErrorsByProfile: { ...this.data.feedbackErrorsByProfile, [id]: '' }
})
void this.loadRecentFeedback(id)
},
async openCreate() {
this.setData({
sheetVisible: true,
guardianAuto: true,
draft: buildDraft()
})
this.getTabBar()?.setData({ visible: false })
await this.refreshDraftDefaults()
},
async refreshDraftDefaults() {
try {
const defaults = await getProfileDefaults()
if (!this.data.sheetVisible) return
this.setData({
'draft.grade': defaults.grade,
'draft.subject': defaults.subject,
'draft.endTime': addMinutes(this.data.draft.startTime, defaults.lessonDurationMinutes),
refreshDraftFromDefaults: false
})
} catch (error) {
this.setData({ refreshDraftFromDefaults: false })
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
}
},
closeCreate() {
this.setData({ sheetVisible: false })
this.getTabBar()?.setData({ visible: true })
},
noop() {},
goDefaults() {
this.setData({ refreshDraftFromDefaults: true })
wx.navigateTo({ url: '/pages/defaults/index' })
},
onNameInput(event: InputEvent) {
const name = event.detail.value
const nextData: Record<string, unknown> = { 'draft.name': name }
if (this.data.guardianAuto || !this.data.draft.guardianTitle) {
nextData['draft.guardianTitle'] = name ? `${name}妈妈` : ''
nextData.guardianAuto = true
}
this.setData(nextData)
},
onGuardianInput(event: InputEvent) {
this.setData({
'draft.guardianTitle': event.detail.value,
guardianAuto: false
})
},
onGradeChange(event: PickerEvent) {
this.setData({ 'draft.grade': grades[Number(event.detail.value)] })
},
onSubjectChange(event: PickerEvent) {
this.setData({ 'draft.subject': subjects[Number(event.detail.value)] })
},
onYearChange(event: PickerEvent) {
this.setData({ 'draft.academicYear': years[Number(event.detail.value)] })
},
onTermChange(event: PickerEvent) {
this.setData({ 'draft.term': terms[Number(event.detail.value)] })
},
onStartTimeChange(event: InputEvent) {
const startTime = event.detail.value
this.setData({ 'draft.startTime': startTime })
void getProfileDefaults()
.then((defaults) => {
this.setData({ 'draft.endTime': addMinutes(startTime, defaults.lessonDurationMinutes) })
})
.catch(() => {
this.setData({ 'draft.endTime': addMinutes(startTime, initialDefaults.lessonDurationMinutes) })
})
},
onEndTimeChange(event: InputEvent) {
this.setData({ 'draft.endTime': event.detail.value })
},
async saveStudent() {
const draft = this.data.draft
if (!draft.name.trim()) {
wx.showToast({ title: '请填写学生姓名', icon: 'none' })
return
}
this.setData({ saving: true })
try {
const profile = withAcademicTermLabel(
await createProfile({
...draft,
name: draft.name.trim(),
guardianTitle: draft.guardianTitle.trim() || `${draft.name.trim()}妈妈`
})
)
const profiles = [profile, ...this.data.profiles]
this.setData({
sheetVisible: false,
profiles,
filteredProfiles: filterProfiles(profiles, this.data.query, this.data.filterGrade, this.data.filterSubject),
lastUpdatedText: formatUpdatedAt(profile.updatedAt),
errorMessage: ''
})
this.getTabBar()?.setData({ visible: true })
wx.showToast({ title: '学生档案已保存', icon: 'success' })
} catch (error) {
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
} finally {
this.setData({ saving: false })
}
},
openProfileActions(event: WechatMiniprogram.TouchEvent) {
const { id } = event.currentTarget.dataset as { id: string }
const profile = this.data.profiles.find((item) => item.id === id)
if (!profile) return
wx.showActionSheet({
itemList: ['删除学生档案'],
itemColor: '#D13F3A',
success: (result) => {
if (result.tapIndex === 0) this.confirmDeleteProfile(id)
}
})
},
confirmDeleteProfile(id: string) {
const profile = this.data.profiles.find((item) => item.id === id)
if (!profile) return
wx.showModal({
title: '删除学生档案',
content: `确认删除“${profile.name}”吗?`,
confirmColor: '#D13F3A',
success: async (result) => {
if (!result.confirm) return
this.setData({ deletingId: id })
try {
await deleteProfileRequest(id)
const profiles = this.data.profiles.filter((item) => item.id !== id)
const recentFeedbackByProfile = { ...this.data.recentFeedbackByProfile }
const feedbackLoadingByProfile = { ...this.data.feedbackLoadingByProfile }
const feedbackErrorsByProfile = { ...this.data.feedbackErrorsByProfile }
delete recentFeedbackByProfile[id]
delete feedbackLoadingByProfile[id]
delete feedbackErrorsByProfile[id]
this.setData({
profiles,
filteredProfiles: filterProfiles(profiles, this.data.query, this.data.filterGrade, this.data.filterSubject),
lastUpdatedText: getLastUpdatedText(profiles),
expandedProfileId: this.data.expandedProfileId === id ? '' : this.data.expandedProfileId,
recentFeedbackByProfile,
feedbackLoadingByProfile,
feedbackErrorsByProfile,
errorMessage: ''
})
wx.showToast({ title: '已删除', icon: 'success' })
} catch (error) {
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
} finally {
this.setData({ deletingId: '' })
}
}
})
}
})