feat: establish teaching feedback mini program

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

219
utils/api.ts Normal file
View 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
View 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
View 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
}