add admin user management: list and delete registered users
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -71,6 +71,29 @@
|
||||
</div>
|
||||
<div id="empty-state" class="empty-state" hidden>暂无视频</div>
|
||||
</section>
|
||||
|
||||
<section class="video-band" aria-labelledby="users-heading">
|
||||
<div class="section-heading">
|
||||
<h2 id="users-heading">用户管理</h2>
|
||||
<span id="user-count">0 个用户</span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>姓名</th>
|
||||
<th>手机号</th>
|
||||
<th>选修课程</th>
|
||||
<th>注册时间</th>
|
||||
<th class="actions-column">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="user-empty-state" class="empty-state" hidden>暂无用户</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="sentence-dialog">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user