Commit 79a2a1e90d67cf178d44c735548a95bac8d0ce24

Authored by yibo
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(&quot;th[data-sort]&quot;).forEach((th) =&gt; { @@ -248,6 +280,17 @@ document.querySelectorAll(&quot;th[data-sort]&quot;).forEach((th) =&gt; {
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(&quot;addBtn&quot;).addEventListener(&quot;click&quot;, async () =&gt; { @@ -256,6 +299,7 @@ document.getElementById(&quot;addBtn&quot;).addEventListener(&quot;click&quot;, async () =&gt; {
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(&quot;addBtn&quot;).addEventListener(&quot;click&quot;, async () =&gt; { @@ -264,7 +308,7 @@ document.getElementById(&quot;addBtn&quot;).addEventListener(&quot;click&quot;, async () =&gt; {
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 = "";