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:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -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
|
||||
45
README.md
Normal file
45
README.md
Normal file
@@ -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;生产发布前必须改为由后端根据微信登录凭据解析用户身份。
|
||||
31
app.json
Normal file
31
app.json
Normal file
@@ -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"
|
||||
}
|
||||
145
app.wxss
Normal file
145
app.wxss
Normal file
@@ -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;
|
||||
}
|
||||
3
custom-tab-bar/index.json
Normal file
3
custom-tab-bar/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
22
custom-tab-bar/index.ts
Normal file
22
custom-tab-bar/index.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
14
custom-tab-bar/index.wxml
Normal file
14
custom-tab-bar/index.wxml
Normal file
@@ -0,0 +1,14 @@
|
||||
<view class="tabbar" role="tablist">
|
||||
<view
|
||||
wx:for="{{tabItems}}"
|
||||
wx:key="pagePath"
|
||||
class="tab-item {{selected === index ? 'tab-item-active' : ''}}"
|
||||
data-index="{{index}}"
|
||||
data-path="{{item.pagePath}}"
|
||||
bindtap="switchTab"
|
||||
role="tab"
|
||||
>
|
||||
<text class="tab-glyph">{{item.glyph}}</text>
|
||||
<text class="tab-label">{{item.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
38
custom-tab-bar/index.wxss
Normal file
38
custom-tab-bar/index.wxss
Normal file
@@ -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;
|
||||
}
|
||||
37
package-lock.json
generated
Normal file
37
package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
package.json
Normal file
12
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
3
pages/defaults/index.json
Normal file
3
pages/defaults/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "学生档案预设项"
|
||||
}
|
||||
83
pages/defaults/index.ts
Normal file
83
pages/defaults/index.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
58
pages/defaults/index.wxml
Normal file
58
pages/defaults/index.wxml
Normal file
@@ -0,0 +1,58 @@
|
||||
<view class="page-shell defaults-page">
|
||||
<view class="top-row">
|
||||
<view>
|
||||
<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>
|
||||
<button class="reload-button secondary-button" loading="{{loading}}" disabled="{{loading}}" bindtap="onReload">↻ 刷新</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{errorMessage}}" class="status-banner">
|
||||
<text>{{errorMessage}}</text>
|
||||
<text class="status-action" bindtap="onReload">重试</text>
|
||||
</view>
|
||||
|
||||
<view class="defaults-card surface">
|
||||
<view class="defaults-grid">
|
||||
<picker class="select-field" mode="selector" range="{{grades}}" bindchange="onGradeChange">
|
||||
<text class="field-label">年级</text>
|
||||
<text class="field-value">{{defaults.grade}}</text>
|
||||
</picker>
|
||||
<picker class="select-field" mode="selector" range="{{subjects}}" bindchange="onSubjectChange">
|
||||
<text class="field-label">学科</text>
|
||||
<text class="field-value">{{defaults.subject}}</text>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="duration-field">
|
||||
<text class="field-label">上课时长</text>
|
||||
<view class="duration-input-row">
|
||||
<input class="duration-input" type="number" value="{{defaults.lessonDurationMinutes}}" bindblur="onDurationInput" />
|
||||
<text class="duration-unit">分钟</text>
|
||||
</view>
|
||||
<slider
|
||||
class="duration-slider"
|
||||
min="15"
|
||||
max="360"
|
||||
step="15"
|
||||
value="{{defaults.lessonDurationMinutes}}"
|
||||
activeColor="#2B67E8"
|
||||
backgroundColor="#DBE7F5"
|
||||
blockColor="#2B67E8"
|
||||
blockSize="26"
|
||||
bindchange="onDurationChange"
|
||||
/>
|
||||
<view class="duration-bounds">
|
||||
<text>15 分钟</text>
|
||||
<text>360 分钟</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="time-help">新增学生时会按开始时间自动推算结束时间。</view>
|
||||
<button class="save-defaults primary-button" loading="{{saving}}" disabled="{{saving || loading}}" bindtap="save">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
106
pages/defaults/index.wxss
Normal file
106
pages/defaults/index.wxss
Normal file
@@ -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;
|
||||
}
|
||||
3
pages/feedback/index.json
Normal file
3
pages/feedback/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "教学反馈助手"
|
||||
}
|
||||
108
pages/feedback/index.ts
Normal file
108
pages/feedback/index.ts
Normal file
@@ -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' })
|
||||
}
|
||||
})
|
||||
46
pages/feedback/index.wxml
Normal file
46
pages/feedback/index.wxml
Normal file
@@ -0,0 +1,46 @@
|
||||
<view class="page-shell feedback-page">
|
||||
<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="feedback-form surface">
|
||||
<picker class="feedback-field" mode="selector" range="{{profileOptions}}" value="{{selectedProfileIndex}}" disabled="{{loading}}" bindchange="onProfileChange">
|
||||
<text class="form-label">学生档案</text>
|
||||
<text class="form-value">{{loading ? '正在加载...' : profileOptions[selectedProfileIndex]}}</text>
|
||||
</picker>
|
||||
|
||||
<picker class="feedback-field" mode="date" value="{{draft.feedbackDate}}" bindchange="onDateChange">
|
||||
<text class="form-label">反馈日期</text>
|
||||
<text class="form-value">{{draft.feedbackDate}}</text>
|
||||
</picker>
|
||||
|
||||
<view class="content-field">
|
||||
<view class="content-label-row">
|
||||
<text class="form-label">反馈内容</text>
|
||||
<text class="content-count">{{contentLength}} / 2000</text>
|
||||
</view>
|
||||
<textarea
|
||||
class="feedback-textarea"
|
||||
value="{{draft.content}}"
|
||||
maxlength="2000"
|
||||
placeholder="记录课堂完成情况、进步与后续建议"
|
||||
bindinput="onContentInput"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<button class="save-feedback primary-button" loading="{{saving}}" disabled="{{saving}}" bindtap="saveFeedback">保存反馈</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!loading && profiles.length === 0}}" class="profiles-hint">
|
||||
<text>当前没有学生档案,反馈仍可不关联学生直接保存。</text>
|
||||
<text class="status-action" bindtap="goStudents">新建档案</text>
|
||||
</view>
|
||||
</view>
|
||||
57
pages/feedback/index.wxss
Normal file
57
pages/feedback/index.wxss
Normal file
@@ -0,0 +1,57 @@
|
||||
.feedback-page {
|
||||
padding-top: 44rpx;
|
||||
}
|
||||
|
||||
.feedback-form {
|
||||
margin-top: 32rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.feedback-field,
|
||||
.content-field {
|
||||
padding: 22rpx;
|
||||
border: 2rpx solid #d8e5f2;
|
||||
border-radius: 14rpx;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.feedback-field + .feedback-field,
|
||||
.content-field {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.content-label-row,
|
||||
.profiles-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.content-count {
|
||||
color: #8a97a6;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.feedback-textarea {
|
||||
width: 100%;
|
||||
min-height: 300rpx;
|
||||
margin-top: 18rpx;
|
||||
color: #17233a;
|
||||
font-size: 29rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.save-feedback {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.profiles-hint {
|
||||
margin-top: 22rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
border: 2rpx dashed #c7ddeb;
|
||||
border-radius: 12rpx;
|
||||
color: #65758a;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
3
pages/profile/index.json
Normal file
3
pages/profile/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "教学反馈助手"
|
||||
}
|
||||
54
pages/profile/index.ts
Normal file
54
pages/profile/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
DEVELOPMENT_USER_ID,
|
||||
getApiBaseUrl,
|
||||
getApiErrorMessage,
|
||||
getHealth,
|
||||
setApiBaseUrl
|
||||
} from '../../utils/api'
|
||||
|
||||
type InputEvent = { detail: { value: string } }
|
||||
|
||||
Page({
|
||||
data: {
|
||||
apiBaseUrl: getApiBaseUrl(),
|
||||
developmentUserId: DEVELOPMENT_USER_ID,
|
||||
connectionText: '尚未检测',
|
||||
connectionTone: 'idle',
|
||||
testing: false
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.getTabBar()?.setData({ selected: 3 })
|
||||
this.setData({ apiBaseUrl: getApiBaseUrl() })
|
||||
void this.checkConnection()
|
||||
},
|
||||
|
||||
onUrlInput(event: InputEvent) {
|
||||
this.setData({ apiBaseUrl: event.detail.value })
|
||||
},
|
||||
|
||||
async saveAndCheck() {
|
||||
try {
|
||||
const apiBaseUrl = setApiBaseUrl(this.data.apiBaseUrl)
|
||||
this.setData({ apiBaseUrl })
|
||||
await this.checkConnection()
|
||||
} catch (error) {
|
||||
this.setData({ connectionText: getApiErrorMessage(error), connectionTone: 'error' })
|
||||
}
|
||||
},
|
||||
|
||||
async checkConnection() {
|
||||
this.setData({ testing: true, connectionText: '正在检测服务...', connectionTone: 'idle' })
|
||||
try {
|
||||
const health = await getHealth()
|
||||
this.setData({
|
||||
connectionText: health.databaseConfigured ? '服务和数据库连接正常' : '服务正常,数据库尚未配置',
|
||||
connectionTone: health.databaseConfigured ? 'success' : 'warning'
|
||||
})
|
||||
} catch (error) {
|
||||
this.setData({ connectionText: getApiErrorMessage(error), connectionTone: 'error' })
|
||||
} finally {
|
||||
this.setData({ testing: false })
|
||||
}
|
||||
}
|
||||
})
|
||||
25
pages/profile/index.wxml
Normal file
25
pages/profile/index.wxml
Normal file
@@ -0,0 +1,25 @@
|
||||
<view class="page-shell profile-page">
|
||||
<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 class="server-card surface">
|
||||
<text class="section-title">Rust API 服务</text>
|
||||
<text class="form-label url-label">服务地址</text>
|
||||
<input class="url-input" value="{{apiBaseUrl}}" placeholder="https://api.example.com" bindinput="onUrlInput" />
|
||||
<view class="connection-row">
|
||||
<text class="connection-dot connection-{{connectionTone}}"></text>
|
||||
<text class="connection-text">{{connectionText}}</text>
|
||||
</view>
|
||||
<button class="primary-button check-button" loading="{{testing}}" disabled="{{testing}}" bindtap="saveAndCheck">保存并检测</button>
|
||||
</view>
|
||||
|
||||
<view class="identity-card surface">
|
||||
<text class="section-title">开发身份</text>
|
||||
<text class="identity-value">{{developmentUserId}}</text>
|
||||
<text class="identity-note">微信登录接入前,业务数据按此开发用户隔离。</text>
|
||||
</view>
|
||||
</view>
|
||||
91
pages/profile/index.wxss
Normal file
91
pages/profile/index.wxss
Normal file
@@ -0,0 +1,91 @@
|
||||
.profile-page {
|
||||
padding-top: 44rpx;
|
||||
}
|
||||
|
||||
.server-card,
|
||||
.identity-card {
|
||||
margin-top: 30rpx;
|
||||
padding: 26rpx;
|
||||
}
|
||||
|
||||
.identity-card {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.identity-value,
|
||||
.identity-note {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #17233a;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.url-label {
|
||||
display: block;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
height: 78rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 0 20rpx;
|
||||
border: 2rpx solid #cbdced;
|
||||
border-radius: 12rpx;
|
||||
background: #fbfdff;
|
||||
color: #17233a;
|
||||
font-size: 27rpx;
|
||||
}
|
||||
|
||||
.connection-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: #8a97a6;
|
||||
}
|
||||
|
||||
.connection-success {
|
||||
background: #23855b;
|
||||
}
|
||||
|
||||
.connection-warning {
|
||||
background: #c17a18;
|
||||
}
|
||||
|
||||
.connection-error {
|
||||
background: #c34343;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
color: #566577;
|
||||
font-size: 25rpx;
|
||||
}
|
||||
|
||||
.check-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.identity-value {
|
||||
margin-top: 18rpx;
|
||||
color: #2b67e8;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.identity-note {
|
||||
margin-top: 12rpx;
|
||||
color: #718096;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
3
pages/records/index.json
Normal file
3
pages/records/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "教学反馈助手"
|
||||
}
|
||||
97
pages/records/index.ts
Normal file
97
pages/records/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
deleteFeedbackRecord,
|
||||
getApiErrorMessage,
|
||||
listFeedbackRecords,
|
||||
listProfiles
|
||||
} from '../../utils/api'
|
||||
import type { FeedbackRecord, StudentProfile } from '../../utils/types'
|
||||
|
||||
type PickerEvent = { detail: { value: string | number } }
|
||||
type FeedbackRecordView = FeedbackRecord & { profileName: string }
|
||||
|
||||
Page({
|
||||
data: {
|
||||
profiles: [] as StudentProfile[],
|
||||
records: [] as FeedbackRecordView[],
|
||||
profileOptions: ['全部学生'],
|
||||
selectedProfileIndex: 0,
|
||||
loading: false,
|
||||
deletingId: '',
|
||||
errorMessage: ''
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.getTabBar()?.setData({ selected: 2 })
|
||||
void this.loadData()
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
try {
|
||||
const profiles = await listProfiles()
|
||||
const selectedProfile = profiles[this.data.selectedProfileIndex - 1]
|
||||
const records = await listFeedbackRecords(selectedProfile?.id)
|
||||
this.setData({
|
||||
profiles,
|
||||
profileOptions: ['全部学生', ...profiles.map((profile) => profile.name)],
|
||||
selectedProfileIndex: selectedProfile ? this.data.selectedProfileIndex : 0,
|
||||
records: this.withProfileNames(records, profiles)
|
||||
})
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: getApiErrorMessage(error) })
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
withProfileNames(records: FeedbackRecord[], profiles: StudentProfile[]): FeedbackRecordView[] {
|
||||
return records.map((record) => ({
|
||||
...record,
|
||||
profileName: profiles.find((profile) => profile.id === record.profileId)?.name || '未关联学生'
|
||||
}))
|
||||
},
|
||||
|
||||
retryLoad() {
|
||||
void this.loadData()
|
||||
},
|
||||
|
||||
async onProfileChange(event: PickerEvent) {
|
||||
const selectedProfileIndex = Number(event.detail.value)
|
||||
const profile = this.data.profiles[selectedProfileIndex - 1]
|
||||
this.setData({ selectedProfileIndex, loading: true, errorMessage: '' })
|
||||
try {
|
||||
const records = await listFeedbackRecords(profile?.id)
|
||||
this.setData({ records: this.withProfileNames(records, this.data.profiles) })
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: getApiErrorMessage(error) })
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
deleteRecord(event: WechatMiniprogram.TouchEvent) {
|
||||
const { id } = event.currentTarget.dataset as { id: string }
|
||||
wx.showModal({
|
||||
title: '删除反馈记录',
|
||||
content: '删除后无法恢复,确认继续吗?',
|
||||
confirmColor: '#D14343',
|
||||
success: async (result) => {
|
||||
if (!result.confirm) return
|
||||
this.setData({ deletingId: id })
|
||||
try {
|
||||
await deleteFeedbackRecord(id)
|
||||
this.setData({ records: this.data.records.filter((record) => record.id !== id) })
|
||||
wx.showToast({ title: '记录已删除', icon: 'success' })
|
||||
} catch (error) {
|
||||
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
|
||||
} finally {
|
||||
this.setData({ deletingId: '' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
goFeedback() {
|
||||
wx.switchTab({ url: '/pages/feedback/index' })
|
||||
}
|
||||
})
|
||||
40
pages/records/index.wxml
Normal file
40
pages/records/index.wxml
Normal file
@@ -0,0 +1,40 @@
|
||||
<view class="page-shell records-page">
|
||||
<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>
|
||||
|
||||
<picker class="records-filter surface" mode="selector" range="{{profileOptions}}" value="{{selectedProfileIndex}}" disabled="{{loading}}" bindchange="onProfileChange">
|
||||
<text class="filter-label">筛选学生</text>
|
||||
<text class="filter-value">{{profileOptions[selectedProfileIndex]}}</text>
|
||||
</picker>
|
||||
|
||||
<view wx:if="{{loading && records.length === 0}}" class="loading-state">正在读取反馈记录...</view>
|
||||
|
||||
<view wx:elif="{{records.length === 0}}" class="records-empty surface">
|
||||
<text class="empty-icon">◴</text>
|
||||
<text class="empty-title">暂无反馈记录</text>
|
||||
<text class="helper-text">保存课堂反馈后,记录会显示在这里。</text>
|
||||
<button class="primary-button empty-action" bindtap="goFeedback">填写反馈</button>
|
||||
</view>
|
||||
|
||||
<view wx:else class="record-list">
|
||||
<view wx:for="{{records}}" wx:key="id" class="record-card surface">
|
||||
<view class="record-top">
|
||||
<view>
|
||||
<text class="record-profile">{{item.profileName}}</text>
|
||||
<text class="record-date">{{item.feedbackDate}}</text>
|
||||
</view>
|
||||
<button class="delete-button" loading="{{deletingId === item.id}}" disabled="{{deletingId === item.id}}" data-id="{{item.id}}" bindtap="deleteRecord">删除</button>
|
||||
</view>
|
||||
<text class="record-content">{{item.content}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
108
pages/records/index.wxss
Normal file
108
pages/records/index.wxss
Normal file
@@ -0,0 +1,108 @@
|
||||
.records-page {
|
||||
padding-top: 44rpx;
|
||||
}
|
||||
|
||||
.records-filter {
|
||||
margin-top: 30rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
}
|
||||
|
||||
.filter-label,
|
||||
.filter-value {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: #718096;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.filter-value {
|
||||
margin-top: 8rpx;
|
||||
color: #17233a;
|
||||
font-size: 29rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.records-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 24rpx;
|
||||
padding: 64rpx 36rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: #2b67e8;
|
||||
font-size: 56rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin: 20rpx 0 12rpx;
|
||||
color: #17233a;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
width: 280rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.record-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.record-profile,
|
||||
.record-date,
|
||||
.record-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.record-profile {
|
||||
color: #17233a;
|
||||
font-size: 31rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
margin-top: 6rpx;
|
||||
color: #718096;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.record-content {
|
||||
margin-top: 20rpx;
|
||||
padding-top: 18rpx;
|
||||
border-top: 1rpx solid #e8eef5;
|
||||
color: #334258;
|
||||
font-size: 27rpx;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
3
pages/students/index.json
Normal file
3
pages/students/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "教学反馈助手"
|
||||
}
|
||||
314
pages/students/index.ts
Normal file
314
pages/students/index.ts
Normal 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
161
pages/students/index.wxml
Normal 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
457
pages/students/index.wxss
Normal 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;
|
||||
}
|
||||
54
project.config.json
Normal file
54
project.config.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.9.12",
|
||||
"miniprogramRoot": "./",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"enhance": true,
|
||||
"postcss": true,
|
||||
"minified": true,
|
||||
"ignoreDevUnusedFiles": false,
|
||||
"ignoreUploadUnusedFiles": false,
|
||||
"minifyWXML": true,
|
||||
"useCompilerPlugins": [
|
||||
"typescript"
|
||||
],
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
}
|
||||
},
|
||||
"packOptions": {
|
||||
"ignore": [
|
||||
{
|
||||
"value": "server",
|
||||
"type": "folder"
|
||||
},
|
||||
{
|
||||
"value": "node_modules",
|
||||
"type": "folder"
|
||||
},
|
||||
{
|
||||
"value": "graphify-out",
|
||||
"type": "folder"
|
||||
}
|
||||
],
|
||||
"include": []
|
||||
},
|
||||
"appid": "wx3fe11e262a4b4885",
|
||||
"simulatorPluginLibVersion": {},
|
||||
"editorSetting": {}
|
||||
}
|
||||
4
sitemap.json
Normal file
4
sitemap.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"desc": "教学反馈助手页面索引",
|
||||
"rules": [{ "action": "allow", "page": "*" }]
|
||||
}
|
||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["miniprogram-api-typings"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
219
utils/api.ts
Normal file
219
utils/api.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type {
|
||||
FeedbackDraft,
|
||||
FeedbackRecord,
|
||||
HealthStatus,
|
||||
StudentDraft,
|
||||
StudentProfile,
|
||||
StudentProfileDefaults,
|
||||
Term
|
||||
} from './types'
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'http://127.0.0.1:8080'
|
||||
const API_BASE_URL_KEY = 'teaching-feedback-api-base-url-v1'
|
||||
|
||||
export const DEVELOPMENT_USER_ID = '5a4da7f0-d70c-465f-bcab-124c504aa9f0'
|
||||
|
||||
interface ApiErrorBody {
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface RawStudentProfile {
|
||||
id: string
|
||||
name: string
|
||||
grade: string
|
||||
subject: string
|
||||
academic_year: number
|
||||
term: Term
|
||||
guardian_title: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface RawProfileDefaults {
|
||||
grade: string
|
||||
subject: string
|
||||
lesson_duration_minutes: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface RawFeedbackRecord {
|
||||
id: string
|
||||
profile_id: string | null
|
||||
feedback_date: string
|
||||
content: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface RawHealthStatus {
|
||||
status: string
|
||||
database_configured: boolean
|
||||
}
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode?: number
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return (wx.getStorageSync(API_BASE_URL_KEY) as string) || DEFAULT_API_BASE_URL
|
||||
}
|
||||
|
||||
export function setApiBaseUrl(value: string): string {
|
||||
const normalized = value.trim().replace(/\/+$/, '')
|
||||
if (!/^https?:\/\/[^\s]+$/i.test(normalized)) {
|
||||
throw new ApiRequestError('服务地址必须以 http:// 或 https:// 开头')
|
||||
}
|
||||
wx.setStorageSync(API_BASE_URL_KEY, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof ApiRequestError)) return '请求失败,请稍后重试'
|
||||
if (error.statusCode === 401) return '开发身份未通过后端校验'
|
||||
if (error.statusCode === 404) return '请求的数据不存在或无权访问'
|
||||
if (error.statusCode === 503) return '后端尚未配置数据库连接'
|
||||
return error.message
|
||||
}
|
||||
|
||||
function request<T>(path: string, method: HttpMethod = 'GET', data?: object): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url: `${getApiBaseUrl()}${path}`,
|
||||
method,
|
||||
data,
|
||||
timeout: 10000,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': DEVELOPMENT_USER_ID
|
||||
},
|
||||
success(response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
resolve(response.data as T)
|
||||
return
|
||||
}
|
||||
|
||||
const body = response.data as ApiErrorBody
|
||||
reject(new ApiRequestError(body?.error || `服务返回 HTTP ${response.statusCode}`, response.statusCode))
|
||||
},
|
||||
fail(error) {
|
||||
reject(new ApiRequestError(`无法连接后端:${error.errMsg}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string): number {
|
||||
const timestamp = Date.parse(value)
|
||||
return Number.isNaN(timestamp) ? Date.now() : timestamp
|
||||
}
|
||||
|
||||
function mapProfile(profile: RawStudentProfile): StudentProfile {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
grade: profile.grade,
|
||||
subject: profile.subject,
|
||||
academicYear: profile.academic_year,
|
||||
term: profile.term,
|
||||
guardianTitle: profile.guardian_title,
|
||||
startTime: profile.start_time,
|
||||
endTime: profile.end_time,
|
||||
createdAt: parseTimestamp(profile.created_at),
|
||||
updatedAt: parseTimestamp(profile.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
function profileInput(profile: StudentDraft): object {
|
||||
return {
|
||||
name: profile.name,
|
||||
grade: profile.grade,
|
||||
subject: profile.subject,
|
||||
academic_year: profile.academicYear,
|
||||
term: profile.term,
|
||||
guardian_title: profile.guardianTitle,
|
||||
start_time: profile.startTime,
|
||||
end_time: profile.endTime
|
||||
}
|
||||
}
|
||||
|
||||
function mapDefaults(defaults: RawProfileDefaults): StudentProfileDefaults {
|
||||
return {
|
||||
grade: defaults.grade,
|
||||
subject: defaults.subject,
|
||||
lessonDurationMinutes: defaults.lesson_duration_minutes
|
||||
}
|
||||
}
|
||||
|
||||
function mapFeedbackRecord(record: RawFeedbackRecord): FeedbackRecord {
|
||||
return {
|
||||
id: record.id,
|
||||
profileId: record.profile_id,
|
||||
feedbackDate: record.feedback_date,
|
||||
content: record.content,
|
||||
createdAt: parseTimestamp(record.created_at),
|
||||
updatedAt: parseTimestamp(record.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHealth(): Promise<HealthStatus> {
|
||||
const result = await request<RawHealthStatus>('/health')
|
||||
return {
|
||||
status: result.status,
|
||||
databaseConfigured: result.database_configured
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProfiles(): Promise<StudentProfile[]> {
|
||||
return (await request<RawStudentProfile[]>('/api/v1/profiles')).map(mapProfile)
|
||||
}
|
||||
|
||||
export async function createProfile(profile: StudentDraft): Promise<StudentProfile> {
|
||||
return mapProfile(await request<RawStudentProfile>('/api/v1/profiles', 'POST', profileInput(profile)))
|
||||
}
|
||||
|
||||
export async function deleteProfile(profileId: string): Promise<void> {
|
||||
await request<unknown>(`/api/v1/profiles/${encodeURIComponent(profileId)}`, 'DELETE')
|
||||
}
|
||||
|
||||
export async function getProfileDefaults(): Promise<StudentProfileDefaults> {
|
||||
return mapDefaults(await request<RawProfileDefaults>('/api/v1/profile-defaults'))
|
||||
}
|
||||
|
||||
export async function updateProfileDefaults(
|
||||
defaults: StudentProfileDefaults
|
||||
): Promise<StudentProfileDefaults> {
|
||||
const result = await request<RawProfileDefaults>('/api/v1/profile-defaults', 'PUT', {
|
||||
grade: defaults.grade,
|
||||
subject: defaults.subject,
|
||||
lesson_duration_minutes: defaults.lessonDurationMinutes
|
||||
})
|
||||
return mapDefaults(result)
|
||||
}
|
||||
|
||||
export async function listFeedbackRecords(profileId?: string): Promise<FeedbackRecord[]> {
|
||||
const query = profileId ? `?profile_id=${encodeURIComponent(profileId)}` : ''
|
||||
return (await request<RawFeedbackRecord[]>(`/api/v1/feedback-records${query}`)).map(mapFeedbackRecord)
|
||||
}
|
||||
|
||||
export async function createFeedbackRecord(draft: FeedbackDraft): Promise<FeedbackRecord> {
|
||||
const result = await request<RawFeedbackRecord>('/api/v1/feedback-records', 'POST', {
|
||||
profile_id: draft.profileId,
|
||||
feedback_date: draft.feedbackDate,
|
||||
content: draft.content
|
||||
})
|
||||
return mapFeedbackRecord(result)
|
||||
}
|
||||
|
||||
export async function deleteFeedbackRecord(recordId: string): Promise<void> {
|
||||
await request<unknown>(`/api/v1/feedback-records/${encodeURIComponent(recordId)}`, 'DELETE')
|
||||
}
|
||||
15
utils/time.ts
Normal file
15
utils/time.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function addMinutes(time: string, minutes: number): string {
|
||||
const [hours, mins] = time.split(':').map(Number)
|
||||
const total = (hours * 60 + mins + minutes) % (24 * 60)
|
||||
const nextHours = Math.floor(total / 60)
|
||||
const nextMinutes = total % 60
|
||||
|
||||
return `${String(nextHours).padStart(2, '0')}:${String(nextMinutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatUpdatedAt(timestamp: number): string {
|
||||
const date = new Date(timestamp)
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
52
utils/types.ts
Normal file
52
utils/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type Term = '春' | '暑' | '秋' | '寒'
|
||||
|
||||
export interface StudentProfile {
|
||||
id: string
|
||||
name: string
|
||||
grade: string
|
||||
subject: string
|
||||
academicYear: number
|
||||
term: Term
|
||||
guardianTitle: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface StudentProfileDefaults {
|
||||
grade: string
|
||||
subject: string
|
||||
lessonDurationMinutes: number
|
||||
}
|
||||
|
||||
export interface StudentDraft {
|
||||
name: string
|
||||
grade: string
|
||||
subject: string
|
||||
academicYear: number
|
||||
term: Term
|
||||
guardianTitle: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
export interface FeedbackRecord {
|
||||
id: string
|
||||
profileId: string | null
|
||||
feedbackDate: string
|
||||
content: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface FeedbackDraft {
|
||||
profileId: string | null
|
||||
feedbackDate: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
status: string
|
||||
databaseConfigured: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user