feat: establish teaching feedback mini program

Connect student profiles, defaults, and feedback records to the Rust API. Configure the WeChat AppID, custom tab bar, and local development workflow for version 0.1.0.
This commit is contained in:
2026-07-15 15:36:51 +08:00
commit 0a07538dce
37 changed files with 2550 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "教学反馈助手"
}

314
pages/students/index.ts Normal file
View File

@@ -0,0 +1,314 @@
import {
createProfile,
deleteProfile as deleteProfileRequest,
getApiErrorMessage,
getProfileDefaults,
listProfiles
} from '../../utils/api'
import { addMinutes, formatUpdatedAt } from '../../utils/time'
import type { 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
}
type InputEvent = { detail: { value: string } }
type PickerEvent = { detail: { value: string | number } }
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: StudentProfile[],
query: string,
filterGrade: string,
filterSubject: string
): StudentProfile[] {
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 StudentProfile[],
filteredProfiles: [] as StudentProfile[],
query: '',
filterVisible: false,
filterGrade: '',
filterSubject: '',
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 })
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()
const filteredProfiles = filterProfiles(
profiles,
this.data.query,
this.data.filterGrade,
this.data.filterSubject
)
this.setData({
profiles,
filteredProfiles,
lastUpdatedText: getLastUpdatedText(profiles)
})
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,
filteredProfiles: filterProfiles(this.data.profiles, this.data.query, filterGrade, filterSubject)
})
},
clearFilters() {
this.setData({
query: '',
filterGrade: '',
filterSubject: '',
filteredProfiles: this.data.profiles
})
},
async openCreate() {
this.setData({
sheetVisible: true,
guardianAuto: true,
draft: buildDraft()
})
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 })
},
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 = 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: ''
})
wx.showToast({ title: '学生档案已保存', icon: 'success' })
} catch (error) {
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
} finally {
this.setData({ saving: false })
}
},
deleteProfile(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.showModal({
title: '删除学生档案',
content: `确认删除“${profile.name}”吗?`,
confirmColor: '#D14343',
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)
this.setData({
profiles,
filteredProfiles: filterProfiles(profiles, this.data.query, this.data.filterGrade, this.data.filterSubject),
lastUpdatedText: getLastUpdatedText(profiles),
errorMessage: ''
})
wx.showToast({ title: '已删除', icon: 'success' })
} catch (error) {
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
} finally {
this.setData({ deletingId: '' })
}
}
})
}
})

161
pages/students/index.wxml Normal file
View File

@@ -0,0 +1,161 @@
<view class="page-shell">
<text class="eyebrow">个人档案</text>
<view class="page-heading">
<text class="heading-icon">◎</text>
<text class="page-title">学生档案</text>
</view>
<text class="page-subtitle">管理学生默认信息,用于快速生成课堂反馈</text>
<view wx:if="{{errorMessage}}" class="status-banner">
<text>{{errorMessage}}</text>
<text class="status-action" bindtap="retryLoad">重试</text>
</view>
<view class="search-row">
<view class="search-box">
<text class="search-icon">⌕</text>
<input
class="search-input"
placeholder="搜索学生姓名、年级、学科或称呼"
placeholder-class="search-placeholder"
value="{{query}}"
bindinput="onSearch"
confirm-type="search"
/>
</view>
<button class="filter-button" bindtap="toggleFilter" aria-label="筛选学生档案">
<text>▽</text>
</button>
</view>
<view wx:if="{{filterVisible}}" class="filter-panel surface">
<view class="filter-header">
<text class="filter-title">筛选学生档案</text>
<text class="filter-clear" bindtap="clearFilters">清除</text>
</view>
<text class="filter-label">年级</text>
<view class="filter-options">
<text class="filter-chip {{!filterGrade ? 'filter-chip-active' : ''}}" data-type="grade" data-value="all" bindtap="selectFilter">全部</text>
<text wx:for="{{grades}}" wx:key="*this" class="filter-chip {{filterGrade === item ? 'filter-chip-active' : ''}}" data-type="grade" data-value="{{item}}" bindtap="selectFilter">{{item}}</text>
</view>
<text class="filter-label">学科</text>
<view class="filter-options">
<text class="filter-chip {{!filterSubject ? 'filter-chip-active' : ''}}" data-type="subject" data-value="all" bindtap="selectFilter">全部</text>
<text wx:for="{{subjects}}" wx:key="*this" class="filter-chip {{filterSubject === item ? 'filter-chip-active' : ''}}" data-type="subject" data-value="{{item}}" bindtap="selectFilter">{{item}}</text>
</view>
</view>
<view class="archive-toolbar surface">
<view class="toolbar-copy">
<view class="toolbar-title-row">
<text class="toolbar-icon">▣</text>
<text class="toolbar-title">档案卡片</text>
</view>
<text class="toolbar-description">点击卡片展开最近三条反馈</text>
<text class="toolbar-updated">上次更新:{{lastUpdatedText}}</text>
</view>
<view class="toolbar-actions">
<button class="refresh-button" loading="{{loading}}" disabled="{{loading}}" bindtap="onRefresh" aria-label="刷新学生档案">↻</button>
<button class="new-student-button" bindtap="openCreate"><text class="new-student-icon"></text>新增学生</button>
</view>
</view>
<view wx:if="{{loading && profiles.length === 0}}" class="loading-state">正在从服务端读取学生档案...</view>
<view wx:elif="{{profiles.length === 0}}" class="empty-state">
<text class="empty-icon">◎</text>
<text class="empty-title">暂无学生档案</text>
<text class="empty-description">请先创建学生档案</text>
<button class="create-empty-button primary-button" bindtap="openCreate"><text class="button-icon"></text>创建学生档案</button>
</view>
<view wx:elif="{{filteredProfiles.length === 0}}" class="empty-state compact-empty">
<text class="empty-icon">⌕</text>
<text class="empty-title">没有匹配的档案</text>
<text class="empty-description">试试清除关键词或筛选条件</text>
<button class="clear-search-button secondary-button" bindtap="clearFilters">清除搜索与筛选</button>
</view>
<view wx:else class="profile-list">
<view wx:for="{{filteredProfiles}}" wx:key="id" class="profile-card surface">
<view class="profile-card-top">
<view>
<text class="profile-name">{{item.name}}</text>
<text class="profile-guardian">{{item.guardianTitle}}</text>
</view>
<button class="delete-button" loading="{{deletingId === item.id}}" disabled="{{deletingId === item.id}}" data-id="{{item.id}}" catchtap="deleteProfile" aria-label="删除学生档案">删除</button>
</view>
<view class="profile-tags">
<text class="profile-tag">{{item.grade}}</text>
<text class="profile-tag">{{item.subject}}</text>
<text class="profile-tag">{{item.academicYear}}{{item.term}}</text>
</view>
<view class="profile-time-row">
<text class="profile-time-label">上课时间</text>
<text class="profile-time-value">{{item.startTime}} - {{item.endTime}}</text>
</view>
</view>
</view>
</view>
<view wx:if="{{sheetVisible}}" class="sheet-mask" bindtap="closeCreate">
<view class="create-sheet" catchtap="noop">
<view class="sheet-top">
<view>
<view class="sheet-title-row">
<text class="sheet-title-icon">▣</text>
<text class="sheet-title">新增学生档案</text>
</view>
<text class="sheet-subtitle">保存后会同步到学生档案列表</text>
</view>
<text class="defaults-link" bindtap="goDefaults">设置预设项 </text>
</view>
<scroll-view class="sheet-scroll" scroll-y="true">
<view class="form-field full-field">
<text class="form-label">学生姓名</text>
<input class="form-input" placeholder="请输入学生姓名" value="{{draft.name}}" bindinput="onNameInput" maxlength="20" />
</view>
<view class="form-grid">
<picker class="form-field" mode="selector" range="{{grades}}" bindchange="onGradeChange">
<text class="form-label">年级</text>
<text class="form-value">{{draft.grade}}</text>
</picker>
<picker class="form-field" mode="selector" range="{{subjects}}" bindchange="onSubjectChange">
<text class="form-label">学科</text>
<text class="form-value">{{draft.subject}}</text>
</picker>
<picker class="form-field" mode="selector" range="{{years}}" bindchange="onYearChange">
<text class="form-label">日期年份</text>
<text class="form-value">{{draft.academicYear}}</text>
</picker>
<picker class="form-field" mode="selector" range="{{terms}}" bindchange="onTermChange">
<text class="form-label">日期</text>
<text class="form-value">{{draft.term}}</text>
</picker>
</view>
<view class="form-field full-field guardian-field">
<text class="form-label">默认家长称呼</text>
<input class="form-input" placeholder="默认:学生姓名 + 妈妈" value="{{draft.guardianTitle}}" bindinput="onGuardianInput" maxlength="24" />
</view>
<view class="form-grid bottom-grid">
<picker class="form-field" mode="time" value="{{draft.startTime}}" bindchange="onStartTimeChange">
<text class="form-label">开始时间</text>
<text class="form-value">{{draft.startTime}}</text>
</picker>
<picker class="form-field" mode="time" value="{{draft.endTime}}" bindchange="onEndTimeChange">
<text class="form-label">结束时间</text>
<text class="form-value">{{draft.endTime}}</text>
</picker>
</view>
</scroll-view>
<view class="sheet-footer">
<button class="sheet-cancel secondary-button" bindtap="closeCreate">取消</button>
<button class="sheet-save primary-button" loading="{{saving}}" disabled="{{saving}}" bindtap="saveStudent">保存</button>
</view>
</view>
</view>

457
pages/students/index.wxss Normal file
View File

@@ -0,0 +1,457 @@
.search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 88rpx;
gap: 16rpx;
margin-top: 42rpx;
}
.search-box {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
height: 88rpx;
padding: 0 24rpx;
border: 2rpx solid #cbdced;
border-radius: 16rpx;
background: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(45, 78, 120, 0.05);
}
.search-icon {
margin-right: 14rpx;
color: #2b67e8;
font-size: 44rpx;
line-height: 1;
}
.search-input {
flex: 1;
min-width: 0;
color: #17233a;
font-size: 28rpx;
}
.search-placeholder {
color: #9aa8b8;
}
.filter-button {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 88rpx;
min-width: 88rpx;
max-width: 88rpx;
height: 88rpx;
margin: 0;
padding: 0;
border: 2rpx solid #cbdced;
border-radius: 16rpx;
background: #ffffff;
color: #2b67e8;
font-size: 44rpx;
line-height: 1;
}
.filter-panel {
margin-top: 16rpx;
padding: 24rpx;
}
.filter-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.filter-title {
color: #17233a;
font-size: 30rpx;
font-weight: 700;
}
.filter-clear {
color: #2b67e8;
font-size: 26rpx;
}
.filter-label {
display: block;
margin: 16rpx 0 12rpx;
color: #718096;
font-size: 24rpx;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.filter-chip {
padding: 10rpx 18rpx;
border: 1rpx solid #d6e1ed;
border-radius: 12rpx;
color: #566577;
font-size: 24rpx;
line-height: 1.2;
}
.filter-chip-active {
border-color: #2b67e8;
background: #eaf1ff;
color: #2b67e8;
}
.archive-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 264rpx;
align-items: center;
gap: 20rpx;
margin-top: 20rpx;
padding: 28rpx;
}
.toolbar-copy {
flex: 1;
min-width: 0;
}
.toolbar-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.toolbar-icon {
color: #2b67e8;
font-size: 34rpx;
}
.toolbar-title {
color: #17233a;
font-size: 34rpx;
font-weight: 800;
}
.toolbar-description,
.toolbar-updated {
display: block;
margin-top: 8rpx;
color: #7b8796;
font-size: 24rpx;
line-height: 1.4;
}
.toolbar-updated {
font-weight: 600;
}
.toolbar-actions {
display: grid;
grid-template-columns: 62rpx 190rpx;
align-items: center;
width: 264rpx;
min-width: 264rpx;
max-width: 264rpx;
gap: 12rpx;
}
.refresh-button {
flex: none;
width: 62rpx;
min-width: 62rpx;
max-width: 62rpx;
height: 62rpx;
margin: 0;
padding: 0;
border: 2rpx solid #cbdced;
border-radius: 12rpx;
background: #ffffff;
color: #2b67e8;
font-size: 36rpx;
line-height: 1;
}
.new-student-button {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 190rpx;
min-width: 190rpx;
max-width: 190rpx;
height: 62rpx;
margin: 0;
padding: 0 18rpx;
border: 2rpx solid #cbdced;
border-radius: 12rpx;
background: #ffffff;
color: #2b67e8;
font-size: 26rpx;
font-weight: 700;
line-height: 1;
}
.new-student-icon {
margin-right: 6rpx;
font-size: 30rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 28rpx;
padding: 70rpx 36rpx;
border: 2rpx dashed #c7ddeb;
border-radius: 16rpx;
text-align: center;
}
.compact-empty {
padding: 54rpx 36rpx;
}
.empty-icon {
color: #2b67e8;
font-size: 54rpx;
line-height: 1;
}
.empty-title {
margin-top: 18rpx;
color: #17233a;
font-size: 36rpx;
font-weight: 800;
}
.empty-description {
margin-top: 14rpx;
color: #7b8796;
font-size: 28rpx;
}
.create-empty-button {
width: 360rpx;
margin-top: 34rpx;
}
.clear-search-button {
width: 300rpx;
margin-top: 30rpx;
}
.profile-list {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-top: 22rpx;
}
.profile-card {
padding: 24rpx;
}
.profile-card-top,
.profile-time-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.profile-name {
display: block;
color: #17233a;
font-size: 34rpx;
font-weight: 800;
}
.profile-guardian {
display: block;
margin-top: 8rpx;
color: #718096;
font-size: 24rpx;
}
.delete-button {
margin: 0;
padding: 8rpx 14rpx;
border: 1rpx solid #f2c6c6;
border-radius: 10rpx;
background: #ffffff;
color: #c34343;
font-size: 22rpx;
line-height: 1.3;
}
.profile-tags {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 22rpx;
}
.profile-tag {
padding: 8rpx 12rpx;
border-radius: 8rpx;
background: #eef4ff;
color: #3563b5;
font-size: 22rpx;
line-height: 1.2;
}
.profile-time-row {
margin-top: 20rpx;
padding-top: 18rpx;
border-top: 1rpx solid #e8eef5;
}
.profile-time-label {
color: #7b8796;
font-size: 24rpx;
}
.profile-time-value {
color: #17233a;
font-size: 26rpx;
font-weight: 700;
}
.sheet-mask {
position: fixed;
z-index: 200;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: flex-end;
padding: 28rpx;
background: rgba(17, 27, 50, 0.42);
}
.create-sheet {
width: 100%;
max-height: 88vh;
overflow: hidden;
border-radius: 16rpx;
background: #ffffff;
box-shadow: 0 24rpx 56rpx rgba(17, 27, 50, 0.22);
}
.sheet-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
padding: 30rpx 28rpx 20rpx;
}
.sheet-title-row {
display: flex;
align-items: center;
gap: 10rpx;
}
.sheet-title-icon {
color: #2b67e8;
font-size: 34rpx;
}
.sheet-title {
color: #17233a;
font-size: 38rpx;
font-weight: 800;
}
.sheet-subtitle {
display: block;
margin-top: 12rpx;
color: #7b8796;
font-size: 24rpx;
}
.defaults-link {
flex: none;
margin-top: 8rpx;
color: #2b67e8;
font-size: 26rpx;
font-weight: 700;
}
.sheet-scroll {
max-height: calc(88vh - 250rpx);
padding: 0 28rpx;
}
.form-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 16rpx;
}
.form-grid .form-field {
width: calc((100% - 16rpx) / 2);
}
.form-field {
min-height: 136rpx;
padding: 22rpx;
border: 2rpx solid #d8e5f2;
border-radius: 14rpx;
background: #fbfdff;
}
.full-field {
min-height: 150rpx;
}
.form-label {
display: block;
color: #2b67e8;
font-size: 27rpx;
font-weight: 700;
}
.form-value {
display: block;
margin-top: 28rpx;
color: #17233a;
font-size: 31rpx;
font-weight: 700;
}
.form-input {
width: 100%;
height: 58rpx;
margin-top: 16rpx;
color: #17233a;
font-size: 30rpx;
font-weight: 600;
}
.guardian-field {
margin-top: 16rpx;
}
.bottom-grid {
margin-bottom: 24rpx;
}
.sheet-footer {
display: flex;
gap: 16rpx;
padding: 20rpx 28rpx 28rpx;
border-top: 1rpx solid #e7eef6;
}
.sheet-cancel,
.sheet-save {
flex: 1;
}