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 23 .row-4 { display: grid; grid-template-columns: 1fr 1fr 1.6fr 100px; gap: 14px; }
24 24 .sort-arrow { font-size: 10px; }
25 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 28 </style>
27 29 </head>
28 30 <body>
... ... @@ -37,6 +39,11 @@
37 39 </header>
38 40  
39 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 47 <div class="card add-card">
41 48 <div class="field"><label>新增片段(手动指定视频地址)</label></div>
42 49 <div class="row-4">
... ... @@ -46,6 +53,13 @@
46 53 <input type="number" id="newDuration" placeholder="时长(秒)" />
47 54 </div>
48 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 63 <textarea id="newFullPrompt" placeholder="完整 prompt(可选,留空则用名称)" style="min-height:60px;"></textarea>
50 64 </div>
51 65 <button id="addBtn" style="margin-top:8px;">新增</button>
... ... @@ -70,6 +84,7 @@
70 84 <th data-sort="promptEn">名称(英)</th>
71 85 <th data-sort="fullPrompt">Prompt</th>
72 86 <th>视频地址</th>
  87 + <th>直播间</th>
73 88 <th>溯源(人物×衣服×参考视频)</th>
74 89 <th data-sort="duration">时长</th>
75 90 <th data-sort="likes">点赞</th>
... ... @@ -103,6 +118,13 @@ let danceRefs = [];
103 118 let editingId = null;
104 119 let sortKey = "createdAt";
105 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 129 function escapeHtml(s) {
108 130 const div = document.createElement("div");
... ... @@ -142,7 +164,7 @@ function traceability(c) {
142 164 function filteredSorted() {
143 165 const q = document.getElementById("searchInput").value.trim().toLowerCase();
144 166 const source = document.getElementById("sourceFilter").value;
145   - let rows = library.slice();
  167 + let rows = library.filter(inChannel);
146 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 169 if (source) rows = rows.filter((c) => c.source === source);
148 170 rows.sort((a, b) => {
... ... @@ -160,7 +182,10 @@ function videoHref(c) {
160 182  
161 183 function render() {
162 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 189 document.querySelectorAll("th[data-sort]").forEach((th) => {
165 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 200 <td class="name"><input data-field="promptEn" value="${escapeHtml(c.promptEn || "")}" /></td>
176 201 <td class="prompt"><textarea data-field="fullPrompt">${escapeHtml(c.fullPrompt || "")}</textarea></td>
177 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 209 <td>${escapeHtml(traceability(c))}</td>
179 210 <td>${c.duration ?? "-"}s</td>
180 211 <td>${c.likes || 0}</td>
... ... @@ -193,6 +224,7 @@ function render() {
193 224 <td class="name">${escapeHtml(c.promptEn || "")}</td>
194 225 <td class="prompt" title="${escapeHtml(c.fullPrompt || "")}">${escapeHtml((c.fullPrompt || "").slice(0, 80))}${(c.fullPrompt || "").length > 80 ? "…" : ""}</td>
195 226 <td><a class="video-link" href="${videoHref(c)}" target="_blank">${videoHref(c)}</a></td>
  227 + <td>${c.channel === "dance" ? "跳舞直播间" : "视频直播间"}</td>
196 228 <td>${escapeHtml(traceability(c))}</td>
197 229 <td>${c.duration ?? "-"}s</td>
198 230 <td>${c.likes || 0}</td>
... ... @@ -248,6 +280,17 @@ document.querySelectorAll(&quot;th[data-sort]&quot;).forEach((th) =&gt; {
248 280 document.getElementById("searchInput").addEventListener("input", render);
249 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 294 document.getElementById("addBtn").addEventListener("click", async () => {
252 295 const errorBox = document.getElementById("addError");
253 296 errorBox.hidden = true;
... ... @@ -256,6 +299,7 @@ document.getElementById(&quot;addBtn&quot;).addEventListener(&quot;click&quot;, async () =&gt; {
256 299 const videoUrl = document.getElementById("newVideoUrl").value.trim();
257 300 const fullPrompt = document.getElementById("newFullPrompt").value.trim();
258 301 const duration = Number(document.getElementById("newDuration").value) || null;
  302 + const channel = document.getElementById("newChannel").value;
259 303 if (!prompt || !videoUrl) {
260 304 errorBox.hidden = false;
261 305 errorBox.textContent = "名称和视频地址必填";
... ... @@ -264,7 +308,7 @@ document.getElementById(&quot;addBtn&quot;).addEventListener(&quot;click&quot;, async () =&gt; {
264 308 const res = await fetch("/api/admin/library", {
265 309 method: "POST",
266 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 313 if (res.ok) {
270 314 document.getElementById("newPrompt").value = "";
... ...