diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt index d10cc1e..8f85044 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/UserApi.kt @@ -90,37 +90,47 @@ class UserApi internal constructor( fun register( username: String, password: String, + nickname: String? = null, + phone: String? = null, onComplete: (Result) -> Unit, - ): CancellableRequest = submit(onComplete) { requestBlocking("auth/register", username, password, auth = false) } + ): CancellableRequest = submit(onComplete) { + requestRegisterBlocking( + "api/v1/auth/register", + username = username, + password = password, + nickname = nickname, + phone = phone, + ) + } fun login( username: String, password: String, onComplete: (Result) -> Unit, - ): CancellableRequest = submit(onComplete) { requestBlocking("auth/login", username, password, auth = false) } + ): CancellableRequest = submit(onComplete) { requestBlocking("api/v1/auth/login", username, password, auth = false) } fun courses( token: String, onComplete: (Result>) -> Unit, - ): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "courses")) } + ): CancellableRequest = submitObject(onComplete, token) { parseCourses(getBlocking(it, "api/v1/courses")) } fun enroll( token: String, videoHash: String, onComplete: (Result) -> Unit, ): CancellableRequest = submitUnit(onComplete, token) { - request("courses/$videoHash/enroll", token, method = "POST") + request("api/v1/courses/$videoHash/enroll", token, method = "POST") } fun results( token: String, onComplete: (Result>) -> Unit, - ): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "me/results")) } + ): CancellableRequest = submitObject(onComplete, token) { parseResults(getBlocking(it, "api/v1/me/results")) } fun dubShares( token: String, onComplete: (Result>) -> Unit, - ): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "me/dub-shares")) } + ): CancellableRequest = submitObject(onComplete, token) { parseDubShares(getBlocking(it, "api/v1/me/dub-shares")) } fun release() { executor.shutdownNow() @@ -163,6 +173,22 @@ class UserApi internal constructor( return parseSession(JSONObject(response)) } + private fun requestRegisterBlocking( + path: String, + username: String, + password: String, + nickname: String?, + phone: String?, + ): AuthSession { + val root = JSONObject() + .put("username", username) + .put("password", password) + nickname?.takeIf { it.isNotBlank() }?.let { root.put("nickname", it) } + phone?.takeIf { it.isNotBlank() }?.let { root.put("phone", it) } + val response = request(path, body = root.toString(), method = "POST") + return parseSession(JSONObject(response)) + } + private fun getBlocking(token: String, path: String): JSONArray { val body = request(path, token = token, method = "GET") return JSONArray(body) diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index dbc1e6e..f62262a 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -115,6 +115,12 @@ class MainActivity : Activity() { private var authUsernameInput: EditText? = null private var authPasswordInput: EditText? = null private var authStatusText: TextView? = null + private var regUsernameInput: EditText? = null + private var regPasswordInput: EditText? = null + private var regNicknameInput: EditText? = null + private var regPhoneInput: EditText? = null + private var regStatusText: TextView? = null + private var showingRegisterPage = false private var myContentList: LinearLayout? = null private var sentenceBoundaryRequestGeneration = 0 private var catalogVideos: List = emptyList() @@ -320,7 +326,13 @@ class MainActivity : Activity() { } private fun setRootView() { - setContentView(if (isLoggedIn()) createContentView() else createAuthView()) + setContentView( + when { + isLoggedIn() -> createContentView() + showingRegisterPage -> createRegisterView() + else -> createAuthView() + } + ) if (isLoggedIn()) { renderCatalog() refreshCurrentUi() @@ -375,13 +387,137 @@ class MainActivity : Activity() { }) addView(Button(this@MainActivity).apply { text = "注册" - setOnClickListener { authenticate(register = true) } + setOnClickListener { + showingRegisterPage = true + setRootView() + } layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0) }) addView(authStatusText) } } + private fun createRegisterView(): View { + return ScrollView(this).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + setBackgroundColor(COLOR_BACKGROUND) + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(28.dp, 60.dp, 28.dp, 28.dp) + + addView(TextView(this@MainActivity).apply { + text = "注册账号" + textSize = 28f + typeface = Typeface.DEFAULT_BOLD + setTextColor(Color.WHITE) + }) + addView(TextView(this@MainActivity).apply { + text = "填写以下信息完成注册" + textSize = 14f + setTextColor(COLOR_TEXT_MUTED) + setPadding(0, 6.dp, 0, 24.dp) + }) + + addView(fieldLabel("用户名")) + regUsernameInput = EditText(this@MainActivity).apply { + hint = "3-50 位字母、数字或下划线" + setSingleLine(true) + } + addView(regUsernameInput) + + addView(fieldLabel("密码")) + regPasswordInput = EditText(this@MainActivity).apply { + hint = "至少 8 位" + inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + setSingleLine(true) + } + addView(regPasswordInput) + + addView(fieldLabel("姓名")) + regNicknameInput = EditText(this@MainActivity).apply { + hint = "你的真实姓名或昵称" + setSingleLine(true) + } + addView(regNicknameInput) + + addView(fieldLabel("手机号")) + regPhoneInput = EditText(this@MainActivity).apply { + hint = "选填" + inputType = android.text.InputType.TYPE_CLASS_PHONE + setSingleLine(true) + } + addView(regPhoneInput) + + regStatusText = TextView(this@MainActivity).apply { + setTextColor(COLOR_ACCENT_LIGHT) + textSize = 13f + setPadding(0, 14.dp, 0, 0) + } + + addView(Button(this@MainActivity).apply { + text = "注册" + background = rounded(COLOR_ACCENT, 8f) + setTextColor(Color.WHITE) + setOnClickListener { submitRegistration() } + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 48.dp).withMargins(0, 20.dp, 0, 8.dp) + }) + addView(Button(this@MainActivity).apply { + text = "返回登录" + setOnClickListener { + showingRegisterPage = false + regStatusText = null + setRootView() + } + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 44.dp).withMargins(0, 0, 0, 0) + }) + addView(regStatusText) + }) + } + } + + private fun fieldLabel(label: String): TextView { + return TextView(this).apply { + text = label + textSize = 13f + setTextColor(COLOR_TEXT_MUTED) + setPadding(0, 12.dp, 0, 4.dp) + } + } + + private fun submitRegistration() { + val username = regUsernameInput?.text?.toString()?.trim().orEmpty() + val password = regPasswordInput?.text?.toString().orEmpty() + val nickname = regNicknameInput?.text?.toString()?.trim().orEmpty() + val phone = regPhoneInput?.text?.toString()?.trim().orEmpty() + + if (username.length < 3) { + regStatusText?.text = "用户名至少 3 个字符" + return + } + if (password.length < 8) { + regStatusText?.text = "密码至少 8 位" + return + } + if (nickname.isBlank()) { + regStatusText?.text = "请填写姓名" + return + } + + regStatusText?.text = "注册中..." + sdk.userApi.register(username, password, nickname, phone) { result -> + result.fold( + onSuccess = { session -> + showingRegisterPage = false + saveAuthSession(session) + }, + onFailure = { error -> + regStatusText?.text = "注册失败:${error.message}" + }, + ) + } + } + private fun authenticate(register: Boolean) { val username = authUsernameInput?.text?.toString()?.trim().orEmpty() val password = authPasswordInput?.text?.toString().orEmpty() @@ -392,7 +528,7 @@ class MainActivity : Activity() { onFailure = { error -> authStatusText?.text = "失败:${error.message}" }, ) } - if (register) sdk.userApi.register(username, password, callback) + if (register) sdk.userApi.register(username, password, onComplete = callback) else sdk.userApi.login(username, password, callback) } @@ -1473,6 +1609,7 @@ class MainActivity : Activity() { } override fun onMediaChanged(item: TrainingMediaItem?) { + if (!::lessonTitleText.isInitialized) return lessonTitleText.text = item?.title ?: "未选择课程" currentSentenceCount = item?.sentences?.size ?: 0 } @@ -1482,6 +1619,7 @@ class MainActivity : Activity() { } override fun onGesture(event: GestureEvent) { + if (!::statusText.isInitialized) return statusText.text = when (event.kind) { GestureKind.SINGLE_TAP -> "播放状态已切换" GestureKind.SWIPE_LEFT -> "已跳到上一句" @@ -1491,12 +1629,18 @@ class MainActivity : Activity() { } override fun onPlayerError(error: Throwable) { + if (!::statusText.isInitialized) return statusText.text = "播放失败:${error.message.orEmpty()}" } } } private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) { + if (!::timeText.isInitialized || !::progressBar.isInitialized || + !::speedText.isInitialized || !::statusText.isInitialized + ) { + return + } if (!progressBarDragging) { timeText.text = "${formatTime(snapshot.positionMs)} / ${formatTime(snapshot.durationMs)}" progressBar.progress = playbackProgress(snapshot) @@ -1514,6 +1658,11 @@ class MainActivity : Activity() { } private fun applySentence(sentence: SentenceBoundary?) { + if (!::sentenceMetaText.isInitialized || !::sentenceText.isInitialized || + !::lessonTitleText.isInitialized + ) { + return + } if (sentence == null) { sentenceMetaText.text = "暂无句子边界" sentenceText.text = lessonTitleText.text @@ -1606,6 +1755,11 @@ class MainActivity : Activity() { } private fun createCourseSection(): View { + if (!::catalogList.isInitialized) { + catalogList = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + } return ScrollView(this).apply { layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) addView(LinearLayout(this@MainActivity).apply { diff --git a/sentence_api/main.py b/sentence_api/main.py index faadb90..e77c2c0 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -165,7 +165,8 @@ def create_app( user = video_repository.create_user( username=payload.username, password=payload.password, - nickname=payload.username, + nickname=payload.nickname or payload.username, + phone=payload.phone, ) if user is None: raise HTTPException(status_code=409, detail="Username is already taken.") diff --git a/sentence_api/models.py b/sentence_api/models.py index 808fe01..32d25e5 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -138,12 +138,15 @@ class AuthRequest(BaseModel): username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$") password: str = Field(min_length=8, max_length=128) + nickname: Optional[str] = Field(default=None, max_length=80) + phone: Optional[str] = Field(default=None, max_length=20) class UserPublic(BaseModel): id: str username: str nickname: str + phone: Optional[str] = None created_at: datetime diff --git a/sentence_api/repository.py b/sentence_api/repository.py index 8480c15..81cd404 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -75,6 +75,7 @@ class VideoRepository: id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, nickname TEXT NOT NULL, + phone TEXT, password_hash TEXT NOT NULL, created_at TEXT NOT NULL ); @@ -147,6 +148,14 @@ class VideoRepository: } if "user_id" not in attempt_columns: connection.execute("ALTER TABLE attempts ADD COLUMN user_id TEXT NOT NULL DEFAULT ''") + + with self._connect() as connection: + user_columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(users)") + } + if "phone" not in user_columns: + connection.execute("ALTER TABLE users ADD COLUMN phone TEXT") connection.execute("CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id)") with self._connect() as connection: @@ -750,20 +759,28 @@ class VideoRepository: ), ) - def create_user(self, *, username: str, password: str, nickname: Optional[str]) -> Optional[Dict[str, Any]]: + def create_user( + self, + *, + username: str, + password: str, + nickname: Optional[str] = None, + phone: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: user_id = uuid.uuid4().hex now = utc_now() try: with self._connect() as connection: row = connection.execute( """ - INSERT INTO users (id, username, nickname, password_hash, created_at) - VALUES (?, ?, ?, ?, ?) + INSERT INTO users (id, username, nickname, phone, password_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?) """, ( user_id, username.strip().lower(), (nickname or username).strip()[:80], + (phone or "").strip()[:20] or None, hash_password(password), now, ), @@ -783,7 +800,7 @@ class VideoRepository: def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: row = connection.execute( - "SELECT id, username, nickname, created_at FROM users WHERE id = ?", + "SELECT id, username, nickname, phone, created_at FROM users WHERE id = ?", (user_id,), ).fetchone() return dict(row) if row else None