Emit correlated JSON logs from the API and FunASR services, propagate request IDs to the client, and configure bounded opt-in Docker logging. Document the host-wide Grafana/Loki query workflow and keep observability deployment ownership outside the application repository.
432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
import {
|
|
copyApiRequestId,
|
|
createProfile,
|
|
deleteProfile as deleteProfileRequest,
|
|
getApiErrorMessage,
|
|
getApiRequestId,
|
|
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: '',
|
|
errorRequestId: ''
|
|
},
|
|
|
|
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: '', errorRequestId: '' })
|
|
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), errorRequestId: getApiRequestId(error) })
|
|
} finally {
|
|
this.setData({ loading: false })
|
|
}
|
|
},
|
|
|
|
onRefresh() {
|
|
void this.refreshProfiles(true)
|
|
},
|
|
|
|
retryLoad() {
|
|
void this.refreshProfiles()
|
|
},
|
|
|
|
copyRequestId() {
|
|
copyApiRequestId(this.data.errorRequestId)
|
|
},
|
|
|
|
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: '' })
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|