diff --git a/miniapp/package.json b/miniapp/package.json index 1a48f442..7191cf6b 100644 --- a/miniapp/package.json +++ b/miniapp/package.json @@ -70,6 +70,7 @@ "react-native-linear-gradient": "^2.8.3", "react-native-safe-area-context": "^5.9.1", "react-native-screens": "^4.27.0", + "xlsx": "0.18.5", "zustand": "^5.0.15" }, "devDependencies": { diff --git a/miniapp/pnpm-lock.yaml b/miniapp/pnpm-lock.yaml index b53df463..994df6a0 100644 --- a/miniapp/pnpm-lock.yaml +++ b/miniapp/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: react-native-screens: specifier: ^4.27.0 version: 4.27.0(react-native@0.73.11(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(react@18.2.0))(react@18.2.0) + xlsx: + specifier: 0.18.5 + version: 0.18.5 zustand: specifier: ^5.0.15 version: 5.0.15(@types/react@18.3.31)(react@18.2.0) @@ -2907,6 +2910,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + adm-zip@0.5.18: resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} engines: {node: '>=12.0'} @@ -3427,6 +3434,10 @@ packages: resolution: {integrity: sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==} engines: {node: '>=4'} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -3539,6 +3550,10 @@ packages: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + collection-visit@1.0.0: resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==} engines: {node: '>=0.10.0'} @@ -3674,6 +3689,11 @@ packages: typescript: optional: true + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -4516,6 +4536,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -7259,6 +7283,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + ssri@8.0.1: resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} engines: {node: '>= 8'} @@ -7961,6 +7989,10 @@ packages: wildcard@2.0.1: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + wonka@4.0.15: resolution: {integrity: sha512-U0IUQHKXXn6PFo9nqsHphVCE5m3IntqZNB9Jjn7EB1lrR7YTDY3YWgFvEvwniTzXSvOH/XMzAZaIfJF/LvHYXg==} @@ -7968,6 +8000,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -8033,6 +8069,11 @@ packages: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -11584,6 +11625,8 @@ snapshots: acorn@8.18.0: {} + adler-32@1.3.1: {} + adm-zip@0.5.18: {} agent-base@6.0.2: @@ -12278,6 +12321,11 @@ snapshots: tunnel-agent: 0.6.0 url-to-options: 1.0.1 + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -12427,6 +12475,8 @@ snapshots: clone@2.1.2: {} + codepage@1.15.0: {} + collection-visit@1.0.0: dependencies: map-visit: 1.0.0 @@ -12560,6 +12610,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crc-32@1.2.2: {} + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -13682,6 +13734,8 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + frac@1.1.2: {} + fraction.js@5.3.4: {} fragment-cache@0.2.1: @@ -16761,6 +16815,10 @@ snapshots: sprintf-js@1.0.3: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + ssri@8.0.1: dependencies: minipass: 3.3.6 @@ -17544,10 +17602,14 @@ snapshots: wildcard@2.0.1: {} + wmf@1.0.2: {} + wonka@4.0.15: {} word-wrap@1.2.5: {} + word@0.3.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -17597,6 +17659,16 @@ snapshots: simple-plist: 1.3.1 uuid: 7.0.3 + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + xml2js@0.6.0: dependencies: sax: 1.2.4 diff --git a/miniapp/project.config.json b/miniapp/project.config.json index 306dddcf..683276c7 100644 --- a/miniapp/project.config.json +++ b/miniapp/project.config.json @@ -1,25 +1,49 @@ { - "miniprogramRoot": "dist/", - "projectname": "miniapp", - "description": "miniapp", - "appid": "wxed6ad84ef3fe585b", - "setting": { - "urlCheck": false, - "es6": false, + "miniprogramRoot": "dist/", + "projectname": "miniapp", + "description": "miniapp", + "appid": "wxed6ad84ef3fe585b", + "setting": { + "urlCheck": false, + "es6": false, "enhance": false, "compileHotReLoad": false, - "postcss": false, - "preloadBackgroundData": false, - "minified": false, - "newFeature": true, - "autoAudits": false, - "coverView": true, - "showShadowRootInWxmlPanel": false, - "scopeDataCheck": false, - "useCompilerModule": false - }, - "compileType": "miniprogram", - "simulatorType": "wechat", - "simulatorPluginLibVersion": {}, - "condition": {} -} + "postcss": false, + "preloadBackgroundData": false, + "minified": false, + "newFeature": true, + "autoAudits": false, + "coverView": true, + "showShadowRootInWxmlPanel": false, + "scopeDataCheck": false, + "useCompilerModule": false, + "compileWorklet": false, + "uglifyFileName": false, + "uploadWithSourceMap": true, + "packNpmManually": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "minifyWXML": true, + "localPlugins": false, + "disableUseStrict": false, + "useCompilerPlugins": false, + "condition": false, + "swc": false, + "disableSWC": true, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + } + }, + "compileType": "miniprogram", + "simulatorType": "wechat", + "simulatorPluginLibVersion": {}, + "condition": {}, + "packOptions": { + "ignore": [], + "include": [] + }, + "editorSetting": {}, + "libVersion": "3.17.3" +} \ No newline at end of file diff --git a/miniapp/src/api/announcements.ts b/miniapp/src/api/announcements.ts new file mode 100644 index 00000000..7b6cbf81 --- /dev/null +++ b/miniapp/src/api/announcements.ts @@ -0,0 +1,6 @@ +import { get, post } from '../utils/request' +import type { ApiResponse } from '../types/api' +export type Announcement = { id: string | number; title: string; content: string; status?: string; publishedAt?: string; priority?: number; read?: boolean; createdAt?: string } +export function listAnnouncements(params?: { page?: number; pageSize?: number }): Promise> { return get('/announcements', { page: params?.page ?? 1, pageSize: params?.pageSize ?? 20 } as any) } +export function getPendingAnnouncementPopup(): Promise> { return get('/announcements/pending-popup') } +export function markAnnouncementRead(id: string | number): Promise> { return post(`/announcements/${id}/read`) } diff --git a/miniapp/src/api/auth.ts b/miniapp/src/api/auth.ts index 3b62a82e..e5bdfdd7 100644 --- a/miniapp/src/api/auth.ts +++ b/miniapp/src/api/auth.ts @@ -22,6 +22,7 @@ export interface CaptchaResponse { export interface User { id?: string | number + ID?: number email: string account?: string displayName?: string @@ -31,6 +32,7 @@ export interface User { gender?: string city?: string region?: string + extra?: string timezone: string avatar?: string role?: 'user' | 'admin' @@ -38,10 +40,17 @@ export interface User { updatedAt: string lastLogin: string loginCount?: number + lastPasswordChange?: string + profileComplete?: number + streakDays?: number hasFilledDetails: boolean emailNotifications: boolean - emailVerified?: boolean + pushNotifications?: boolean + systemNotifications?: boolean + autoCleanUnreadEmails?: boolean + reviewCurvePreset?: 'times3' | 'times5' | 'times7' | 'times10' | 'standard' | 'interval3' | 'interval5' | 'interval10' twoFactorEnabled?: boolean + emailVerified?: boolean } export interface LoginResponseData { diff --git a/miniapp/src/api/checkin.ts b/miniapp/src/api/checkin.ts new file mode 100644 index 00000000..9b5ff002 --- /dev/null +++ b/miniapp/src/api/checkin.ts @@ -0,0 +1,40 @@ +import { get, post } from '../utils/request' +import type { ApiResponse } from '../types/api' + +export type CheckInRewardTier = { days: number; minutes: number } + +export type CheckInStatus = { + checkedInToday: boolean + currentStreak: number + longestStreak: number + yearCheckIns: number + dailyReward: number + nextStreakBonusDays?: number | null + nextStreakBonusMinutes?: number | null + poolRemainingMinutes: number + monthMask: boolean[] + monthStartWeekday: number + recentMask: boolean[] + recentStartWeekday: number + recentDays: number + recentStartDate: string + rewardPreview?: CheckInRewardTier[] +} + +export type CheckInResult = { + alreadyCheckedIn: boolean + grantedMinutes: number + dailyMinutes: number + bonusMinutes: number + currentStreak: number + longestStreak: number + poolRemainingMinutes: number +} + +export function getCheckInStatus(): Promise> { + return get('/teacher/checkin') +} + +export function postCheckIn(): Promise> { + return post('/teacher/checkin') +} diff --git a/miniapp/src/api/cloze.ts b/miniapp/src/api/cloze.ts new file mode 100644 index 00000000..41ac4310 --- /dev/null +++ b/miniapp/src/api/cloze.ts @@ -0,0 +1,127 @@ +/** + * 完形填空 API — 对齐 web/src/api/cloze.ts + customCloze.ts + */ +import { get, post } from '../utils/request' +import type { ApiResponse } from '../types/api' + +export type ClozeOption = { key: string; text: string } + +export type ClozePassageListItem = { + id: number + title: string + level: string + tags?: string + summary?: string + blankCount?: number + estimatedMinutes?: number + lastScore?: number + lastCorrectCount?: number + lastBlankCount?: number + lastCompletedAt?: string +} + +export type ClozeBlankView = { + id: number + blankNo: number + options: ClozeOption[] +} + +export type ClozePassageDetail = { + id: number + title: string + level: string + content: string + summary?: string + blankCount?: number + estimatedMinutes?: number + blanks: ClozeBlankView[] +} + +export type ClozeAnswerDetail = { + blankId: number + blankNo: number + answer: string + correct: boolean + rightAnswer?: string + explanation?: string +} + +export type ClozeSubmitResult = { + recordId: number + passageId: number + title: string + level: string + blankCount: number + correctCount: number + score: number + durationSec: number + completedAt?: string + details: ClozeAnswerDetail[] +} + +export function listClozePassages(params?: { + level?: string + tag?: string + keyword?: string + cursor?: string + limit?: number +}): Promise> { + return get('/cloze/passages', params as any) +} + +export function listClozeTags(): Promise> { + return get('/cloze/tags') +} + +export function getClozePassage(id: number): Promise> { + return get(`/cloze/passages/${id}`) +} + +export function submitClozePassage( + id: number, + data: { answers: Array<{ blankId: number; answer: string }>; durationSec?: number } +): Promise> { + return post(`/cloze/passages/${id}/submit`, data) +} + +/* ============ 自定义完形填空 ============ */ + +export function listCustomClozePassages(params?: { + level?: string + keyword?: string + cursor?: string + limit?: number +}): Promise> { + return get('/cloze/custom/passages', params as any) +} + +export function getCustomClozePassage(id: number): Promise> { + return get(`/cloze/custom/passages/${id}`) +} + +export function submitCustomClozePassage( + id: number, + data: { answers: Array<{ blankId: number; answer: string }>; durationSec?: number } +): Promise> { + return post(`/cloze/custom/passages/${id}/submit`, data) +} + +export type CustomClozeBlankInput = { + blankNo: number + options: ClozeOption[] + answer: string + explanation?: string +} + +export type CustomClozePayload = { + title: string + level?: string + content: string + summary?: string + estimatedMinutes?: number + blanks: CustomClozeBlankInput[] +} + +export function createCustomClozePassage(payload: CustomClozePayload): Promise> { + return post('/cloze/custom/passages', payload) +} diff --git a/miniapp/src/api/coaching.ts b/miniapp/src/api/coaching.ts index a83bafcb..58ec7cb1 100644 --- a/miniapp/src/api/coaching.ts +++ b/miniapp/src/api/coaching.ts @@ -1,7 +1,7 @@ /** * 陪练 API — 对齐 web/src/api/coaching.ts */ -import { get, post, del } from '../utils/request' +import { get, post, del, put } from '../utils/request' import type { ApiResponse } from '../types/api' export type CoachingWeekSchedule = { @@ -13,6 +13,8 @@ export type CoachingWeekSchedule = { teacherId: number studentId: number status: string + /** practice=无排课练习;scheduled=正式排课 */ + source?: 'practice' | 'scheduled' | string students?: string[] session?: { status?: string @@ -167,6 +169,13 @@ export function listStudentActivityRecordsAsTeacher( return get(`/teacher/coaching/students/${studentId}/vocab-records`, params as any) } +export function getStudentVocabRecordAsTeacher( + studentId: number | string, + recordId: number | string +): Promise> { + return get(`/teacher/coaching/students/${encodeURIComponent(String(studentId))}/vocab-records/${encodeURIComponent(String(recordId))}`) +} + export function getStudentCoachingWeek(date: string): Promise> { return get<{ schedules: CoachingWeekSchedule[] }>('/student/coaching/week', { date } as any) } @@ -175,16 +184,20 @@ export function startCoachingAppointment(id: number): Promise> { +export function endCoachingAppointment(id: number | string): Promise> { return post(`/teacher/coaching/appointments/${id}/end`) } +export function consumeCoachingLesson(id: number | string): Promise> { + return post(`/teacher/coaching/appointments/${id}/consume-lesson`) +} + export function startPracticeSession(body: { studentId: number plannedMinutes?: number }): Promise> { + return del(`/teacher/coaching/students/${studentId}`) +} + +export type ReviewCurvePreset = 'times3' | 'times5' | 'times7' | 'times10' + +/** 老师为学员设置抗遗忘次数(艾宾浩斯曲线) */ +export function setTeacherStudentReviewCurve( + studentId: number, + reviewCurvePreset: ReviewCurvePreset +): Promise> { + return put(`/teacher/coaching/students/${studentId}/review-curve`, { reviewCurvePreset }) +} + export function createTeacherCoachingAppointment(body: { studentId: number scheduledDate: string @@ -273,3 +301,54 @@ export function addStudentWordBookAsTeacher(studentId: number, wordBookId: numbe export function removeStudentWordBookAsTeacher(studentId: number, wordBookId: number): Promise> { return del(`/teacher/coaching/students/${studentId}/wordbooks/${wordBookId}`) } + +// ── 订阅 ── +export type SubscriptionType = 'monthly' | 'yearly' | 'lifetime' +export type SubscriptionStatus = 'active' | 'expired' | 'cancelled' + +export type UserSubscription = { + id: number + userId: number + type: SubscriptionType + startedAt: string + expiredAt?: string | null + status: SubscriptionStatus + user?: { + id?: number + username?: string + displayName?: string + email?: string + role?: string + } +} + +export function listSubscriptions(params?: { + userId?: number + status?: SubscriptionStatus +}): Promise> { + return get('/coaching/subscriptions', params as any) +} + +export function upsertSubscription(body: { + userId: number + type: SubscriptionType + startedAt?: string + duration?: number + status?: string +}): Promise> { + return put('/coaching/subscriptions', body) +} + +export function cancelSubscription(id: number): Promise> { + return del<{ id: number }>(`/coaching/subscriptions/${id}`) +} + +export type TeacherTeachingPoolWithSubscription = { + remainingMinutes: number + totalAllocatedMinutes: number + subscription?: UserSubscription | null +} + +export function getTeacherTeachingPoolWithSubscription(): Promise> { + return get('/teacher/coaching/teacher-pool') +} diff --git a/miniapp/src/api/feedback.ts b/miniapp/src/api/feedback.ts new file mode 100644 index 00000000..d5c730dc --- /dev/null +++ b/miniapp/src/api/feedback.ts @@ -0,0 +1,10 @@ +import { get, post } from '../utils/request' +import type { ApiResponse } from '../types/api' + +export type FeedbackReply = { id: number; role: string; content: string; createdAt: string } +export type FeedbackTicket = { id: number; content: string; contact?: string; status: string; replyCount: number; createdAt: string; replies?: FeedbackReply[] } +export type ListFeedbackResponse = { list: FeedbackTicket[]; total: number; page: number; pageSize: number } +export function listFeedback(params?: { page?: number; pageSize?: number }): Promise> { return get('/feedback', params as any) } +export function getFeedbackUnreadCount(): Promise> { return get('/feedback/unread-count') } +export function createFeedback(body: { content: string; contact?: string }): Promise> { return post('/feedback', body) } +export function replyFeedback(id: number, content: string): Promise> { return post(`/feedback/${id}/replies`, { content }) } diff --git a/miniapp/src/api/grammar.ts b/miniapp/src/api/grammar.ts new file mode 100644 index 00000000..9b442b45 --- /dev/null +++ b/miniapp/src/api/grammar.ts @@ -0,0 +1,83 @@ +/** + * 语法练习 API — 对齐 web/src/api/grammar.ts + */ +import { get, post } from '../utils/request' +import type { ApiResponse } from '../types/api' + +export type GrammarOption = { key: string; text: string } +export type GrammarExample = { en: string; zh: string } + +export type GrammarLessonListItem = { + id: number + title: string + topic?: string + level: string + summary?: string + estimatedMinutes?: number + questionCount?: number + lastScore?: number + lastCorrectCount?: number + lastQuestionCount?: number + lastCompletedAt?: string +} + +export type GrammarQuestionView = { + id: number + stem: string + options: GrammarOption[] + sortOrder?: number +} + +export type GrammarLessonDetail = { + id: number + title: string + topic?: string + level: string + explanation: string + examples: GrammarExample[] + summary?: string + estimatedMinutes?: number + questions: GrammarQuestionView[] +} + +export type GrammarAnswerDetail = { + questionId: number + answer: string + correct: boolean + rightAnswer?: string + stem?: string + explanation?: string +} + +export type GrammarSubmitResult = { + recordId: number + lessonId: number + title: string + topic?: string + level: string + questionCount: number + correctCount: number + score: number + durationSec: number + completedAt?: string + details: GrammarAnswerDetail[] +} + +export function listGrammarLessons(params?: { + level?: string + page?: number + pageSize?: number +}): Promise> { + return get('/grammar/lessons', params as any) +} + +export function getGrammarLesson(id: number): Promise> { + return get(`/grammar/lessons/${id}`) +} + +export function submitGrammarLesson( + id: number, + data: { answers: Array<{ questionId: number; answer: string }>; durationSec?: number } +): Promise> { + return post(`/grammar/lessons/${id}/submit`, data) +} diff --git a/miniapp/src/api/invite.ts b/miniapp/src/api/invite.ts new file mode 100644 index 00000000..e90c2182 --- /dev/null +++ b/miniapp/src/api/invite.ts @@ -0,0 +1,6 @@ +import { get, post } from '../utils/request' +import type { ApiResponse } from '../types/api' +export type InviteRecord = { id: string | number; invitee: string; registeredAt: string; status: string } +export type InviteOverview = { code: string; createdAt: string; totalInvited: number; totalActivated: number; earnedMinutes: number; records: InviteRecord[]; reward?: { enabled: boolean; inviterRegisterMinutes: number; inviteeRegisterMinutes: number; inviterActivateMinutes: number; inviteeActivateMinutes: number } } +export function fetchMyInvite(): Promise> { return get('/invite/me') } +export function rotateInviteCode(): Promise> { return post('/invite/rotate') } diff --git a/miniapp/src/api/reading.ts b/miniapp/src/api/reading.ts new file mode 100644 index 00000000..b64b8678 --- /dev/null +++ b/miniapp/src/api/reading.ts @@ -0,0 +1,148 @@ +/** + * 阅读理解 API — 对齐 web/src/api/reading.ts + customReading.ts + */ +import { get, post } from '../utils/request' +import type { ApiResponse } from '../types/api' + +export type ReadingOption = { key: string; text: string } + +export type ReadingPassageListItem = { + id: number + title: string + level: string + tags?: string + summary?: string + wordCount?: number + estimatedMinutes?: number + questionCount?: number + lastScore?: number + lastCorrectCount?: number + lastQuestionCount?: number + lastCompletedAt?: string +} + +export type ReadingQuestionView = { + id: number + stem: string + options: ReadingOption[] + sortOrder?: number +} + +export type ReadingPassageDetail = { + id: number + title: string + level: string + content: string + summary?: string + wordCount?: number + estimatedMinutes?: number + questions: ReadingQuestionView[] +} + +export type ReadingAnswerDetail = { + questionId: number + answer: string + correct: boolean + rightAnswer?: string + stem?: string + explanation?: string +} + +export type ReadingSubmitResult = { + recordId: number + passageId: number + title: string + level: string + questionCount: number + correctCount: number + score: number + durationSec: number + completedAt?: string + details: ReadingAnswerDetail[] +} + +export function listReadingPassages(params?: { + level?: string + tag?: string + keyword?: string + page?: number + pageSize?: number +}): Promise> { + return get('/reading/passages', params as any) +} + +export function listReadingTags(): Promise> { + return get('/reading/tags') +} + +export function getReadingPassage(id: number): Promise> { + return get(`/reading/passages/${id}`) +} + +export function checkReadingAnswer(id: number, data: { questionId: number; answer: string }): Promise> { + return post(`/reading/passages/${id}/check`, data) +} + +export function getReadingAnalysis(id: number): Promise> { + return get(`/reading/passages/${id}/analysis`, { timeout: 120000 } as any) +} + +export function getReadingKnowledge(id: number): Promise> { + return get(`/reading/passages/${id}/knowledge`, { timeout: 120000 } as any) +} + +export function submitReadingPassage( + id: number, + data: { answers: Array<{ questionId: number; answer: string }>; durationSec?: number } +): Promise> { + return post(`/reading/passages/${id}/submit`, data) +} + +/* ============ 自定义阅读 ============ */ + +export function listCustomReadingPassages(params?: { + level?: string + page?: number + pageSize?: number +}): Promise> { + return get('/reading/custom/passages', params as any) +} + +export function getCustomReadingPassage(id: number): Promise> { + return get(`/reading/custom/passages/${id}`) +} + +export function submitCustomReadingPassage( + id: number, + data: { answers: Array<{ questionId: number; answer: string }>; durationSec?: number } +): Promise> { + return post(`/reading/custom/passages/${id}/submit`, data) +} + +export function checkCustomReadingAnswer( + id: number, + data: { questionId: number; answer: string } +): Promise> { + return post(`/reading/custom/passages/${id}/check`, data) +} + +export function getCustomReadingAnalysis(id: number): Promise> { + return get(`/reading/custom/passages/${id}/analysis`, { timeout: 120000 } as any) +} + +export function getCustomReadingKnowledge(id: number): Promise> { + return get(`/reading/custom/passages/${id}/knowledge`, { timeout: 120000 } as any) +} + +export type CustomReadingPayload = { + title: string + level?: string + summary?: string + content: string + estimatedMinutes?: number + questions: Array<{ stem: string; options: ReadingOption[]; answer: string; explanation?: string; sortOrder?: number }> +} + +export function createCustomReadingPassage(payload: CustomReadingPayload): Promise> { + return post('/reading/custom/passages', payload) +} diff --git a/miniapp/src/api/review.ts b/miniapp/src/api/review.ts index 7be2f750..a45848bc 100644 --- a/miniapp/src/api/review.ts +++ b/miniapp/src/api/review.ts @@ -30,18 +30,20 @@ export interface CompleteReviewResult { } export type ReviewBookStatRow = { - wordBookId: number + studentId?: string | number + studentName?: string + wordBookId: string | number cnt: number name: string level: string - sessionId?: number + sessionId?: string | number practiceStartedAt?: string practiceEndedAt?: string | null } export function getReviewToday( wordBookId: number, - opts?: { date?: string; timeZone?: string; limit?: number; studySessionId?: number } + opts?: { date?: string; timeZone?: string; limit?: number; studySessionId?: number; all?: boolean; studentId?: number } ): Promise> { const tz = opts?.timeZone || 'Asia/Shanghai' return get('/review/today', { @@ -50,6 +52,8 @@ export function getReviewToday( timeZone: tz, ...(opts?.limit ? { limit: opts.limit } : {}), ...(opts?.studySessionId ? { studySessionId: opts.studySessionId } : {}), + ...(opts?.all ? { all: 1 } : {}), + ...(opts?.studentId ? { studentId: opts.studentId } : {}), } as any) } @@ -66,6 +70,6 @@ export function startReviewSession(data: StartReviewSessionRequest): Promise('/review/session/start', data) } -export function completeReviewSession(sessionId: number, results: CompleteReviewResult[]): Promise> { +export function completeReviewSession(sessionId: number | string, results: CompleteReviewResult[]): Promise> { return post(`/review/session/${sessionId}/complete`, { results }) } diff --git a/miniapp/src/api/scenarioDialogue.ts b/miniapp/src/api/scenarioDialogue.ts index 1213a179..27180b13 100644 --- a/miniapp/src/api/scenarioDialogue.ts +++ b/miniapp/src/api/scenarioDialogue.ts @@ -118,6 +118,18 @@ export function getVoiceReady(): Promise> { return get('/scenario-dialogue/voice/ready') } +export type CustomScenarioPayload = { + name: string + description?: string + aiRole: string + prompt: string + difficulty: string +} + +export function createCustomScenario(payload: CustomScenarioPayload): Promise> { + return post('/scenario-dialogue/custom/scenarios', payload) +} + export function activateSession(sessionId: number): Promise> { return post(`/scenario-dialogue/sessions/${sessionId}/activate`, {}) } diff --git a/miniapp/src/api/study.ts b/miniapp/src/api/study.ts index 16dd716e..bfc85b81 100644 --- a/miniapp/src/api/study.ts +++ b/miniapp/src/api/study.ts @@ -1,13 +1,14 @@ /** * 学习 API — 对齐 web/src/api/study.ts */ -import { get, post } from '../utils/request' +import { get, post, put } from '../utils/request' import type { ApiResponse } from '../types/api' export interface StudyWordItem { id: number word: string translation?: string + translationShort?: string phonetic?: string phoneticUk?: string phoneticUs?: string @@ -45,8 +46,9 @@ export interface StartStudySessionRequest { } export interface StartStudySessionResponse { - sessionId: number + sessionId?: number | string words: any[] + finished?: boolean } export interface CompleteSessionResult { @@ -58,22 +60,29 @@ export function getStudyWords( wordBookId: number, page: number = 1, pageSize: number = 20, - opts?: { shuffle?: boolean; seed?: number } + opts?: { shuffle?: boolean; seed?: number; studentId?: number } ): Promise> { return get('/study/words', { wordBookId, page, pageSize, ...(opts?.shuffle ? { shuffle: 1, seed: opts.seed ?? 0 } : {}), + ...(opts?.studentId ? { studentId: opts.studentId } : {}), } as any) } -export function getStudyLighthouse(wordBookId: number): Promise> { - return get('/study/lighthouse', { wordBookId } as any) +export function getStudyLighthouse( + wordBookId: number, + opts?: { studentId?: number } +): Promise> { + return get('/study/lighthouse', { + wordBookId, + ...(opts?.studentId ? { studentId: opts.studentId } : {}), + } as any) } export function startStudySession( - data: StartStudySessionRequest + data: StartStudySessionRequest & { studentId?: number } ): Promise> { return post('/study/session/start', data) } @@ -87,15 +96,17 @@ export function getLighthouseWords( wordBookId: number, step: string, page: number = 1, - pageSize: number = 50 + pageSize: number = 50, + opts?: { studentId?: number } ): Promise> { return get('/study/lighthouse/words', { wordBookId, step, page, pageSize, + ...(opts?.studentId ? { studentId: opts.studentId } : {}), } as any) } export function completeStudySession( - sessionId: number, + sessionId: number | string, results: CompleteSessionResult[] ): Promise> { return post(`/study/session/${sessionId}/complete`, { results }) @@ -158,7 +169,7 @@ export interface StudySessionDetail { words: StudyWordItem[] } -export function getStudySessionDetail(sessionId: number): Promise> { +export function getStudySessionDetail(sessionId: number | string): Promise> { return get(`/study/session/${sessionId}`) } @@ -184,3 +195,89 @@ export function exportStudySessionWords(params?: { }): Promise> { return get<{ words: StudyExportWord[]; total: number }>('/study/sessions/export-words', params as any) } + +export interface UpdatePracticeTimeRequest { + date: string + startTime: string + endTime?: string + studentId?: string | number + sessionIds?: Array +} + +/** 课后设置识记练习时段(抗遗忘列表展示用) */ +export function updateStudySessionsPracticeTime( + data: UpdatePracticeTimeRequest +): Promise }>> { + return put<{ updated: number; sessionIds?: Array }>('/study/sessions/practice-time', data) +} + +/* ============ 灯塔复习 ============ */ + +export function getLighthouseReviewWords( + wordBookId: number, + opts?: { page?: number; pageSize?: number; studentId?: number } +): Promise> { + return get('/study/lighthouse/review-words', { + wordBookId, + page: opts?.page ?? 1, + pageSize: opts?.pageSize ?? 200, + ...(opts?.studentId ? { studentId: opts.studentId } : {}), + } as any) +} + +export type LighthouseReviewSubmitResult = { + wordId: number + remembered: boolean +} + +export function submitLighthouseReview( + wordBookId: number, + results: LighthouseReviewSubmitResult[], + opts?: { studentId?: number } +): Promise> { + return post<{ advanced: number; unchanged: number }>('/study/lighthouse/review-submit', { + wordBookId, + results, + ...(opts?.studentId ? { studentId: opts.studentId } : {}), + }) +} + +/* ============ 会话报告 ============ */ + +export interface StudySessionReport { + sessionId: string + wordBookId: number + wordBookName: string + studentName: string + studentAvatar?: string + coachName?: string + coachAvatar?: string + status: string + startedAt: string + completedAt?: string + durationMinutes: number + screenedKnownCount: number + screenedUnknownCount: number + wordCount: number + correctCount: number + forgotCount: number + accuracyPercent: number + remainPending: number + wordBookWordCount?: number + learnedCount?: number + lessonCount?: number + remainingLessons?: number + forgotWords?: string[] + studiedWords?: string[] + reportSummary?: string + aiAvailable: boolean +} + +export function getStudySessionReport( + sessionId: number | string, + opts?: { sessionIds?: Array } +): Promise> { + const extras = (opts?.sessionIds || []).map((x) => String(x).trim()).filter(Boolean) + const params = extras.length > 0 ? { sessionIds: extras.join(',') } : undefined + return get(`/study/session/${sessionId}/report`, params as any) +} diff --git a/miniapp/src/api/tts.ts b/miniapp/src/api/tts.ts new file mode 100644 index 00000000..574870fd --- /dev/null +++ b/miniapp/src/api/tts.ts @@ -0,0 +1,8 @@ +import { post } from '../utils/request' +import type { ApiResponse } from '../types/api' + +export type TtsResult = { url: string } + +export function synthesizeTts(text: string, opts?: { lang?: string }): Promise> { + return post('/tts', { text, ...(opts?.lang ? { lang: opts.lang } : {}) }) +} diff --git a/miniapp/src/api/vocab.ts b/miniapp/src/api/vocab.ts index 4fc4d911..27da2455 100644 --- a/miniapp/src/api/vocab.ts +++ b/miniapp/src/api/vocab.ts @@ -12,14 +12,18 @@ export interface VocabNextRequest { } export interface VocabSubmitRequest { - answers: Array<{ questionId: number; answer: string }> - studentId?: number + answers: Array<{ questionId: number | string; answer: string }> + studentId?: number | string } export function getVocabStart(): Promise> { return get('/vocab/start') } +export function getVocabPoolRevision(): Promise> { + return get<{ poolRevision: number }>('/vocab/pool-revision') +} + export function getVocabNext(data: VocabNextRequest): Promise> { return post('/vocab/next', data) } diff --git a/miniapp/src/api/wordbooks.ts b/miniapp/src/api/wordbooks.ts index 618b256f..c9c88679 100644 --- a/miniapp/src/api/wordbooks.ts +++ b/miniapp/src/api/wordbooks.ts @@ -1,7 +1,7 @@ /** * 词库 API — 对齐 web/src/api/wordbooks.ts */ -import { get } from '../utils/request' +import { del, get, post, put } from '../utils/request' import type { ApiResponse } from '../types/api' export interface WordBookItem { @@ -11,6 +11,8 @@ export interface WordBookItem { wordCount?: number category?: string description?: string + coverUrl?: string + ownerUserId?: number } export interface WordBookListResult { @@ -57,10 +59,12 @@ export interface WordBookWord { phoneticUs?: string phoneticUk?: string translation?: string + translationShort?: string definition?: string partOfSpeech?: string exampleSentence?: string audioUrl?: string + overridden?: boolean } export function listWordBookWords( @@ -73,6 +77,23 @@ export function listWordBookWords( ) } +export function updateWordBookWord( + wordBookId: number, + wordId: number, + body: { + word?: string + phonetic?: string + translation?: string + translationShort?: string + } +): Promise> { + return put(`/wordbooks/${wordBookId}/words/${wordId}`, body) +} + +export function deleteWordBookWord(wordBookId: number, wordId: number): Promise> { + return del(`/wordbooks/${wordBookId}/words/${wordId}`) +} + export interface WordDetail { id: number word: string @@ -103,3 +124,23 @@ export interface WordDetail { export function getWordDetail(id: number): Promise> { return get(`/words/${id}`) } + +export type CustomParsedWord = { + word: string + phonetic?: string + translation?: string + translationShort?: string +} + +export function enrichCustomWordBookWords( + words: CustomParsedWord[] +): Promise> { + return post<{ list: CustomParsedWord[]; total: number }>('/wordbooks/custom/enrich', { words }) +} + +export function createCustomWordBook(body: { + name: string + words: CustomParsedWord[] +}): Promise> { + return post('/wordbooks/custom', body) +} diff --git a/miniapp/src/app.config.ts b/miniapp/src/app.config.ts index 4e898e56..7f71141e 100644 --- a/miniapp/src/app.config.ts +++ b/miniapp/src/app.config.ts @@ -22,6 +22,39 @@ export default defineAppConfig({ 'pages/vocab-test-result/index', 'pages/training-records/index', 'pages/review-word-list/index', + 'pages/reading-comprehension/index', + 'pages/cloze-practice/index', + 'pages/grammar-analysis/index', + 'pages/pre-training-check/index', + 'pages/word-practice/index', + 'pages/post-training-check/index', + 'pages/session-report/index', + 'pages/lighthouse-words/index', + 'pages/word-training/index', + 'pages/review-check/index', + 'pages/flash-review/index', + 'pages/listen-identify/index', + 'pages/scenario-history/index', + 'pages/scenario-review/index', + 'pages/create-custom-scenario/index', + 'pages/create-custom-reading/index', + 'pages/create-custom-cloze/index', + 'pages/create-student/index', + 'pages/create-custom-wordbook/index', + 'pages/create-coaching-appointment/index', + 'pages/feedback/index', + 'pages/invite-code/index', + 'pages/announcements/index', + 'pages/recharge/index', + 'pages/check-in/index', + 'pages/guides/index', + 'pages/create-anti-forgetting/index', + 'pages/coach-completed-sessions/index', + 'pages/student-detail/index', + 'pages/wordbook-shelf/index', + 'pages/scenario-dialogue/index', + 'pages/forbidden/index', + 'pages/admin-subscriptions/index', ], window: { backgroundTextStyle: 'light', @@ -29,6 +62,7 @@ export default defineAppConfig({ navigationBarTitleText: '解忧', navigationBarTextStyle: 'black', backgroundColor: '#f6f5f4', + pageOrientation: 'auto', }, tabBar: { color: '#787671', diff --git a/miniapp/src/app.scss b/miniapp/src/app.scss index 67574bad..9450f7ce 100644 --- a/miniapp/src/app.scss +++ b/miniapp/src/app.scss @@ -6,6 +6,11 @@ /* ============ CSS 变量(运行时,对齐 web theme.css :root) ============ */ page { + width: 100%; + min-height: 100%; + box-sizing: border-box; + overflow-x: hidden; + /* 字体 */ --font-sans: "Plus Jakarta Sans", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; @@ -20,6 +25,7 @@ page { --background: #f6f5f4; --card: #ffffff; --surface-soft: #fafaf9; + --muted: #f0eeec; /* 边框 */ --border: #e5e3df; diff --git a/miniapp/src/app.ts b/miniapp/src/app.ts index e3f7a91b..c1e0c596 100644 --- a/miniapp/src/app.ts +++ b/miniapp/src/app.ts @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import { useEffect } from 'react' import { useDidShow, useDidHide } from '@tarojs/taro' import { useAuthStore } from './stores/authStore' // 全局样式 diff --git a/miniapp/src/components/add-student-panel/AddStudentPanel.tsx b/miniapp/src/components/add-student-panel/AddStudentPanel.tsx new file mode 100644 index 00000000..05db12d6 --- /dev/null +++ b/miniapp/src/components/add-student-panel/AddStudentPanel.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react' +import { Input, ScrollView, Text, View } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { Plus } from '@nutui/icons-react-taro' +import { CloudButton } from '../button' +import { + addTeacherCoachingStudent, + searchCoachingStudents, + type CoachingStudentSearchResult, +} from '../../api/coaching' +import { color } from '../../styles/tokens' +import './add-student-panel.scss' + +type Props = { + open: boolean + onClose: () => void + onAdded?: () => void +} + +export function AddStudentPanel({ open, onClose, onAdded }: Props) { + const [searchQ, setSearchQ] = useState('') + const [searching, setSearching] = useState(false) + const [searchResults, setSearchResults] = useState([]) + const [picked, setPicked] = useState(null) + const [quotaLessons, setQuotaLessons] = useState('2') + const [adding, setAdding] = useState(false) + + if (!open) return null + + const onSearch = async () => { + const q = searchQ.trim() + if (!q) { + Taro.showToast({ title: '请输入搜索关键词', icon: 'none' }) + return + } + setSearching(true) + try { + const res = await searchCoachingStudents(q) + const list = Array.isArray(res.data) ? res.data : [] + setSearchResults(list) + if (!list.length) Taro.showToast({ title: '未找到该用户', icon: 'none' }) + } catch { + Taro.showToast({ title: '搜索失败', icon: 'none' }) + setSearchResults([]) + } finally { + setSearching(false) + } + } + + const onAdd = async () => { + if (!picked) { + Taro.showToast({ title: '请先选择学员', icon: 'none' }) + return + } + const lessons = Number(quotaLessons) + if (Number.isNaN(lessons) || lessons < 0 || !Number.isInteger(lessons)) { + Taro.showToast({ title: '请输入有效的课时数', icon: 'none' }) + return + } + setAdding(true) + try { + const res = await addTeacherCoachingStudent({ studentId: picked.id, remainingLessons: lessons }) + if (res.code !== 200) { + Taro.showToast({ title: res.msg || '添加失败', icon: 'none' }) + return + } + Taro.showToast({ title: '学员添加成功', icon: 'success' }) + setPicked(null) + setSearchQ('') + setSearchResults([]) + onAdded?.() + onClose() + } catch (e: any) { + Taro.showToast({ title: e?.msg || '添加失败', icon: 'none' }) + } finally { + setAdding(false) + } + } + + return ( + + + + + 添加学员 + + 收起 + + + + setSearchQ(e.detail.value)} + onConfirm={() => void onSearch()} + placeholder="搜索用户名、手机号或邮箱" + placeholderClass="add-student-panel__placeholder" + /> + void onSearch()}>搜索 + + + {searchResults.length ? ( + + {searchResults.map((u) => ( + setPicked(u)}> + {u.displayName || u.username} + {[u.username, u.phone, u.email].filter(Boolean).join(' · ')} + + ))} + + ) : null} + + {picked ? ( + <> + + 初始课时 + setQuotaLessons(e.detail.value)} /> + + void onAdd()}>确认添加 + + ) : null} + + ) +} + +export default AddStudentPanel diff --git a/miniapp/src/components/add-student-panel/add-student-panel.scss b/miniapp/src/components/add-student-panel/add-student-panel.scss new file mode 100644 index 00000000..864b3468 --- /dev/null +++ b/miniapp/src/components/add-student-panel/add-student-panel.scss @@ -0,0 +1,100 @@ +@import '../../styles/vars.scss'; + +.add-student-panel { + padding: 16px; + box-sizing: border-box; + border: 1px solid rgba(78, 205, 196, .3); + border-radius: 16px; + background: $card; +} + +.add-student-panel__head { + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.add-student-panel__title { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 600; + color: $foreground; +} + +.add-student-panel__search { + display: flex; + align-items: center; + gap: 8px; +} + +.add-student-panel__input { + min-width: 0; + flex: 1; + height: 40px; + box-sizing: border-box; + padding: 0 12px; + border: 1px solid $input; + border-radius: 12px; + background: $card; + color: $charcoal; + font-size: 14px; +} + +.add-student-panel__placeholder { + color: $muted-soft; +} + +.add-student-panel__results { + max-height: 160px; + margin-top: 12px; +} + +.add-student-panel__result { + margin-bottom: 8px; + padding: 12px; + border: 1px solid $border; + border-radius: 12px; +} + +.add-student-panel__result--active { + border-color: $primary; + background: $primary-soft; +} + +.add-student-panel__result-name { + display: block; + font-size: 14px; + font-weight: 500; + color: $foreground; +} + +.add-student-panel__result-meta { + display: block; + margin-top: 2px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; + color: $muted-foreground; +} + +.add-student-panel__quota { + margin-top: 12px; +} + +.add-student-panel__label { + display: block; + margin-bottom: 4px; + font-size: 12px; + font-weight: 500; + color: $charcoal; +} + +.add-student-panel__submit { + width: 100%; + margin-top: 12px; +} diff --git a/miniapp/src/components/annotation-layer/AnnotationLayer.tsx b/miniapp/src/components/annotation-layer/AnnotationLayer.tsx new file mode 100644 index 00000000..c204d2d7 --- /dev/null +++ b/miniapp/src/components/annotation-layer/AnnotationLayer.tsx @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react' +import { Canvas, Text, View } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { Close, Del, Edit, Retweet } from '@nutui/icons-react-taro' +import './annotation-layer.scss' + +type Point = { x: number; y: number } +type Stroke = { color: string; width: number; points: Point[] } +type Props = { open: boolean; storageKey: string; onClose: () => void } + +const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6', '#a855f7', '#111827'] + +export function AnnotationLayer({ open, storageKey, onClose }: Props) { + const canvasId = `annotation-${storageKey.replace(/[^a-z0-9]/gi, '') || 'default'}` + const [tool, setTool] = useState<'pen' | 'eraser'>('pen') + const [color, setColor] = useState(COLORS[0]) + const [strokes, setStrokes] = useState([]) + const current = useRef(null) + + useEffect(() => { + if (!open) return + try { setStrokes(Taro.getStorageSync(storageKey) || []) } catch { setStrokes([]) } + }, [open, storageKey]) + + useEffect(() => { + if (!open) return + const ctx = Taro.createCanvasContext(canvasId) + ctx.clearRect(0, 0, 375, 620) + strokes.forEach((stroke) => { + if (stroke.points.length < 2) return + ctx.beginPath() + ctx.setStrokeStyle(stroke.color) + ctx.setLineWidth(stroke.width) + ctx.setLineCap('round') + ctx.moveTo(stroke.points[0].x, stroke.points[0].y) + stroke.points.slice(1).forEach((point) => ctx.lineTo(point.x, point.y)) + ctx.stroke() + }) + ctx.draw() + }, [open, strokes, canvasId]) + + if (!open) return null + const point = (event: any): Point => ({ x: Number(event.touches?.[0]?.x || 0), y: Number(event.touches?.[0]?.y || 0) }) + const start = (event: any) => { current.current = { color: tool === 'eraser' ? '#ffffff' : color, width: tool === 'eraser' ? 18 : 4, points: [point(event)] } } + const move = (event: any) => { if (current.current) current.current.points.push(point(event)) } + const end = () => { if (!current.current) return; setStrokes((prev) => [...prev, current.current as Stroke]); current.current = null } + const undo = () => setStrokes((prev) => prev.slice(0, -1)) + const clear = () => setStrokes([]) + const save = () => { Taro.setStorageSync(storageKey, strokes); Taro.showToast({ title: '标注已保存', icon: 'success' }) } + + return 标注 setTool('pen')}>画笔 setTool('eraser')}>橡皮撤销清空{COLORS.map((item) => { setColor(item); setTool('pen') }} />)}保存标注 +} diff --git a/miniapp/src/components/annotation-layer/annotation-layer.scss b/miniapp/src/components/annotation-layer/annotation-layer.scss new file mode 100644 index 00000000..b1d4a54a --- /dev/null +++ b/miniapp/src/components/annotation-layer/annotation-layer.scss @@ -0,0 +1,11 @@ +.annotation__mask { position:fixed; inset:0; z-index:98; padding:16px; box-sizing:border-box; display:flex; align-items:flex-end; background:rgba(0,0,0,.4); } +.annotation { width:100%; padding:16px; box-sizing:border-box; border-radius:16px 16px 0 0; background:#fff; } +.annotation__header { display:flex; align-items:center; justify-content:space-between; font-size:16px; font-weight:600; color:#2d3748; } +.annotation__canvas { width:100%; height:420px; margin-top:12px; border-radius:10px; background:#fff8e8; } +.annotation__tools, .annotation__colors { display:flex; align-items:center; gap:8px; margin-top:10px; } +.annotation__tool { min-width:48px; height:32px; padding:0 7px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; gap:3px; border-radius:7px; background:#f1f5f9; color:#718096; font-size:11px; } +.annotation__tool--active { background:rgba(78,205,196,.15); color:#2c7a7b; } +.annotation__color { width:22px; height:22px; border:2px solid transparent; border-radius:999px; } +.annotation__color--active { border-color:#2d3748; box-shadow:0 0 0 2px #fff inset; } +.annotation__save { height:42px; margin-top:14px; display:flex; align-items:center; justify-content:center; border-radius:999px; background:#4ecdc4; color:#fff; font-size:14px; } +@media (min-width:640px) { .annotation__mask { align-items:center; justify-content:center; } .annotation { max-width:560px; border-radius:16px; } } diff --git a/miniapp/src/components/app-header/AppHeader.tsx b/miniapp/src/components/app-header/AppHeader.tsx new file mode 100644 index 00000000..502c1979 --- /dev/null +++ b/miniapp/src/components/app-header/AppHeader.tsx @@ -0,0 +1,114 @@ +import { useCallback, useState } from 'react' +import { Image, Text, View } from '@tarojs/components' +import Taro, { useDidShow } from '@tarojs/taro' +import { List, Notice, Refresh, Setting, Star } from '@nutui/icons-react-taro' +import { listNotifications } from '../../api/notifications' +import { useAuthStore } from '../../stores/authStore' +import './app-header.scss' + +type NavItem = { + path: string + label: string + icon: 'home' | 'lesson' | 'books' | 'review' | 'coach' +} + +const NAV_ITEMS: NavItem[] = [ + { path: '/pages/home/index', label: '首页', icon: 'home' }, + { path: '/pages/lesson-prep/index', label: '备课', icon: 'lesson' }, + { path: '/pages/wordbooks/index', label: '词库', icon: 'books' }, + { path: '/pages/anti-forgetting/index', label: '抗遗忘', icon: 'review' }, + { path: '/pages/coach/index', label: '陪练中心', icon: 'coach' }, +] + +function NavIcon({ icon, active }: { icon: NavItem['icon']; active: boolean }) { + const color = active ? '#4ECDC4' : '#787671' + if (icon === 'home') return + if (icon === 'lesson') return + if (icon === 'books') return + if (icon === 'review') return + return +} + +export function AppHeader() { + const user = useAuthStore((s) => s.user) + const hasHydrated = useAuthStore((s) => s.hasHydrated) + const [open, setOpen] = useState(false) + const [unread, setUnread] = useState(0) + const currentPath = `/${Taro.getCurrentInstance().router?.path || 'pages/home/index'}` + const badge = unread > 99 ? '99+' : unread > 0 ? String(unread) : '' + const userName = user?.displayName || user?.email || '-' + + const refreshUnread = useCallback(() => { + if (!hasHydrated || !user?.id) return + listNotifications({ page: 1, size: 1 }) + .then((res) => setUnread(res.data?.totalUnread ?? 0)) + .catch(() => {}) + }, [hasHydrated, user?.id]) + + useDidShow(refreshUnread) + + const go = (url: string) => { + setOpen(false) + if (url === currentPath) return + Taro.switchTab({ url }) + } + + return ( + <> + + + + + setOpen(true)}> + ☰ + + go('/pages/home/index')}> + + 解忧背词 + + + + Taro.navigateTo({ url: '/pages/guides/index' })}> + + + Taro.navigateTo({ url: '/pages/notifications/index' })}> + + {badge ? {badge} : null} + + + + + + {open ? ( + + setOpen(false)} /> + + + 官方陪练 + + {userName.slice(0, 1).toUpperCase()} + + Hi, + {userName} + + + + + {NAV_ITEMS.map((item) => { + const active = currentPath === item.path + return ( + go(item.path)}> + + {item.label} + + ) + })} + + + + ) : null} + + ) +} + +export default AppHeader diff --git a/miniapp/src/components/app-header/app-header.scss b/miniapp/src/components/app-header/app-header.scss new file mode 100644 index 00000000..da56f3da --- /dev/null +++ b/miniapp/src/components/app-header/app-header.scss @@ -0,0 +1,204 @@ +.app-header { + position: sticky; + top: 0; + z-index: 50; + background: rgba(255, 255, 255, .95); + border-bottom: 1px solid #e5e3df; +} + +.app-header__inner { + position: relative; + height: 44px; + box-sizing: border-box; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.app-header__accent { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background: rgba(78, 205, 196, .4); +} + +.app-header__left { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.app-header__icon-btn { + position: relative; + width: 36px; + height: 36px; + flex: 0 0 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; +} + +.app-header__menu { + font-size: 20px; + line-height: 1; + color: #37352f; +} + +.app-header__brand { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + border-radius: 8px; +} + +.app-header__logo { + width: 28px; + height: 28px; + flex: 0 0 28px; + border-radius: 6px; +} + +.app-header__name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 14px; + font-weight: 600; + letter-spacing: -.02em; + color: #1a1a1a; +} + +.app-header__actions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 2px; +} + +.app-header__badge { + position: absolute; + top: -2px; + right: -2px; + min-width: 18px; + height: 18px; + padding: 0 4px; + box-sizing: border-box; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 10px; + font-weight: 600; + line-height: 18px; + text-align: center; +} + +.app-drawer { + position: fixed; + inset: 0; + z-index: 60; +} + +.app-drawer__mask { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, .2); +} + +.app-drawer__panel { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 256px; + box-sizing: border-box; + padding: 24px; + overflow-y: auto; + background: #ffffff; + border-right: 1px solid #e5e3df; +} + +.app-drawer__badge { + display: inline-flex; + margin-left: 50px; + margin-bottom: 8px; + padding: 2px 10px; + border-radius: 8px; + background: rgba(78, 205, 196, .12); + color: #4ecdc4; + font-size: 12px; + font-weight: 600; +} + +.app-drawer__user { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 24px; +} + +.app-drawer__avatar { + width: 40px; + height: 40px; + flex: 0 0 40px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #e5e3df; + border-radius: 999px; + background: #ffffff; + color: #4ecdc4; + font-size: 16px; + font-weight: 600; +} + +.app-drawer__hello { + display: block; + font-size: 12px; + color: #787671; +} + +.app-drawer__name { + display: block; + font-size: 14px; + font-weight: 500; + color: #1a1a1a; +} + +.app-drawer__nav { + display: flex; + flex-direction: column; + gap: 4px; +} + +.app-drawer__item { + position: relative; + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + display: flex; + align-items: center; + gap: 10px; + border-radius: 10px; + font-size: 14px; + color: #787671; +} + +.app-drawer__item--active { + background: #ffffff; + color: #1a1a1a; + font-weight: 500; + box-shadow: 0 1px 4px rgba(0, 0, 0, .08); +} + +.app-drawer__item-label { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/miniapp/src/components/button/button.scss b/miniapp/src/components/button/button.scss index 7d23cb5d..43e3dd39 100644 --- a/miniapp/src/components/button/button.scss +++ b/miniapp/src/components/button/button.scss @@ -6,22 +6,28 @@ /* ============ 基础重置 ============ */ .cs-btn { - display: flex; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; - border-radius: 8px; + width: auto; + min-width: 0; + box-sizing: border-box; + border: 0; + border-radius: 10px; font-weight: 500; - font-size: 28px; - line-height: 1; - padding: 0px 32px; - height: 72px; + font-size: 14px; + line-height: 1.3; + padding: 8px 16px; + height: 36px; + margin: 0; + white-space: nowrap; + user-select: none; - /* 覆盖微信小程序 button 默认样式 */ &::after { + border: 0; } - /* 重置微信 button 默认背景 */ color: #37352f; } @@ -34,7 +40,7 @@ .cs-btn--brand-outline { color: #4ECDC4; - border: 2px solid #4ECDC4; + border: 1px solid #4ECDC4; } .cs-btn--mint { @@ -44,7 +50,7 @@ .cs-btn--mint-outline { color: #4ECDC4; - border: 2px solid #4ECDC4; + border: 1px solid #4ECDC4; } .cs-btn--destructive { @@ -55,7 +61,7 @@ .cs-btn--outline { background-color: #ffffff; color: #37352f; - border: 2px solid #c8c4be; + border: 1px solid #c8c4be; } .cs-btn--secondary { @@ -69,69 +75,73 @@ .cs-btn--card { width: 100%; + height: auto; flex-direction: column; align-items: flex-start; gap: 0; - border-radius: 12px; - border: 2px solid #e5e3df; + border-radius: 16px; + border: 1px solid #e5e3df; background-color: #ffffff; color: #37352f; - padding: 32px; + padding: 24px; + text-align: left; + box-shadow: 0 1px 2px rgba(0, 0, 0, .04); } /* ============ Sizes ============ */ .cs-btn--sm { - height: 64px; - padding: 0px 24px; - font-size: 24px; + height: 32px; + padding: 6px 12px; + gap: 6px; + font-size: 12px; border-radius: 8px; } .cs-btn--md { - height: 72px; - padding: 0px 32px; + height: 36px; + padding: 8px 16px; } .cs-btn--lg { - height: 88px; - padding: 0px 48px; - border-radius: 8px; + height: 40px; + padding: 10px 24px; + border-radius: 10px; } .cs-btn--pill { - height: 72px; - padding: 0px 32px; + height: 36px; + padding: 8px 16px; border-radius: 999px; } .cs-btn--pill-lg { - height: 88px; - padding: 0px 48px; + height: 44px; + padding: 10px 24px; border-radius: 999px; - font-size: 28px; + font-size: 14px; } .cs-btn--icon { - width: 72px; - height: 72px; - padding: 0px; + width: 36px; + height: 36px; + padding: 0; border-radius: 8px; } .cs-btn--icon-round { - width: 72px; - height: 72px; - padding: 0px; + width: 36px; + height: 36px; + padding: 0; border-radius: 999px; } /* ============ Loading ============ */ .cs-btn__spinner { - font-size: 24px; + font-size: 16px; color: currentColor; - margin-right: 8px; + margin-right: 0; } /* ============ Disabled ============ */ diff --git a/miniapp/src/components/card/card.scss b/miniapp/src/components/card/card.scss index 3eee0e99..c5a5af6b 100644 --- a/miniapp/src/components/card/card.scss +++ b/miniapp/src/components/card/card.scss @@ -3,19 +3,20 @@ * white canvas + hairline border + radius 12px + 微阴影 */ .cs-card { + box-sizing: border-box; background-color: #ffffff; - border: 2px solid #e5e3df; - border-radius: 12px; + border: 1px solid #e5e3df; + border-radius: 16px; &--pad-default { - padding: 32px; + padding: 24px; } &--pad-compact { - padding: 24px; + padding: 12px; } &--pad-none { - padding: 0px; + padding: 0; } } diff --git a/miniapp/src/components/input/input.scss b/miniapp/src/components/input/input.scss index a07d1a4e..976c49de 100644 --- a/miniapp/src/components/input/input.scss +++ b/miniapp/src/components/input/input.scss @@ -5,13 +5,14 @@ */ .cs-input { width: 100%; - height: 88px; - padding: 0px 32px; + height: 40px; + box-sizing: border-box; + padding: 0 12px; border-radius: 12px; background-color: #ffffff; - border: 2px solid #c8c4be; + border: 1px solid #c8c4be; color: #37352f; - font-size: 30px; + font-size: 16px; &::placeholder { color: #a4a097; diff --git a/miniapp/src/components/mobile-select-sheet/MobileSelectSheet.tsx b/miniapp/src/components/mobile-select-sheet/MobileSelectSheet.tsx new file mode 100644 index 00000000..d3e12a71 --- /dev/null +++ b/miniapp/src/components/mobile-select-sheet/MobileSelectSheet.tsx @@ -0,0 +1,115 @@ +import { useEffect, useMemo, useState } from 'react' +import { Input, ScrollView, Text, View } from '@tarojs/components' +import './mobile-select-sheet.scss' + +export type MobileSelectOption = { + value: string + label: string + disabled?: boolean +} + +export type MobileSelectSheetProps = { + label?: string + title?: string + value?: string + options: MobileSelectOption[] + onChange?: (value: string) => void + placeholder?: string + disabled?: boolean + showSearch?: boolean + className?: string + size?: 'small' | 'default' +} + +export function MobileSelectSheet({ + label, + title, + value, + options, + onChange, + placeholder = '请选择', + disabled, + showSearch, + className, + size = 'default', +}: MobileSelectSheetProps) { + const [open, setOpen] = useState(false) + const [draft, setDraft] = useState(value || '') + const [q, setQ] = useState('') + + useEffect(() => { + if (!open) return + setDraft(value || '') + setQ('') + }, [open, value]) + + const filtered = useMemo(() => { + const keyword = q.trim().toLowerCase() + if (!keyword) return options + return options.filter((o) => o.label.toLowerCase().includes(keyword) || o.value.toLowerCase().includes(keyword)) + }, [options, q]) + + const selectedLabel = options.find((o) => o.value === value)?.label + const compact = size === 'small' + + return ( + + {label ? {label} : null} + { + if (!disabled) setOpen(true) + }} + > + + {selectedLabel || placeholder} + + ▼ + + + {open ? ( + + setOpen(false)} /> + + + setOpen(false)}>取消 + {title || '请选择'} + { + if (draft) onChange?.(draft) + setOpen(false) + }} + >确定 + + {showSearch ? ( + + setQ(e.detail.value)} /> + + ) : null} + + {filtered.length === 0 ? ( + 没有匹配选项 + ) : filtered.map((o) => { + const active = draft === o.value + return ( + { + if (!o.disabled) setDraft(o.value) + }} + > + {o.label} + + ) + })} + + + + ) : null} + + ) +} + +export default MobileSelectSheet diff --git a/miniapp/src/components/mobile-select-sheet/mobile-select-sheet.scss b/miniapp/src/components/mobile-select-sheet/mobile-select-sheet.scss new file mode 100644 index 00000000..9d9e2581 --- /dev/null +++ b/miniapp/src/components/mobile-select-sheet/mobile-select-sheet.scss @@ -0,0 +1,154 @@ +.mobile-select { + width: 100%; +} + +.mobile-select__label { + display: block; + margin-bottom: 6px; + font-size: 12px; + font-weight: 500; + color: #37352f; +} + +.mobile-select__trigger { + width: 100%; + height: 40px; + box-sizing: border-box; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid #c8c4be; + border-radius: 12px; + background: #ffffff; + font-size: 14px; +} + +.mobile-select__trigger--small { + height: 32px; + padding: 0 10px; + border-radius: 8px; +} + +.mobile-select__trigger--disabled { + opacity: .5; +} + +.mobile-select__value { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: #37352f; +} + +.mobile-select__value--placeholder { + color: #a4a097; +} + +.mobile-select__arrow { + flex-shrink: 0; + font-size: 12px; + color: #a4a097; +} + +.mobile-select-sheet { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +.mobile-select-sheet__mask { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, .4); +} + +.mobile-select-sheet__panel { + position: relative; + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 16px 16px 0 0; + background: #ffffff; + box-shadow: 0 -12px 32px rgba(0, 0, 0, .12); +} + +.mobile-select-sheet__header { + height: 48px; + flex-shrink: 0; + padding: 0 16px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #e5e3df; +} + +.mobile-select-sheet__action { + padding: 0 4px; + font-size: 14px; + color: #787671; +} + +.mobile-select-sheet__action--confirm { + color: #4ecdc4; + font-weight: 500; +} + +.mobile-select-sheet__title { + font-size: 14px; + font-weight: 600; + color: #1a1a1a; +} + +.mobile-select-sheet__search { + padding: 8px 8px 4px; +} + +.mobile-select-sheet__search-input { + width: 100%; + height: 36px; + box-sizing: border-box; + padding: 0 12px; + border: 1px solid #e5e3df; + border-radius: 8px; + background: rgba(240, 238, 236, .6); + font-size: 14px; +} + +.mobile-select-sheet__list { + max-height: 50vh; + padding: 4px 0; + box-sizing: border-box; +} + +.mobile-select-sheet__empty { + padding: 32px 0; + text-align: center; + font-size: 14px; + color: #a4a097; +} + +.mobile-select-sheet__option { + width: 100%; + box-sizing: border-box; + padding: 12px 16px; + font-size: 14px; + color: #37352f; +} + +.mobile-select-sheet__option--active { + background: rgba(78, 205, 196, .12); + color: #4ecdc4; + font-weight: 500; +} + +.mobile-select-sheet__option--disabled { + opacity: .4; +} diff --git a/miniapp/src/components/page-back-header/PageBackHeader.tsx b/miniapp/src/components/page-back-header/PageBackHeader.tsx new file mode 100644 index 00000000..9ccc4f1a --- /dev/null +++ b/miniapp/src/components/page-back-header/PageBackHeader.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react' +import { View, Text } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { ArrowLeft } from '@nutui/icons-react-taro' +import './page-back-header.scss' + +type PageBackHeaderProps = { + title: string + subtitle?: string + onBack?: () => void + fallbackTo?: string + extra?: ReactNode +} + +const TAB_PATHS = new Set([ + '/pages/home/index', + '/pages/lesson-prep/index', + '/pages/wordbooks/index', + '/pages/anti-forgetting/index', + '/pages/coach/index', +]) + +export function PageBackHeader({ + title, + subtitle, + onBack, + fallbackTo = '/pages/home/index', + extra, +}: PageBackHeaderProps) { + const handleBack = () => { + if (onBack) { + onBack() + return + } + Taro.navigateBack({ + fail: () => { + if (TAB_PATHS.has(fallbackTo)) Taro.switchTab({ url: fallbackTo }) + else Taro.redirectTo({ url: fallbackTo }) + }, + }) + } + + return ( + + + + + + + {title} + {subtitle ? {subtitle} : null} + + {extra ? {extra} : null} + + + ) +} + +export default PageBackHeader diff --git a/miniapp/src/components/page-back-header/page-back-header.scss b/miniapp/src/components/page-back-header/page-back-header.scss new file mode 100644 index 00000000..0fb67b86 --- /dev/null +++ b/miniapp/src/components/page-back-header/page-back-header.scss @@ -0,0 +1,66 @@ +.page-back-header { + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 50; + background: rgba(255, 255, 255, .95); + backdrop-filter: blur(4px); + border-bottom: 1px solid #e5e3df; +} + +.page-back-header__inner { + width: 100%; + height: 44px; + min-height: 44px; + box-sizing: border-box; + margin: 0 auto; + padding: 0 8px; + display: flex; + align-items: center; + gap: 4px; +} + +.page-back-header__back { + width: 36px; + height: 36px; + margin-left: -4px; + flex: 0 0 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; +} + +.page-back-header__text { + min-width: 0; + flex: 1; + padding: 2px 0; + display: flex; + flex-direction: column; +} + +.page-back-header__title { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 15px; + font-weight: 600; + line-height: 1; + color: #1a1a1a; +} + +.page-back-header__subtitle { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-top: 2px; + font-size: 11px; + line-height: 1.25; + color: #787671; +} + +.page-back-header__extra { + flex-shrink: 0; +} diff --git a/miniapp/src/components/practice-font-settings/PracticeFontSettings.tsx b/miniapp/src/components/practice-font-settings/PracticeFontSettings.tsx new file mode 100644 index 00000000..551280a9 --- /dev/null +++ b/miniapp/src/components/practice-font-settings/PracticeFontSettings.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' +import { Text, View } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { Setting, Add, Minus } from '@nutui/icons-react-taro' +import './practice-font-settings.scss' + +export type PracticeDisplaySettings = { wordSizePx: number; fontFamily: 'sans' | 'serif' | 'italic'; bold: boolean } +const KEY = 'lb_practice_display' +const DEFAULTS: PracticeDisplaySettings = { wordSizePx: 26, fontFamily: 'sans', bold: false } + +export function readPracticeDisplay(): PracticeDisplaySettings { + try { + const raw = Taro.getStorageSync(KEY) + const data = typeof raw === 'string' ? JSON.parse(raw) : raw + return { ...DEFAULTS, ...(data || {}), wordSizePx: Math.max(14, Math.min(48, Number(data?.wordSizePx || DEFAULTS.wordSizePx))) } + } catch { return DEFAULTS } +} + +export function PracticeFontSettingsButton() { + const [open, setOpen] = useState(false) + const [settings, setSettings] = useState(readPracticeDisplay) + useEffect(() => { Taro.setStorageSync(KEY, settings) }, [settings]) + const update = (patch: Partial) => setSettings((prev) => ({ ...prev, ...patch })) + return <> + setOpen(true)}> + {open ? setOpen(false)}> event.stopPropagation()}> + 显示设置 + 字号 update({ wordSizePx: Math.max(14, settings.wordSizePx - 1) })}>{settings.wordSizePx}px update({ wordSizePx: Math.min(48, settings.wordSizePx + 1) })}> + 字重 update({ bold: !settings.bold })}>{settings.bold ? '粗体' : '常规'} + 字体{(['sans', 'serif', 'italic'] as const).map((family) => update({ fontFamily: family })}>{family === 'sans' ? '无衬线' : family === 'serif' ? '衬线' : '斜体'})} + Vocabulary 单词示例 + setOpen(false)}>完成 + : null} + +} diff --git a/miniapp/src/components/practice-font-settings/practice-font-settings.scss b/miniapp/src/components/practice-font-settings/practice-font-settings.scss new file mode 100644 index 00000000..3257a415 --- /dev/null +++ b/miniapp/src/components/practice-font-settings/practice-font-settings.scss @@ -0,0 +1,13 @@ +.practice-font-trigger { width: 32px; height: 32px; display:flex; align-items:center; justify-content:center; } +.practice-font-mask { position:fixed; inset:0; z-index:95; padding:16px; box-sizing:border-box; display:flex; align-items:flex-end; background:rgba(0,0,0,.4); } +.practice-font-panel { width:100%; padding:20px; box-sizing:border-box; border-radius:16px 16px 0 0; background:#fff; } +.practice-font-title { display:block; margin-bottom:16px; font-size:16px; font-weight:600; color:#2d3748; } +.practice-font-row { min-height:44px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid #f1f5f9; font-size:14px; color:#2d3748; } +.practice-font-step { display:flex; align-items:center; gap:14px; color:#55a3ff; } +.practice-font-step > View { width:28px; height:28px; display:flex; align-items:center; justify-content:center; border-radius:999px; background:#eff6ff; } +.practice-font-switch, .practice-font-family { padding:5px 9px; border-radius:7px; background:#f1f5f9; color:#718096; font-size:12px; } +.practice-font-switch--active, .practice-font-family--active { background:rgba(78,205,196,.15); color:#2c7a7b; } +.practice-font-families { display:flex; gap:6px; } +.practice-font-preview { margin-top:16px; padding:16px; border-radius:10px; background:#f7f9fc; color:#2d3748; text-align:center; } +.practice-font-close { height:42px; margin-top:16px; display:flex; align-items:center; justify-content:center; border-radius:999px; background:#4ecdc4; color:#fff; font-size:14px; } +@media (min-width:640px) { .practice-font-mask { align-items:center; justify-content:center; } .practice-font-panel { max-width:420px; border-radius:16px; } } diff --git a/miniapp/src/components/practice-pause-menu/PracticePauseMenu.tsx b/miniapp/src/components/practice-pause-menu/PracticePauseMenu.tsx new file mode 100644 index 00000000..93b79add --- /dev/null +++ b/miniapp/src/components/practice-pause-menu/PracticePauseMenu.tsx @@ -0,0 +1,26 @@ +import { Text, View } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { finishPracticeBilling } from '../../utils/practiceBilling' +import { getReviewReturnPath } from '../../utils/reviewPractice' +import './practice-pause-menu.scss' + +type Props = { open: boolean; onResume: () => void; onClose: () => void; reportUrl?: string } + +export function PracticePauseMenu({ open, onResume, onClose, reportUrl }: Props) { + if (!open) return null + const isReview = Taro.getStorageSync('lb_mode') === 'review' + const homePath = reportUrl || (isReview ? getReviewReturnPath('/pages/word-training/index') : '/pages/word-training/index') + const exit = () => { + onClose() + void (async () => { + if (isReview) { + Taro.removeStorageSync('lb_review_return') + Taro.removeStorageSync('lb_mode') + } else { + await finishPracticeBilling() + } + Taro.reLaunch({ url: homePath }).catch(() => Taro.redirectTo({ url: homePath })) + })() + } + return 暂停练习当前进度已自动保存,可以继续练习。继续练习退出当前流程 +} diff --git a/miniapp/src/components/practice-pause-menu/practice-pause-menu.scss b/miniapp/src/components/practice-pause-menu/practice-pause-menu.scss new file mode 100644 index 00000000..b311ce53 --- /dev/null +++ b/miniapp/src/components/practice-pause-menu/practice-pause-menu.scss @@ -0,0 +1,7 @@ +.practice-pause-mask { position:fixed; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; padding:24px; box-sizing:border-box; background:rgba(0,0,0,.4); } +.practice-pause-panel { width:100%; max-width:360px; padding:24px; box-sizing:border-box; border-radius:16px; background:#fff; text-align:center; } +.practice-pause-title { display:block; font-size:18px; font-weight:700; color:#2d3748; } +.practice-pause-desc { display:block; margin-top:8px; font-size:13px; line-height:1.5; color:#718096; } +.practice-pause-primary, .practice-pause-secondary { height:44px; margin-top:16px; display:flex; align-items:center; justify-content:center; border-radius:999px; font-size:14px; } +.practice-pause-primary { background:#4ecdc4; color:#fff; } +.practice-pause-secondary { margin-top:8px; background:#f1f5f9; color:#718096; } diff --git a/miniapp/src/components/study-note-panel/StudyNotePanel.tsx b/miniapp/src/components/study-note-panel/StudyNotePanel.tsx new file mode 100644 index 00000000..e14b1ebc --- /dev/null +++ b/miniapp/src/components/study-note-panel/StudyNotePanel.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react' +import { Text, Textarea, View } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { Close } from '@nutui/icons-react-taro' +import './study-note-panel.scss' + +export function readStudyNote(storageKey: string): string { + try { return String(Taro.getStorageSync(storageKey) || '') } catch { return '' } +} + +type PanelProps = { open: boolean; storageKey: string; title?: string; onClose: () => void } + +export function StudyNotePanel({ open, storageKey, title = '学习笔记', onClose }: PanelProps) { + const [text, setText] = useState('') + const [saved, setSaved] = useState(false) + + useEffect(() => { + if (open) { setText(readStudyNote(storageKey)); setSaved(false) } + }, [open, storageKey]) + + if (!open) return null + const save = () => { + Taro.setStorageSync(storageKey, text) + setSaved(true) + Taro.showToast({ title: '笔记已保存', icon: 'success' }) + } + return event.stopPropagation()}>{title}