Compare commits

...

4 Commits

6 changed files with 259 additions and 25 deletions

View File

@@ -42,6 +42,7 @@ import android.widget.ScrollView
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.Switch import android.widget.Switch
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import cn.learningpad.oraltrainer.sdk.GestureEvent import cn.learningpad.oraltrainer.sdk.GestureEvent
import cn.learningpad.oraltrainer.sdk.GestureKind import cn.learningpad.oraltrainer.sdk.GestureKind
import cn.learningpad.oraltrainer.sdk.AuthSession import cn.learningpad.oraltrainer.sdk.AuthSession
@@ -127,6 +128,7 @@ class MainActivity : Activity() {
private val courseEnrollment = mutableMapOf<String, Boolean>() private val courseEnrollment = mutableMapOf<String, Boolean>()
private var catalogStatusTextValue = "正在同步" private var catalogStatusTextValue = "正在同步"
private var activeModule = Module.TRAIN private var activeModule = Module.TRAIN
private var currentCourse: TrainingVideoSummary? = null
private var continuousPlaybackEnabled = false private var continuousPlaybackEnabled = false
private var testSubtitleVisible = false private var testSubtitleVisible = false
private var landscapeSubtitleVisible = true private var landscapeSubtitleVisible = true
@@ -550,6 +552,7 @@ class MainActivity : Activity() {
authToken = "" authToken = ""
authNickname = "" authNickname = ""
sdk.config.userToken = null sdk.config.userToken = null
currentCourse = null
controller.pause() controller.pause()
activeModule = Module.TRAIN activeModule = Module.TRAIN
setRootView() setRootView()
@@ -615,6 +618,10 @@ class MainActivity : Activity() {
if (activeModule == module) { if (activeModule == module) {
return@setOnClickListener return@setOnClickListener
} }
if (module != Module.COURSE && currentCourse == null) {
Toast.makeText(this@MainActivity, "请先选择课程", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
if (module == Module.COURSE || module == Module.MINE) { if (module == Module.COURSE || module == Module.MINE) {
loadUserData() loadUserData()
} }
@@ -948,7 +955,7 @@ class MainActivity : Activity() {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
setPadding(0, 10.dp, 0, 4.dp) setPadding(0, 10.dp, 0, 4.dp)
} }
// Fresh instances each time — safe to addView.
val header = LinearLayout(this).apply { val header = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
@@ -1678,6 +1685,8 @@ class MainActivity : Activity() {
} }
private fun refreshCurrentUi() { private fun refreshCurrentUi() {
if (activeModule != Module.TRAIN && activeModule != Module.TEST) return
if (!::lessonTitleText.isInitialized) return
val item = controller.currentTrainingItem() val item = controller.currentTrainingItem()
lessonTitleText.text = item?.title ?: "未选择课程" lessonTitleText.text = item?.title ?: "未选择课程"
currentSentenceCount = item?.sentences?.size ?: 0 currentSentenceCount = item?.sentences?.size ?: 0
@@ -1755,28 +1764,35 @@ class MainActivity : Activity() {
} }
private fun createCourseSection(): View { private fun createCourseSection(): View {
if (!::catalogList.isInitialized) { catalogStatusText = TextView(this).apply {
setTextColor(COLOR_TEXT_MUTED)
textSize = 13f
text = catalogStatusTextValue
setPadding(16.dp, 0.dp, 16.dp, 8.dp)
}
catalogList = LinearLayout(this).apply { catalogList = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
} setPadding(16.dp, 8.dp, 16.dp, 16.dp)
} }
return ScrollView(this).apply { return ScrollView(this).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
addView(LinearLayout(this@MainActivity).apply { addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setPadding(16.dp, 12.dp, 16.dp, 16.dp) setPadding(0.dp, 12.dp, 0.dp, 16.dp)
addView(TextView(this@MainActivity).apply { addView(TextView(this@MainActivity).apply {
text = "课程首页" text = "课程首页"
textSize = 26f textSize = 26f
typeface = Typeface.DEFAULT_BOLD typeface = Typeface.DEFAULT_BOLD
setTextColor(Color.WHITE) setTextColor(Color.WHITE)
setPadding(16.dp, 0.dp, 16.dp, 0.dp)
}) })
addView(TextView(this@MainActivity).apply { addView(TextView(this@MainActivity).apply {
text = "选择一门课程开始训练" text = "选择一门课程开始训练"
textSize = 13f textSize = 13f
setTextColor(COLOR_TEXT_MUTED) setTextColor(COLOR_TEXT_MUTED)
setPadding(0, 4.dp, 0, 16.dp) setPadding(16.dp, 4.dp, 16.dp, 8.dp)
}) })
addView(catalogStatusText)
addView(catalogList) addView(catalogList)
}) })
} }
@@ -1816,15 +1832,35 @@ class MainActivity : Activity() {
private fun refreshMyContent() { private fun refreshMyContent() {
val list = myContentList ?: return val list = myContentList ?: return
list.removeAllViews() list.removeAllViews()
val course = currentCourse
if (course == null) {
list.addView(infoCard("请先在「课程」中选择一门课程"))
return
}
list.addView(TextView(this).apply {
text = "当前课程:${course.title}"
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, 0, 0, 8.dp)
})
list.addView(infoCard("加载中...")) list.addView(infoCard("加载中..."))
sdk.userApi.results(authToken) { result -> sdk.userApi.results(authToken) { result ->
list.removeAllViews() list.removeAllViews()
list.addView(TextView(this).apply {
text = "当前课程:${course.title}"
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, 0, 0, 8.dp)
})
result.fold( result.fold(
onSuccess = { rows -> onSuccess = { rows ->
if (rows.isEmpty()) list.addView(infoCard("还没有测试记录")) val filtered = rows.filter { it.videoHash == course.videoHash }
rows.forEach { row -> if (filtered.isEmpty()) list.addView(infoCard("该课程还没有测试记录"))
filtered.forEach { row ->
list.addView(infoCard( list.addView(infoCard(
"${row.courseTitle} · 第 ${row.sentenceIndex + 1} 句\n" + "第 ${row.sentenceIndex + 1} 句\n" +
"得分 ${row.overallScore.toInt()} · ${if (row.passed) "通过" else "未通过"}" "得分 ${row.overallScore.toInt()} · ${if (row.passed) "通过" else "未通过"}"
)) ))
} }
@@ -1836,10 +1872,11 @@ class MainActivity : Activity() {
list.addView(titleDivider("我的配音分享")) list.addView(titleDivider("我的配音分享"))
shareResult.fold( shareResult.fold(
onSuccess = { shares -> onSuccess = { shares ->
if (shares.isEmpty()) list.addView(infoCard("还没有分享配音")) val filtered = shares.filter { it.videoHash == course.videoHash }
shares.forEach { row -> if (filtered.isEmpty()) list.addView(infoCard("该课程还没有分享配音"))
filtered.forEach { row ->
list.addView(infoCard( list.addView(infoCard(
"${row.title}\n${row.courseTitle} · ${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()} 分" "${row.title}\n${row.segmentCount} 句 · 平均 ${row.averageScore.toInt()} 分"
)) ))
} }
}, },
@@ -1879,6 +1916,18 @@ class MainActivity : Activity() {
return return
} }
catalogList.removeAllViews() catalogList.removeAllViews()
if (activeModule == Module.COURSE) {
if (catalogVideos.isEmpty()) {
catalogList.addView(infoCard("暂无课程"))
return
}
catalogVideos.forEach { video ->
catalogList.addView(courseListCard(video) {
selectCourse(video)
})
}
return
}
catalogVideos.forEach { video -> catalogVideos.forEach { video ->
val enrolled = courseEnrollment[video.videoHash] == true val enrolled = courseEnrollment[video.videoHash] == true
catalogList.addView( catalogList.addView(
@@ -1897,6 +1946,59 @@ class MainActivity : Activity() {
} }
} }
private fun courseListCard(
video: TrainingVideoSummary,
onClick: () -> Unit,
): View {
val enrolled = courseEnrollment[video.videoHash] == true
val isSelected = currentCourse?.videoHash == video.videoHash
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
isClickable = true
background = if (isSelected) {
rounded(COLOR_SELECTED, 10f, COLOR_ACCENT)
} else {
rounded(COLOR_SURFACE, 10f, COLOR_BORDER)
}
setPadding(16.dp, 14.dp, 16.dp, 14.dp)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).withMargins(0, 0, 0, 10.dp)
setOnClickListener { onClick() }
addView(TextView(this@MainActivity).apply {
text = video.title
setTextColor(if (isSelected) COLOR_TEXT_DARK else Color.WHITE)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
maxLines = 2
})
addView(TextView(this@MainActivity).apply {
text = buildString {
append(if (enrolled) "已选修" else "未选修")
append(" · ${video.sentenceCount} 句")
if (isSelected) append(" · 当前课程")
}
setTextColor(if (isSelected) COLOR_ACCENT_DEEP else COLOR_TEXT_MUTED)
textSize = 13f
setPadding(0, 6.dp, 0, 0)
})
}
}
private fun selectCourse(video: TrainingVideoSummary) {
currentCourse = video
if (courseEnrollment[video.videoHash] != true) {
enrollCourse(video)
}
activeModule = Module.TRAIN
landscapeSubtitleVisible = true
setContentView(createContentView())
renderCatalog()
refreshCurrentUi()
loadRemoteVideo(video)
}
private fun enrollCourse(video: TrainingVideoSummary) { private fun enrollCourse(video: TrainingVideoSummary) {
sdk.userApi.enroll(authToken, video.videoHash) { result -> sdk.userApi.enroll(authToken, video.videoHash) { result ->
result.fold( result.fold(
@@ -1915,10 +2017,10 @@ class MainActivity : Activity() {
private fun loadRemoteVideo(video: TrainingVideoSummary) { private fun loadRemoteVideo(video: TrainingVideoSummary) {
activeItemId = video.videoHash activeItemId = video.videoHash
lessonTitleText.text = video.title if (::lessonTitleText.isInitialized) lessonTitleText.text = video.title
sentenceMetaText.text = "句子边界加载中" if (::sentenceMetaText.isInitialized) sentenceMetaText.text = "句子边界加载中"
sentenceText.text = video.title if (::sentenceText.isInitialized) sentenceText.text = video.title
statusText.text = "正在加载云端课程..." if (::statusText.isInitialized) statusText.text = "正在加载云端课程..."
renderCatalog() renderCatalog()
val loadingItem = video.toTrainingMediaItem() val loadingItem = video.toTrainingMediaItem()

View File

@@ -43,6 +43,7 @@ from .models import (
UserDubShareSummary, UserDubShareSummary,
UserPublic, UserPublic,
UserResultSummary, UserResultSummary,
UserAdminListResponse,
VideoDetailResponse, VideoDetailResponse,
VideoListResponse, VideoListResponse,
VideoSummary, VideoSummary,
@@ -249,6 +250,26 @@ def create_app(
# 后台页面本身不缓存,静态 JS 通过版本号刷新,避免发布后浏览器继续跑旧逻辑。 # 后台页面本身不缓存,静态 JS 通过版本号刷新,避免发布后浏览器继续跑旧逻辑。
return FileResponse(page, headers={"Cache-Control": "no-cache"}) 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) @application.get("/api/v1/videos", response_model=VideoListResponse)
def list_videos() -> VideoListResponse: def list_videos() -> VideoListResponse:
return VideoListResponse( return VideoListResponse(

View File

@@ -155,6 +155,19 @@ class AuthResponse(BaseModel):
user: UserPublic 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): class CourseSummary(BaseModel):
video_hash: str video_hash: str
title: str title: str

View File

@@ -805,6 +805,25 @@ class VideoRepository:
).fetchone() ).fetchone()
return dict(row) if row else None 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]]: def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
with self._connect() as connection: with self._connect() as connection:
row = connection.execute( row = connection.execute(

View File

@@ -71,6 +71,29 @@
</div> </div>
<div id="empty-state" class="empty-state" hidden>暂无视频</div> <div id="empty-state" class="empty-state" hidden>暂无视频</div>
</section> </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> </main>
<dialog id="sentence-dialog"> <dialog id="sentence-dialog">

View File

@@ -1,6 +1,7 @@
const state = { const state = {
adminKey: localStorage.getItem("oralTrainerAdminKey") || "", adminKey: localStorage.getItem("oralTrainerAdminKey") || "",
videos: [], videos: [],
users: [],
}; };
const elements = { const elements = {
@@ -21,6 +22,9 @@ const elements = {
sentenceMeta: document.querySelector("#sentence-meta"), sentenceMeta: document.querySelector("#sentence-meta"),
sentenceRows: document.querySelector("#sentence-rows"), sentenceRows: document.querySelector("#sentence-rows"),
closeDialog: document.querySelector("#close-dialog"), 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; elements.adminKey.value = state.adminKey;
@@ -29,7 +33,10 @@ elements.saveKey.addEventListener("click", () => {
localStorage.setItem("oralTrainerAdminKey", state.adminKey); localStorage.setItem("oralTrainerAdminKey", state.adminKey);
elements.serviceState.textContent = "密钥已保存"; elements.serviceState.textContent = "密钥已保存";
}); });
elements.refresh.addEventListener("click", loadVideos); elements.refresh.addEventListener("click", () => {
loadVideos();
loadUsers();
});
elements.closeDialog.addEventListener("click", () => elements.dialog.close()); elements.closeDialog.addEventListener("click", () => elements.dialog.close());
elements.uploadForm.addEventListener("submit", uploadVideo); elements.uploadForm.addEventListener("submit", uploadVideo);
elements.videoFile.addEventListener("change", () => { 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) { function textCell(value) {
const cell = document.createElement("td"); const cell = document.createElement("td");
cell.textContent = value; cell.textContent = value;
@@ -352,6 +407,7 @@ function formatDate(value) {
checkHealth(); checkHealth();
loadVideos(); loadVideos();
loadUsers();
window.setInterval(() => { window.setInterval(() => {
if (!elements.dialog.open) loadVideos(); if (!elements.dialog.open) loadVideos();
}, 5000); }, 5000);