diff --git a/sentence_api/main.py b/sentence_api/main.py index e77c2c0..4961e0a 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -43,6 +43,7 @@ from .models import ( UserDubShareSummary, UserPublic, UserResultSummary, + UserAdminListResponse, VideoDetailResponse, VideoListResponse, VideoSummary, @@ -249,6 +250,26 @@ def create_app( # 后台页面本身不缓存,静态 JS 通过版本号刷新,避免发布后浏览器继续跑旧逻辑。 return FileResponse(page, headers={"Cache-Control": "no-cache"}) + @application.get( + "/api/v1/admin/users", + response_model=UserAdminListResponse, + dependencies=[Depends(require_admin)], + ) + def list_registered_users() -> UserAdminListResponse: + return UserAdminListResponse( + users=video_repository.list_users() + ) + + @application.delete("/api/v1/admin/users/{user_id}", status_code=204) + def delete_registered_user( + user_id: str, + _: None = Depends(require_admin), + ) -> Response: + deleted = video_repository.delete_user(user_id) + if not deleted: + raise HTTPException(status_code=404, detail="User was not found.") + return Response(status_code=204) + @application.get("/api/v1/videos", response_model=VideoListResponse) def list_videos() -> VideoListResponse: return VideoListResponse( diff --git a/sentence_api/models.py b/sentence_api/models.py index 32d25e5..ea482ef 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -155,6 +155,19 @@ class AuthResponse(BaseModel): user: UserPublic +class UserAdminItem(BaseModel): + id: str + username: str + nickname: str + phone: Optional[str] = None + created_at: datetime + enrollment_count: int = 0 + + +class UserAdminListResponse(BaseModel): + users: List[UserAdminItem] + + class CourseSummary(BaseModel): video_hash: str title: str diff --git a/sentence_api/repository.py b/sentence_api/repository.py index 81cd404..5b6eff9 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -805,6 +805,25 @@ class VideoRepository: ).fetchone() return dict(row) if row else None + def list_users(self) -> List[Dict[str, Any]]: + with self._connect() as connection: + rows = connection.execute( + """ + SELECT u.id, u.username, u.nickname, u.phone, u.created_at, + COUNT(DISTINCT e.video_hash) AS enrollment_count + FROM users u + LEFT JOIN enrollments e ON e.user_id = u.id + GROUP BY u.id + ORDER BY u.created_at DESC + """ + ).fetchall() + return [dict(row) for row in rows] + + def delete_user(self, user_id: str) -> bool: + with self._connect() as connection: + cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,)) + return cursor.rowcount > 0 + def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]: with self._connect() as connection: row = connection.execute( diff --git a/sentence_api/static/admin.html b/sentence_api/static/admin.html index 1281135..7317d61 100644 --- a/sentence_api/static/admin.html +++ b/sentence_api/static/admin.html @@ -71,6 +71,29 @@ + +
+
+

用户管理

+ 0 个用户 +
+
+ + + + + + + + + + + + +
用户名姓名手机号选修课程注册时间操作
+
+ +
diff --git a/sentence_api/static/admin.js b/sentence_api/static/admin.js index 8ac7e0a..33c5ddd 100644 --- a/sentence_api/static/admin.js +++ b/sentence_api/static/admin.js @@ -1,6 +1,7 @@ const state = { adminKey: localStorage.getItem("oralTrainerAdminKey") || "", videos: [], + users: [], }; const elements = { @@ -21,6 +22,9 @@ const elements = { sentenceMeta: document.querySelector("#sentence-meta"), sentenceRows: document.querySelector("#sentence-rows"), closeDialog: document.querySelector("#close-dialog"), + userRows: document.querySelector("#user-rows"), + userCount: document.querySelector("#user-count"), + userEmptyState: document.querySelector("#user-empty-state"), }; elements.adminKey.value = state.adminKey; @@ -29,7 +33,10 @@ elements.saveKey.addEventListener("click", () => { localStorage.setItem("oralTrainerAdminKey", state.adminKey); elements.serviceState.textContent = "密钥已保存"; }); -elements.refresh.addEventListener("click", loadVideos); +elements.refresh.addEventListener("click", () => { + loadVideos(); + loadUsers(); +}); elements.closeDialog.addEventListener("click", () => elements.dialog.close()); elements.uploadForm.addEventListener("submit", uploadVideo); elements.videoFile.addEventListener("change", () => { @@ -90,6 +97,54 @@ function renderVideos() { } } +async function loadUsers() { + try { + const payload = await api("/api/v1/admin/users"); + state.users = payload.users; + renderUsers(); + } catch (error) { + elements.serviceState.textContent = `加载用户失败: ${error.message}`; + } +} + +function renderUsers() { + elements.userRows.replaceChildren(); + elements.userCount.textContent = `${state.users.length} 个用户`; + elements.userEmptyState.hidden = state.users.length > 0; + state.users.forEach((user) => { + const row = document.createElement("tr"); + row.append( + textCell(user.username), + textCell(user.nickname), + textCell(user.phone || "—"), + textCell(String(user.enrollment_count ?? 0)), + textCell(formatDate(user.created_at)), + userActionCell(user), + ); + elements.userRows.append(row); + }); +} + +function userActionCell(user) { + const cell = document.createElement("td"); + cell.classList.add("actions-column"); + cell.append( + button("删除", async () => { + const confirmed = window.confirm( + `确定要删除用户「${user.nickname || user.username}」吗?该操作不可恢复。` + ); + if (!confirmed) return; + try { + await api(`/api/v1/admin/users/${user.id}`, { method: "DELETE" }); + await loadUsers(); + } catch (error) { + elements.serviceState.textContent = `删除用户失败: ${error.message}`; + } + }), + ); + return cell; +} + function textCell(value) { const cell = document.createElement("td"); cell.textContent = value; @@ -352,6 +407,7 @@ function formatDate(value) { checkHealth(); loadVideos(); +loadUsers(); window.setInterval(() => { if (!elements.dialog.open) loadVideos(); }, 5000);