Commit 736178621fd31d22d859ff89115b3c036f1e9e77

Authored by yibo
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>
admin.html
... ... @@ -70,6 +70,7 @@
70 70 <th data-sort="promptEn">名称(英)</th>
71 71 <th data-sort="fullPrompt">Prompt</th>
72 72 <th>视频地址</th>
  73 + <th>溯源(人物×衣服×参考视频)</th>
73 74 <th data-sort="duration">时长</th>
74 75 <th data-sort="likes">点赞</th>
75 76 <th data-sort="source">来源</th>
... ... @@ -96,6 +97,9 @@ window.fetch = async (...args) =&gt; {
96 97 };
97 98  
98 99 let library = [];
  100 +let characters = [];
  101 +let garments = [];
  102 +let danceRefs = [];
99 103 let editingId = null;
100 104 let sortKey = "createdAt";
101 105 let sortDir = -1;
... ... @@ -107,11 +111,34 @@ function escapeHtml(s) {
107 111 }
108 112  
109 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 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 142 function filteredSorted() {
116 143 const q = document.getElementById("searchInput").value.trim().toLowerCase();
117 144 const source = document.getElementById("sourceFilter").value;
... ... @@ -148,6 +175,7 @@ function render() {
148 175 <td class="name"><input data-field="promptEn" value="${escapeHtml(c.promptEn || "")}" /></td>
149 176 <td class="prompt"><textarea data-field="fullPrompt">${escapeHtml(c.fullPrompt || "")}</textarea></td>
150 177 <td><input data-field="videoUrl" value="${escapeHtml(c.videoUrl || "")}" placeholder="留空=保持原有播放源" /></td>
  178 + <td>${escapeHtml(traceability(c))}</td>
151 179 <td>${c.duration ?? "-"}s</td>
152 180 <td>${c.likes || 0}</td>
153 181 <td>${c.source}</td>
... ... @@ -165,6 +193,7 @@ function render() {
165 193 <td class="name">${escapeHtml(c.promptEn || "")}</td>
166 194 <td class="prompt" title="${escapeHtml(c.fullPrompt || "")}">${escapeHtml((c.fullPrompt || "").slice(0, 80))}${(c.fullPrompt || "").length > 80 ? "…" : ""}</td>
167 195 <td><a class="video-link" href="${videoHref(c)}" target="_blank">${videoHref(c)}</a></td>
  196 + <td>${escapeHtml(traceability(c))}</td>
168 197 <td>${c.duration ?? "-"}s</td>
169 198 <td>${c.likes || 0}</td>
170 199 <td>${c.source}</td>
... ...
dressup-generate.test.js
... ... @@ -188,6 +188,19 @@ test(&quot;generate: full happy path lands a clip with character/garment/dance-ref tr
188 188 assert.equal(clip.refDanceVideoId, danceRef.id);
189 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 204 // Re-submitting the same character x garment combo should hit the M.7
192 205 // dedupe cache instead of generating again.
193 206 const repeatRes = await fetch(
... ...