Commit 736178621fd31d22d859ff89115b3c036f1e9e77
1 parent
4d437a4c
Add traceability column to admin.html's video table (T10)
Fetches characters/garments/dance-refs alongside the library on load and joins each clip's characterId/garmentId/refDanceVideoId back to their names, so an operator can see "谁的脸 + 谁的衣服 + 哪条参考视频" per row (M.7). No composedImageId column — the one-step pipeline never produces an intermediate composed image. Extends the dressup-generate.test.js happy-path test to confirm the join resolves real names, not just matching ids. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
44 additions
and
2 deletions
admin.html
| @@ -70,6 +70,7 @@ | @@ -70,6 +70,7 @@ | ||
| 70 | <th data-sort="promptEn">名称(英)</th> | 70 | <th data-sort="promptEn">名称(英)</th> |
| 71 | <th data-sort="fullPrompt">Prompt</th> | 71 | <th data-sort="fullPrompt">Prompt</th> |
| 72 | <th>视频地址</th> | 72 | <th>视频地址</th> |
| 73 | + <th>溯源(人物×衣服×参考视频)</th> | ||
| 73 | <th data-sort="duration">时长</th> | 74 | <th data-sort="duration">时长</th> |
| 74 | <th data-sort="likes">点赞</th> | 75 | <th data-sort="likes">点赞</th> |
| 75 | <th data-sort="source">来源</th> | 76 | <th data-sort="source">来源</th> |
| @@ -96,6 +97,9 @@ window.fetch = async (...args) => { | @@ -96,6 +97,9 @@ window.fetch = async (...args) => { | ||
| 96 | }; | 97 | }; |
| 97 | 98 | ||
| 98 | let library = []; | 99 | let library = []; |
| 100 | +let characters = []; | ||
| 101 | +let garments = []; | ||
| 102 | +let danceRefs = []; | ||
| 99 | let editingId = null; | 103 | let editingId = null; |
| 100 | let sortKey = "createdAt"; | 104 | let sortKey = "createdAt"; |
| 101 | let sortDir = -1; | 105 | let sortDir = -1; |
| @@ -107,11 +111,34 @@ function escapeHtml(s) { | @@ -107,11 +111,34 @@ function escapeHtml(s) { | ||
| 107 | } | 111 | } |
| 108 | 112 | ||
| 109 | async function loadLibrary() { | 113 | async function loadLibrary() { |
| 110 | - const res = await fetch("/api/library?sort=newest"); | ||
| 111 | - library = await res.json(); | 114 | + const [libRes, charRes, garmentRes, danceRefRes] = await Promise.all([ |
| 115 | + fetch("/api/library?sort=newest"), | ||
| 116 | + fetch("/api/admin/characters"), | ||
| 117 | + fetch("/api/admin/garments"), | ||
| 118 | + fetch("/api/admin/dance-refs"), | ||
| 119 | + ]); | ||
| 120 | + library = await libRes.json(); | ||
| 121 | + characters = await charRes.json(); | ||
| 122 | + garments = await garmentRes.json(); | ||
| 123 | + danceRefs = await danceRefRes.json(); | ||
| 112 | render(); | 124 | render(); |
| 113 | } | 125 | } |
| 114 | 126 | ||
| 127 | +function nameOf(list, id) { | ||
| 128 | + if (!id) return null; | ||
| 129 | + const item = list.find((x) => x.id === id); | ||
| 130 | + return item ? item.name : id; | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +// T10: 一步法下没有 composedImageId/合成图,溯源只有这三个字段(M.7)。 | ||
| 134 | +function traceability(c) { | ||
| 135 | + const characterName = nameOf(characters, c.characterId); | ||
| 136 | + const garmentName = nameOf(garments, c.garmentId); | ||
| 137 | + const danceRefName = nameOf(danceRefs, c.refDanceVideoId); | ||
| 138 | + if (!characterName && !garmentName && !danceRefName) return "-"; | ||
| 139 | + return [characterName, garmentName, danceRefName].map((n) => n || "?").join(" × "); | ||
| 140 | +} | ||
| 141 | + | ||
| 115 | function filteredSorted() { | 142 | function filteredSorted() { |
| 116 | const q = document.getElementById("searchInput").value.trim().toLowerCase(); | 143 | const q = document.getElementById("searchInput").value.trim().toLowerCase(); |
| 117 | const source = document.getElementById("sourceFilter").value; | 144 | const source = document.getElementById("sourceFilter").value; |
| @@ -148,6 +175,7 @@ function render() { | @@ -148,6 +175,7 @@ function render() { | ||
| 148 | <td class="name"><input data-field="promptEn" value="${escapeHtml(c.promptEn || "")}" /></td> | 175 | <td class="name"><input data-field="promptEn" value="${escapeHtml(c.promptEn || "")}" /></td> |
| 149 | <td class="prompt"><textarea data-field="fullPrompt">${escapeHtml(c.fullPrompt || "")}</textarea></td> | 176 | <td class="prompt"><textarea data-field="fullPrompt">${escapeHtml(c.fullPrompt || "")}</textarea></td> |
| 150 | <td><input data-field="videoUrl" value="${escapeHtml(c.videoUrl || "")}" placeholder="留空=保持原有播放源" /></td> | 177 | <td><input data-field="videoUrl" value="${escapeHtml(c.videoUrl || "")}" placeholder="留空=保持原有播放源" /></td> |
| 178 | + <td>${escapeHtml(traceability(c))}</td> | ||
| 151 | <td>${c.duration ?? "-"}s</td> | 179 | <td>${c.duration ?? "-"}s</td> |
| 152 | <td>${c.likes || 0}</td> | 180 | <td>${c.likes || 0}</td> |
| 153 | <td>${c.source}</td> | 181 | <td>${c.source}</td> |
| @@ -165,6 +193,7 @@ function render() { | @@ -165,6 +193,7 @@ function render() { | ||
| 165 | <td class="name">${escapeHtml(c.promptEn || "")}</td> | 193 | <td class="name">${escapeHtml(c.promptEn || "")}</td> |
| 166 | <td class="prompt" title="${escapeHtml(c.fullPrompt || "")}">${escapeHtml((c.fullPrompt || "").slice(0, 80))}${(c.fullPrompt || "").length > 80 ? "…" : ""}</td> | 194 | <td class="prompt" title="${escapeHtml(c.fullPrompt || "")}">${escapeHtml((c.fullPrompt || "").slice(0, 80))}${(c.fullPrompt || "").length > 80 ? "…" : ""}</td> |
| 167 | <td><a class="video-link" href="${videoHref(c)}" target="_blank">${videoHref(c)}</a></td> | 195 | <td><a class="video-link" href="${videoHref(c)}" target="_blank">${videoHref(c)}</a></td> |
| 196 | + <td>${escapeHtml(traceability(c))}</td> | ||
| 168 | <td>${c.duration ?? "-"}s</td> | 197 | <td>${c.duration ?? "-"}s</td> |
| 169 | <td>${c.likes || 0}</td> | 198 | <td>${c.likes || 0}</td> |
| 170 | <td>${c.source}</td> | 199 | <td>${c.source}</td> |
dressup-generate.test.js
| @@ -188,6 +188,19 @@ test("generate: full happy path lands a clip with character/garment/dance-ref tr | @@ -188,6 +188,19 @@ test("generate: full happy path lands a clip with character/garment/dance-ref tr | ||
| 188 | assert.equal(clip.refDanceVideoId, danceRef.id); | 188 | assert.equal(clip.refDanceVideoId, danceRef.id); |
| 189 | assert.equal(clip.channel, "dance"); | 189 | assert.equal(clip.channel, "dance"); |
| 190 | 190 | ||
| 191 | + // T10's admin.html traceability column joins clip.characterId/garmentId/ | ||
| 192 | + // refDanceVideoId back against the three admin lists to show "谁的脸 + | ||
| 193 | + // 谁的衣服 + 哪条参考视频" — confirm that join actually resolves to the | ||
| 194 | + // real names, not just matching ids. | ||
| 195 | + const [charList, garmentList, danceRefList] = await Promise.all([ | ||
| 196 | + fetch(`${BASE}/api/admin/characters`, authed()).then((r) => r.json()), | ||
| 197 | + fetch(`${BASE}/api/admin/garments`, authed()).then((r) => r.json()), | ||
| 198 | + fetch(`${BASE}/api/admin/dance-refs`, authed()).then((r) => r.json()), | ||
| 199 | + ]); | ||
| 200 | + assert.equal(charList.find((x) => x.id === clip.characterId).name, "小美"); | ||
| 201 | + assert.equal(garmentList.find((x) => x.id === clip.garmentId).name, "红旗袍"); | ||
| 202 | + assert.equal(danceRefList.find((x) => x.id === clip.refDanceVideoId).name, "拉丁舞"); | ||
| 203 | + | ||
| 191 | // Re-submitting the same character x garment combo should hit the M.7 | 204 | // Re-submitting the same character x garment combo should hit the M.7 |
| 192 | // dedupe cache instead of generating again. | 205 | // dedupe cache instead of generating again. |
| 193 | const repeatRes = await fetch( | 206 | const repeatRes = await fetch( |