From 0a07538dce2694bce50571f416f0709ef5b1a5a0 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Wed, 15 Jul 2026 15:36:51 +0800 Subject: [PATCH] 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. --- .gitignore | 23 ++ README.md | 45 ++++ app.json | 31 +++ app.ts | 5 + app.wxss | 145 ++++++++++++ custom-tab-bar/index.json | 3 + custom-tab-bar/index.ts | 22 ++ custom-tab-bar/index.wxml | 14 ++ custom-tab-bar/index.wxss | 38 ++++ package-lock.json | 37 +++ package.json | 12 + pages/defaults/index.json | 3 + pages/defaults/index.ts | 83 +++++++ pages/defaults/index.wxml | 58 +++++ pages/defaults/index.wxss | 106 +++++++++ pages/feedback/index.json | 3 + pages/feedback/index.ts | 108 +++++++++ pages/feedback/index.wxml | 46 ++++ pages/feedback/index.wxss | 57 +++++ pages/profile/index.json | 3 + pages/profile/index.ts | 54 +++++ pages/profile/index.wxml | 25 +++ pages/profile/index.wxss | 91 ++++++++ pages/records/index.json | 3 + pages/records/index.ts | 97 ++++++++ pages/records/index.wxml | 40 ++++ pages/records/index.wxss | 108 +++++++++ pages/students/index.json | 3 + pages/students/index.ts | 314 ++++++++++++++++++++++++++ pages/students/index.wxml | 161 ++++++++++++++ pages/students/index.wxss | 457 ++++++++++++++++++++++++++++++++++++++ project.config.json | 54 +++++ sitemap.json | 4 + tsconfig.json | 11 + utils/api.ts | 219 ++++++++++++++++++ utils/time.ts | 15 ++ utils/types.ts | 52 +++++ 37 files changed, 2550 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.json create mode 100644 app.ts create mode 100644 app.wxss create mode 100644 custom-tab-bar/index.json create mode 100644 custom-tab-bar/index.ts create mode 100644 custom-tab-bar/index.wxml create mode 100644 custom-tab-bar/index.wxss create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pages/defaults/index.json create mode 100644 pages/defaults/index.ts create mode 100644 pages/defaults/index.wxml create mode 100644 pages/defaults/index.wxss create mode 100644 pages/feedback/index.json create mode 100644 pages/feedback/index.ts create mode 100644 pages/feedback/index.wxml create mode 100644 pages/feedback/index.wxss create mode 100644 pages/profile/index.json create mode 100644 pages/profile/index.ts create mode 100644 pages/profile/index.wxml create mode 100644 pages/profile/index.wxss create mode 100644 pages/records/index.json create mode 100644 pages/records/index.ts create mode 100644 pages/records/index.wxml create mode 100644 pages/records/index.wxss create mode 100644 pages/students/index.json create mode 100644 pages/students/index.ts create mode 100644 pages/students/index.wxml create mode 100644 pages/students/index.wxss create mode 100644 project.config.json create mode 100644 sitemap.json create mode 100644 tsconfig.json create mode 100644 utils/api.ts create mode 100644 utils/time.ts create mode 100644 utils/types.ts 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}} + 重试 + + +