Compare commits
4 Commits
4ae77de307
...
3652ebda07
| Author | SHA1 | Date | |
|---|---|---|---|
| 3652ebda07 | |||
| 4a61afd5a9 | |||
| e80a770847 | |||
| a652a39b6c |
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user