Commit 79a2a1e90d67cf178d44c735548a95bac8d0ce24
1 parent
e96d6894
Split admin.html's video library into per-channel management
live.html and dance.html already have independent scheduling pools (architecture.md 2.1), distinguished by clips' channel field, but admin.html rendered everything as one flat table with no way to tell the two apart or scope edits to one room. Add a 视频直播间/跳舞直播间 tab switcher that filters the table by channel, lets manual adds pick which room they land in, and lets existing rows be reclassified via the edit form's new channel select (backed by server.js's existing channel support on POST/PATCH /api/admin/library, committed in e96d689 as part of an unrelated concurrent session's work). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing
1 changed file
with
47 additions
and
3 deletions
admin.html
| @@ -23,6 +23,8 @@ | @@ -23,6 +23,8 @@ | ||
| 23 | .row-4 { display: grid; grid-template-columns: 1fr 1fr 1.6fr 100px; gap: 14px; } | 23 | .row-4 { display: grid; grid-template-columns: 1fr 1fr 1.6fr 100px; gap: 14px; } |
| 24 | .sort-arrow { font-size: 10px; } | 24 | .sort-arrow { font-size: 10px; } |
| 25 | .count-hint { color: var(--muted); font-size: 12px; margin-bottom: 10px; } | 25 | .count-hint { color: var(--muted); font-size: 12px; margin-bottom: 10px; } |
| 26 | + .channel-tabs { display: flex; gap: 8px; margin-bottom: 14px; } | ||
| 27 | + .channel-tabs button.active { background: var(--accent); color: #fff; border-color: var(--accent); } | ||
| 26 | </style> | 28 | </style> |
| 27 | </head> | 29 | </head> |
| 28 | <body> | 30 | <body> |
| @@ -37,6 +39,11 @@ | @@ -37,6 +39,11 @@ | ||
| 37 | </header> | 39 | </header> |
| 38 | 40 | ||
| 39 | <main class="admin-wrap"> | 41 | <main class="admin-wrap"> |
| 42 | + <div class="channel-tabs"> | ||
| 43 | + <button id="tabVideo" class="secondary">视频直播间</button> | ||
| 44 | + <button id="tabDance" class="secondary">跳舞直播间</button> | ||
| 45 | + </div> | ||
| 46 | + | ||
| 40 | <div class="card add-card"> | 47 | <div class="card add-card"> |
| 41 | <div class="field"><label>新增片段(手动指定视频地址)</label></div> | 48 | <div class="field"><label>新增片段(手动指定视频地址)</label></div> |
| 42 | <div class="row-4"> | 49 | <div class="row-4"> |
| @@ -46,6 +53,13 @@ | @@ -46,6 +53,13 @@ | ||
| 46 | <input type="number" id="newDuration" placeholder="时长(秒)" /> | 53 | <input type="number" id="newDuration" placeholder="时长(秒)" /> |
| 47 | </div> | 54 | </div> |
| 48 | <div class="field" style="margin-top:8px;"> | 55 | <div class="field" style="margin-top:8px;"> |
| 56 | + <label>归属直播间</label> | ||
| 57 | + <select id="newChannel"> | ||
| 58 | + <option value="video">视频直播间</option> | ||
| 59 | + <option value="dance">跳舞直播间</option> | ||
| 60 | + </select> | ||
| 61 | + </div> | ||
| 62 | + <div class="field" style="margin-top:8px;"> | ||
| 49 | <textarea id="newFullPrompt" placeholder="完整 prompt(可选,留空则用名称)" style="min-height:60px;"></textarea> | 63 | <textarea id="newFullPrompt" placeholder="完整 prompt(可选,留空则用名称)" style="min-height:60px;"></textarea> |
| 50 | </div> | 64 | </div> |
| 51 | <button id="addBtn" style="margin-top:8px;">新增</button> | 65 | <button id="addBtn" style="margin-top:8px;">新增</button> |
| @@ -70,6 +84,7 @@ | @@ -70,6 +84,7 @@ | ||
| 70 | <th data-sort="promptEn">名称(英)</th> | 84 | <th data-sort="promptEn">名称(英)</th> |
| 71 | <th data-sort="fullPrompt">Prompt</th> | 85 | <th data-sort="fullPrompt">Prompt</th> |
| 72 | <th>视频地址</th> | 86 | <th>视频地址</th> |
| 87 | + <th>直播间</th> | ||
| 73 | <th>溯源(人物×衣服×参考视频)</th> | 88 | <th>溯源(人物×衣服×参考视频)</th> |
| 74 | <th data-sort="duration">时长</th> | 89 | <th data-sort="duration">时长</th> |
| 75 | <th data-sort="likes">点赞</th> | 90 | <th data-sort="likes">点赞</th> |
| @@ -103,6 +118,13 @@ let danceRefs = []; | @@ -103,6 +118,13 @@ let danceRefs = []; | ||
| 103 | let editingId = null; | 118 | let editingId = null; |
| 104 | let sortKey = "createdAt"; | 119 | let sortKey = "createdAt"; |
| 105 | let sortDir = -1; | 120 | let sortDir = -1; |
| 121 | +// 视频直播间(live.html) / 跳舞直播间(dance.html) 是两条独立的产品线, | ||
| 122 | +// 素材各自的排播池互不共享(architecture.md 2.1),所以后台也分开管理。 | ||
| 123 | +let activeChannel = "video"; | ||
| 124 | + | ||
| 125 | +function inChannel(c) { | ||
| 126 | + return activeChannel === "dance" ? c.channel === "dance" : c.channel !== "dance"; | ||
| 127 | +} | ||
| 106 | 128 | ||
| 107 | function escapeHtml(s) { | 129 | function escapeHtml(s) { |
| 108 | const div = document.createElement("div"); | 130 | const div = document.createElement("div"); |
| @@ -142,7 +164,7 @@ function traceability(c) { | @@ -142,7 +164,7 @@ function traceability(c) { | ||
| 142 | function filteredSorted() { | 164 | function filteredSorted() { |
| 143 | const q = document.getElementById("searchInput").value.trim().toLowerCase(); | 165 | const q = document.getElementById("searchInput").value.trim().toLowerCase(); |
| 144 | const source = document.getElementById("sourceFilter").value; | 166 | const source = document.getElementById("sourceFilter").value; |
| 145 | - let rows = library.slice(); | 167 | + let rows = library.filter(inChannel); |
| 146 | if (q) rows = rows.filter((c) => c.prompt.toLowerCase().includes(q) || (c.promptEn || "").toLowerCase().includes(q) || (c.fullPrompt || "").toLowerCase().includes(q) || c.id.toLowerCase().includes(q)); | 168 | if (q) rows = rows.filter((c) => c.prompt.toLowerCase().includes(q) || (c.promptEn || "").toLowerCase().includes(q) || (c.fullPrompt || "").toLowerCase().includes(q) || c.id.toLowerCase().includes(q)); |
| 147 | if (source) rows = rows.filter((c) => c.source === source); | 169 | if (source) rows = rows.filter((c) => c.source === source); |
| 148 | rows.sort((a, b) => { | 170 | rows.sort((a, b) => { |
| @@ -160,7 +182,10 @@ function videoHref(c) { | @@ -160,7 +182,10 @@ function videoHref(c) { | ||
| 160 | 182 | ||
| 161 | function render() { | 183 | function render() { |
| 162 | const rows = filteredSorted(); | 184 | const rows = filteredSorted(); |
| 163 | - document.getElementById("countHint").textContent = `共 ${library.length} 条,当前筛选 ${rows.length} 条`; | 185 | + const channelTotal = library.filter(inChannel).length; |
| 186 | + document.getElementById("countHint").textContent = `本直播间共 ${channelTotal} 条,当前筛选 ${rows.length} 条`; | ||
| 187 | + document.getElementById("tabVideo").classList.toggle("active", activeChannel === "video"); | ||
| 188 | + document.getElementById("tabDance").classList.toggle("active", activeChannel === "dance"); | ||
| 164 | document.querySelectorAll("th[data-sort]").forEach((th) => { | 189 | document.querySelectorAll("th[data-sort]").forEach((th) => { |
| 165 | th.innerHTML = th.textContent.replace(/ ?[▲▼]$/, "") + (th.dataset.sort === sortKey ? ` <span class="sort-arrow">${sortDir === 1 ? "▲" : "▼"}</span>` : ""); | 190 | th.innerHTML = th.textContent.replace(/ ?[▲▼]$/, "") + (th.dataset.sort === sortKey ? ` <span class="sort-arrow">${sortDir === 1 ? "▲" : "▼"}</span>` : ""); |
| 166 | }); | 191 | }); |
| @@ -175,6 +200,12 @@ function render() { | @@ -175,6 +200,12 @@ function render() { | ||
| 175 | <td class="name"><input data-field="promptEn" value="${escapeHtml(c.promptEn || "")}" /></td> | 200 | <td class="name"><input data-field="promptEn" value="${escapeHtml(c.promptEn || "")}" /></td> |
| 176 | <td class="prompt"><textarea data-field="fullPrompt">${escapeHtml(c.fullPrompt || "")}</textarea></td> | 201 | <td class="prompt"><textarea data-field="fullPrompt">${escapeHtml(c.fullPrompt || "")}</textarea></td> |
| 177 | <td><input data-field="videoUrl" value="${escapeHtml(c.videoUrl || "")}" placeholder="留空=保持原有播放源" /></td> | 202 | <td><input data-field="videoUrl" value="${escapeHtml(c.videoUrl || "")}" placeholder="留空=保持原有播放源" /></td> |
| 203 | + <td> | ||
| 204 | + <select data-field="channel"> | ||
| 205 | + <option value="video" ${c.channel === "dance" ? "" : "selected"}>视频直播间</option> | ||
| 206 | + <option value="dance" ${c.channel === "dance" ? "selected" : ""}>跳舞直播间</option> | ||
| 207 | + </select> | ||
| 208 | + </td> | ||
| 178 | <td>${escapeHtml(traceability(c))}</td> | 209 | <td>${escapeHtml(traceability(c))}</td> |
| 179 | <td>${c.duration ?? "-"}s</td> | 210 | <td>${c.duration ?? "-"}s</td> |
| 180 | <td>${c.likes || 0}</td> | 211 | <td>${c.likes || 0}</td> |
| @@ -193,6 +224,7 @@ function render() { | @@ -193,6 +224,7 @@ function render() { | ||
| 193 | <td class="name">${escapeHtml(c.promptEn || "")}</td> | 224 | <td class="name">${escapeHtml(c.promptEn || "")}</td> |
| 194 | <td class="prompt" title="${escapeHtml(c.fullPrompt || "")}">${escapeHtml((c.fullPrompt || "").slice(0, 80))}${(c.fullPrompt || "").length > 80 ? "…" : ""}</td> | 225 | <td class="prompt" title="${escapeHtml(c.fullPrompt || "")}">${escapeHtml((c.fullPrompt || "").slice(0, 80))}${(c.fullPrompt || "").length > 80 ? "…" : ""}</td> |
| 195 | <td><a class="video-link" href="${videoHref(c)}" target="_blank">${videoHref(c)}</a></td> | 226 | <td><a class="video-link" href="${videoHref(c)}" target="_blank">${videoHref(c)}</a></td> |
| 227 | + <td>${c.channel === "dance" ? "跳舞直播间" : "视频直播间"}</td> | ||
| 196 | <td>${escapeHtml(traceability(c))}</td> | 228 | <td>${escapeHtml(traceability(c))}</td> |
| 197 | <td>${c.duration ?? "-"}s</td> | 229 | <td>${c.duration ?? "-"}s</td> |
| 198 | <td>${c.likes || 0}</td> | 230 | <td>${c.likes || 0}</td> |
| @@ -248,6 +280,17 @@ document.querySelectorAll("th[data-sort]").forEach((th) => { | @@ -248,6 +280,17 @@ document.querySelectorAll("th[data-sort]").forEach((th) => { | ||
| 248 | document.getElementById("searchInput").addEventListener("input", render); | 280 | document.getElementById("searchInput").addEventListener("input", render); |
| 249 | document.getElementById("sourceFilter").addEventListener("change", render); | 281 | document.getElementById("sourceFilter").addEventListener("change", render); |
| 250 | 282 | ||
| 283 | +document.getElementById("tabVideo").addEventListener("click", () => { | ||
| 284 | + activeChannel = "video"; | ||
| 285 | + document.getElementById("newChannel").value = "video"; | ||
| 286 | + render(); | ||
| 287 | +}); | ||
| 288 | +document.getElementById("tabDance").addEventListener("click", () => { | ||
| 289 | + activeChannel = "dance"; | ||
| 290 | + document.getElementById("newChannel").value = "dance"; | ||
| 291 | + render(); | ||
| 292 | +}); | ||
| 293 | + | ||
| 251 | document.getElementById("addBtn").addEventListener("click", async () => { | 294 | document.getElementById("addBtn").addEventListener("click", async () => { |
| 252 | const errorBox = document.getElementById("addError"); | 295 | const errorBox = document.getElementById("addError"); |
| 253 | errorBox.hidden = true; | 296 | errorBox.hidden = true; |
| @@ -256,6 +299,7 @@ document.getElementById("addBtn").addEventListener("click", async () => { | @@ -256,6 +299,7 @@ document.getElementById("addBtn").addEventListener("click", async () => { | ||
| 256 | const videoUrl = document.getElementById("newVideoUrl").value.trim(); | 299 | const videoUrl = document.getElementById("newVideoUrl").value.trim(); |
| 257 | const fullPrompt = document.getElementById("newFullPrompt").value.trim(); | 300 | const fullPrompt = document.getElementById("newFullPrompt").value.trim(); |
| 258 | const duration = Number(document.getElementById("newDuration").value) || null; | 301 | const duration = Number(document.getElementById("newDuration").value) || null; |
| 302 | + const channel = document.getElementById("newChannel").value; | ||
| 259 | if (!prompt || !videoUrl) { | 303 | if (!prompt || !videoUrl) { |
| 260 | errorBox.hidden = false; | 304 | errorBox.hidden = false; |
| 261 | errorBox.textContent = "名称和视频地址必填"; | 305 | errorBox.textContent = "名称和视频地址必填"; |
| @@ -264,7 +308,7 @@ document.getElementById("addBtn").addEventListener("click", async () => { | @@ -264,7 +308,7 @@ document.getElementById("addBtn").addEventListener("click", async () => { | ||
| 264 | const res = await fetch("/api/admin/library", { | 308 | const res = await fetch("/api/admin/library", { |
| 265 | method: "POST", | 309 | method: "POST", |
| 266 | headers: { "Content-Type": "application/json" }, | 310 | headers: { "Content-Type": "application/json" }, |
| 267 | - body: JSON.stringify({ prompt, promptEn, videoUrl, fullPrompt, duration }), | 311 | + body: JSON.stringify({ prompt, promptEn, videoUrl, fullPrompt, duration, channel }), |
| 268 | }); | 312 | }); |
| 269 | if (res.ok) { | 313 | if (res.ok) { |
| 270 | document.getElementById("newPrompt").value = ""; | 314 | document.getElementById("newPrompt").value = ""; |