commit 0a07538dce2694bce50571f416f0709ef5b1a5a0 Author: shay7sev Date: Wed Jul 15 15:36:51 2026 +0800 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c17568 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Graphify generated knowledge graphs +graphify-out/ + +# Dependencies and build output +node_modules/ +miniprogram_npm/ +dist/ +coverage/ +target/ +*.tsbuildinfo + +# Independently versioned Rust API +/server/ + +# Local configuration and secrets +.env +.env.* +!.env.example +project.private.config.json + +# Logs and OS metadata +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..cdaf763 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# 教学反馈助手小程序 + +## 本地开发 + +1. 确认微信开发者工具登录账号拥有 AppID `wx3fe11e262a4b4885` 对应小程序的开发权限。 +2. 使用微信开发者工具命令行打开项目: + +```bash +cli open --project /Users/zhangheng/project/ai/miniprogram/teaching-feedback-assistant --lang zh +``` + +3. 在微信开发者工具内编译、预览和调试。 + +小程序默认连接 `http://127.0.0.1:8080`。在开发者工具模拟器中,可进入“我的”页面保存其他 API 地址并检测服务状态。 + +## 验证 + +```bash +npm run typecheck +``` + +项目 AppID 已配置在 `project.config.json` 中。 + +## Rust 后端 + +Rust API 位于 [`server/`](./server)。它使用 Axum 和 PostgreSQL 驱动,但不会安装、启动或自动创建本地 PostgreSQL。 + +```bash +cd server +cp .env.example .env +# 在 .env 中填写 DATABASE_URL +cargo run --bin migrate +cargo run +``` + +未设置 `DATABASE_URL` 时,仅健康检查可用,数据接口会返回 `503`。数据库配置和迁移说明参见 [server/API.md](./server/API.md)。 + +服务启动后可打开 `http://127.0.0.1:8080/scalar` 查看并直接调用 Scalar 交互文档,原始规范位于 `http://127.0.0.1:8080/openapi.json`。完整测试顺序参见 [server/API_GUIDE.md](./server/API_GUIDE.md)。 + +## 小程序与后端 + +- 学生档案、档案预设和反馈记录均通过 `utils/api.ts` 访问 Rust API。 +- 开发者工具模拟器使用默认地址 `http://127.0.0.1:8080`。 +- 真机和正式版本不能把 `127.0.0.1` 当作电脑地址;应部署可公网访问的 HTTPS API,并在微信公众平台配置 request 合法域名,然后到小程序“我的”页面修改服务地址。 +- 微信登录尚未接入。业务请求暂时使用固定开发用户 UUID;生产发布前必须改为由后端根据微信登录凭据解析用户身份。 diff --git a/app.json b/app.json new file mode 100644 index 0000000..c8815d2 --- /dev/null +++ b/app.json @@ -0,0 +1,31 @@ +{ + "pages": [ + "pages/feedback/index", + "pages/students/index", + "pages/records/index", + "pages/profile/index", + "pages/defaults/index" + ], + "window": { + "navigationBarBackgroundColor": "#F4F8FE", + "navigationBarTextStyle": "black", + "navigationBarTitleText": "教学反馈助手", + "backgroundColor": "#F4F8FE", + "backgroundTextStyle": "light" + }, + "tabBar": { + "custom": true, + "color": "#7B8796", + "selectedColor": "#2B67E8", + "backgroundColor": "#FFFFFF", + "borderStyle": "white", + "list": [ + { "pagePath": "pages/feedback/index", "text": "反馈生成" }, + { "pagePath": "pages/students/index", "text": "学生档案" }, + { "pagePath": "pages/records/index", "text": "反馈记录" }, + { "pagePath": "pages/profile/index", "text": "我的" } + ] + }, + "style": "v2", + "sitemapLocation": "sitemap.json" +} diff --git a/app.ts b/app.ts new file mode 100644 index 0000000..9173e7f --- /dev/null +++ b/app.ts @@ -0,0 +1,5 @@ +App({ + globalData: { + appName: '教学反馈助手' + } +}) diff --git a/app.wxss b/app.wxss new file mode 100644 index 0000000..d42a395 --- /dev/null +++ b/app.wxss @@ -0,0 +1,145 @@ +page { + min-height: 100%; + box-sizing: border-box; + background: #f4f8fe; + color: #17233a; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +view, +text, +input, +button { + box-sizing: border-box; +} + +button::after { + border: 0; +} + +.page-shell { + min-height: 100vh; + padding: 40rpx 32rpx 164rpx; +} + +.eyebrow { + color: #2b67e8; + font-size: 28rpx; + font-weight: 700; + line-height: 1.4; +} + +.page-heading { + display: flex; + align-items: center; + gap: 14rpx; + margin-top: 12rpx; +} + +.heading-icon { + color: #2b67e8; + font-size: 46rpx; + font-weight: 500; + line-height: 1; +} + +.page-title { + color: #111b32; + font-size: 52rpx; + font-weight: 800; + line-height: 1.2; +} + +.page-subtitle { + margin-top: 18rpx; + color: #718096; + font-size: 30rpx; + line-height: 1.55; +} + +.surface { + overflow: hidden; + border: 2rpx solid #d7e5f4; + border-radius: 16rpx; + background: #ffffff; + box-shadow: 0 8rpx 22rpx rgba(45, 78, 120, 0.08); +} + +.primary-button, +.secondary-button, +.icon-button { + display: flex; + align-items: center; + justify-content: center; + min-height: 76rpx; + border-radius: 12rpx; + font-size: 30rpx; + font-weight: 700; + line-height: 1; +} + +.primary-button { + border: 2rpx solid #2b67e8; + background: #2b67e8; + color: #ffffff; +} + +.secondary-button, +.icon-button { + border: 2rpx solid #c9dced; + background: #ffffff; + color: #2b67e8; +} + +.button-icon { + margin-right: 10rpx; + font-size: 34rpx; + font-weight: 500; +} + +.helper-text { + color: #7b8796; + font-size: 26rpx; + line-height: 1.55; +} + +.field-label { + color: #2b67e8; + font-size: 28rpx; + font-weight: 700; +} + +.field-value { + margin-top: 18rpx; + color: #17233a; + font-size: 32rpx; + font-weight: 700; +} + +.status-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20rpx; + margin-top: 24rpx; + padding: 20rpx 22rpx; + border: 2rpx solid #efc7c7; + border-radius: 12rpx; + background: #fff7f7; + color: #a83d3d; + font-size: 25rpx; + line-height: 1.45; +} + +.status-action { + flex: none; + color: #2b67e8; + font-weight: 700; +} + +.loading-state { + padding: 70rpx 32rpx; + color: #718096; + font-size: 28rpx; + text-align: center; +} diff --git a/custom-tab-bar/index.json b/custom-tab-bar/index.json new file mode 100644 index 0000000..467ce29 --- /dev/null +++ b/custom-tab-bar/index.json @@ -0,0 +1,3 @@ +{ + "component": true +} diff --git a/custom-tab-bar/index.ts b/custom-tab-bar/index.ts new file mode 100644 index 0000000..ad68e67 --- /dev/null +++ b/custom-tab-bar/index.ts @@ -0,0 +1,22 @@ +const tabItems = [ + { pagePath: '/pages/feedback/index', text: '反馈生成', glyph: '✦' }, + { pagePath: '/pages/students/index', text: '学生档案', glyph: '◎' }, + { pagePath: '/pages/records/index', text: '反馈记录', glyph: '◴' }, + { pagePath: '/pages/profile/index', text: '我的', glyph: '◉' } +] + +Component({ + data: { + selected: 0, + tabItems + }, + methods: { + switchTab(event: WechatMiniprogram.TouchEvent) { + const { index, path } = event.currentTarget.dataset as { index: number; path: string } + if (!path) return + if (index === this.data.selected) return + this.setData({ selected: index }) + wx.switchTab({ url: path }) + } + } +}) diff --git a/custom-tab-bar/index.wxml b/custom-tab-bar/index.wxml new file mode 100644 index 0000000..647f21d --- /dev/null +++ b/custom-tab-bar/index.wxml @@ -0,0 +1,14 @@ + + + {{item.glyph}} + {{item.text}} + + diff --git a/custom-tab-bar/index.wxss b/custom-tab-bar/index.wxss new file mode 100644 index 0000000..3a03f73 --- /dev/null +++ b/custom-tab-bar/index.wxss @@ -0,0 +1,38 @@ +.tabbar { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 100; + display: flex; + min-height: 112rpx; + padding-bottom: env(safe-area-inset-bottom); + border-top: 1rpx solid #edf1f6; + background: #ffffff; +} + +.tab-item { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6rpx; + min-height: 112rpx; + color: #8995a5; +} + +.tab-item-active { + color: #2b67e8; +} + +.tab-glyph { + font-size: 42rpx; + font-weight: 500; + line-height: 1; +} + +.tab-label { + font-size: 24rpx; + line-height: 1.2; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..83f13e3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "teaching-feedback-assistant", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "teaching-feedback-assistant", + "version": "0.1.0", + "devDependencies": { + "miniprogram-api-typings": "^4.0.0", + "typescript": "^5.8.3" + } + }, + "node_modules/miniprogram-api-typings": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/miniprogram-api-typings/-/miniprogram-api-typings-4.1.3.tgz", + "integrity": "sha512-5mobiXzDS3KFJMM20cIg5m7hiFSJY0YGpgm5yzFeIJ+UP15RGOtW/ztqAhRfOQIvPBD0WyYB+WFCWaeeTTEZTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dea0308 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "teaching-feedback-assistant", + "version": "0.1.0", + "private": true, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "miniprogram-api-typings": "^4.0.0", + "typescript": "^5.8.3" + } +} diff --git a/pages/defaults/index.json b/pages/defaults/index.json new file mode 100644 index 0000000..02d30dd --- /dev/null +++ b/pages/defaults/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "学生档案预设项" +} diff --git a/pages/defaults/index.ts b/pages/defaults/index.ts new file mode 100644 index 0000000..791b6e6 --- /dev/null +++ b/pages/defaults/index.ts @@ -0,0 +1,83 @@ +import { getApiErrorMessage, getProfileDefaults, updateProfileDefaults } from '../../utils/api' +import type { StudentProfileDefaults } from '../../utils/types' + +const grades = ['艺术类', '幼儿园', '一年级', '二年级', '三年级', '四年级', '五年级', '六年级'] +const subjects = ['美术', '书法', '音乐', '舞蹈', '语文', '数学', '英语'] +const initialDefaults: StudentProfileDefaults = { + grade: '艺术类', + subject: '美术', + lessonDurationMinutes: 60 +} + +type PickerEvent = { detail: { value: string | number } } +type InputEvent = { detail: { value: string } } + +function normalizeDuration(value: number): number { + if (Number.isNaN(value)) return 60 + return Math.min(360, Math.max(15, Math.round(value / 15) * 15)) +} + +Page({ + data: { + defaults: initialDefaults, + grades, + subjects, + loading: false, + saving: false, + errorMessage: '' + }, + + onShow() { + void this.loadDefaults() + }, + + async loadDefaults(showSuccess = false) { + this.setData({ loading: true, errorMessage: '' }) + try { + this.setData({ defaults: await getProfileDefaults() }) + if (showSuccess) wx.showToast({ title: '预设已刷新', icon: 'success' }) + } catch (error) { + this.setData({ errorMessage: getApiErrorMessage(error) }) + } finally { + this.setData({ loading: false }) + } + }, + + onReload() { + void this.loadDefaults(true) + }, + + onGradeChange(event: PickerEvent) { + this.setData({ 'defaults.grade': grades[Number(event.detail.value)] }) + }, + + onSubjectChange(event: PickerEvent) { + this.setData({ 'defaults.subject': subjects[Number(event.detail.value)] }) + }, + + onDurationInput(event: InputEvent) { + this.setData({ 'defaults.lessonDurationMinutes': normalizeDuration(Number(event.detail.value)) }) + }, + + onDurationChange(event: PickerEvent) { + this.setData({ 'defaults.lessonDurationMinutes': normalizeDuration(Number(event.detail.value)) }) + }, + + async save() { + const defaults = { + ...this.data.defaults, + lessonDurationMinutes: normalizeDuration(this.data.defaults.lessonDurationMinutes) + } + this.setData({ saving: true }) + try { + const saved = await updateProfileDefaults(defaults) + this.setData({ defaults: saved, errorMessage: '' }) + wx.showToast({ title: '预设项已保存', icon: 'success' }) + setTimeout(() => wx.navigateBack(), 450) + } catch (error) { + wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) + } finally { + this.setData({ saving: false }) + } + } +}) diff --git a/pages/defaults/index.wxml b/pages/defaults/index.wxml new file mode 100644 index 0000000..2c317af --- /dev/null +++ b/pages/defaults/index.wxml @@ -0,0 +1,58 @@ + + + + 档案预设 + + + 学生档案预设项 + + 用于新增学生时自动带出 + + + + + + {{errorMessage}} + 重试 + + + + + + 年级 + {{defaults.grade}} + + + 学科 + {{defaults.subject}} + + + + + 上课时长 + + + 分钟 + + + + 15 分钟 + 360 分钟 + + + + 新增学生时会按开始时间自动推算结束时间。 + + + diff --git a/pages/defaults/index.wxss b/pages/defaults/index.wxss new file mode 100644 index 0000000..5bb41bf --- /dev/null +++ b/pages/defaults/index.wxss @@ -0,0 +1,106 @@ +.defaults-page { + padding-top: 38rpx; +} + +.top-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18rpx; +} + +.top-row .page-heading { + max-width: 480rpx; +} + +.top-row .page-title { + font-size: 46rpx; +} + +.reload-button { + flex: none; + min-width: 148rpx; + min-height: 70rpx; + font-size: 26rpx; +} + +.defaults-card { + margin-top: 34rpx; + padding: 24rpx; +} + +.defaults-grid { + display: flex; + gap: 16rpx; +} + +.defaults-grid .select-field { + width: calc((100% - 16rpx) / 2); +} + +.select-field, +.duration-field { + padding: 24rpx; + border: 2rpx solid #d8e5f2; + border-radius: 14rpx; + background: #fbfdff; +} + +.select-field { + min-height: 158rpx; +} + +.duration-field { + margin-top: 16rpx; +} + +.duration-input-row { + display: flex; + align-items: center; + gap: 12rpx; + margin-top: 20rpx; +} + +.duration-input { + width: 150rpx; + height: 64rpx; + padding: 0 16rpx; + border: 2rpx solid #bed4e8; + border-radius: 12rpx; + background: #ffffff; + color: #17233a; + font-size: 38rpx; + font-weight: 800; + text-align: center; +} + +.duration-unit { + color: #65758a; + font-size: 28rpx; +} + +.duration-slider { + margin: 12rpx -12rpx 0; +} + +.duration-bounds { + display: flex; + justify-content: space-between; + color: #8a97a6; + font-size: 23rpx; +} + +.time-help { + margin-top: 18rpx; + padding: 20rpx; + border: 2rpx dashed #c6ddea; + border-radius: 12rpx; + color: #65758a; + font-size: 25rpx; + line-height: 1.5; +} + +.save-defaults { + width: 420rpx; + margin: 26rpx auto 4rpx; +} diff --git a/pages/feedback/index.json b/pages/feedback/index.json new file mode 100644 index 0000000..087ef44 --- /dev/null +++ b/pages/feedback/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "教学反馈助手" +} diff --git a/pages/feedback/index.ts b/pages/feedback/index.ts new file mode 100644 index 0000000..bf40162 --- /dev/null +++ b/pages/feedback/index.ts @@ -0,0 +1,108 @@ +import { + createFeedbackRecord, + getApiErrorMessage, + listProfiles +} from '../../utils/api' +import type { FeedbackDraft, StudentProfile } from '../../utils/types' + +type PickerEvent = { detail: { value: string | number } } +type InputEvent = { detail: { value: string } } + +function today(): string { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function buildDraft(): FeedbackDraft { + return { + profileId: null, + feedbackDate: today(), + content: '' + } +} + +Page({ + data: { + profiles: [] as StudentProfile[], + profileOptions: ['不关联学生'], + selectedProfileIndex: 0, + draft: buildDraft(), + contentLength: 0, + loading: false, + saving: false, + errorMessage: '' + }, + + onShow() { + this.getTabBar()?.setData({ selected: 0 }) + void this.loadProfiles() + }, + + async loadProfiles() { + this.setData({ loading: true, errorMessage: '' }) + try { + const profiles = await listProfiles() + const matchedProfileIndex = profiles.findIndex((profile) => profile.id === this.data.draft.profileId) + const selectedProfileIndex = matchedProfileIndex + 1 + this.setData({ + profiles, + profileOptions: ['不关联学生', ...profiles.map((profile) => `${profile.name} · ${profile.subject}`)], + selectedProfileIndex, + 'draft.profileId': matchedProfileIndex >= 0 ? profiles[matchedProfileIndex].id : null + }) + } catch (error) { + this.setData({ errorMessage: getApiErrorMessage(error) }) + } finally { + this.setData({ loading: false }) + } + }, + + retryLoad() { + void this.loadProfiles() + }, + + onProfileChange(event: PickerEvent) { + const selectedProfileIndex = Number(event.detail.value) + const profile = this.data.profiles[selectedProfileIndex - 1] + this.setData({ + selectedProfileIndex, + 'draft.profileId': profile?.id || null + }) + }, + + onDateChange(event: InputEvent) { + this.setData({ 'draft.feedbackDate': event.detail.value }) + }, + + onContentInput(event: InputEvent) { + const content = event.detail.value + this.setData({ 'draft.content': content, contentLength: content.length }) + }, + + async saveFeedback() { + const content = this.data.draft.content.trim() + if (!content) { + wx.showToast({ title: '请填写反馈内容', icon: 'none' }) + return + } + + this.setData({ saving: true }) + try { + await createFeedbackRecord({ ...this.data.draft, content }) + this.setData({ draft: buildDraft(), selectedProfileIndex: 0, contentLength: 0 }) + wx.showToast({ title: '反馈已保存', icon: 'success' }) + setTimeout(() => wx.switchTab({ url: '/pages/records/index' }), 450) + } catch (error) { + wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) + } finally { + this.setData({ saving: false }) + } + }, + + goStudents() { + wx.switchTab({ url: '/pages/students/index' }) + } +}) diff --git a/pages/feedback/index.wxml b/pages/feedback/index.wxml new file mode 100644 index 0000000..bcb447d --- /dev/null +++ b/pages/feedback/index.wxml @@ -0,0 +1,46 @@ + + 课堂反馈 + + + 反馈生成 + + 填写课堂表现并保存到教学反馈记录。 + + + {{errorMessage}} + 重试 + + +