Commit 2cc989c9be87abefe18d67fa4465ca26ffea769d
1 parent
59183b0e
Add admin-characters/garments/dance-refs.html (T3-T5)
Table + inline edit + add-form pages matching admin.html's existing conventions, wired to the T3-T5 CRUD API and T2's upload endpoints. Cross-linked with admin.html and each other in the topbar. No browser tooling available in this environment to render/click through these, so admin-pages-flow.test.js exercises the exact API call sequence each page's JS performs (login -> upload a real file -> create with the returned URL -> list -> confirm the shape the table renders), which is where a field-name mismatch would surface.
Showing
5 changed files
with
836 additions
and
0 deletions
admin-characters.html
0 → 100644
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="zh-CN"> | |
| 3 | +<head> | |
| 4 | +<meta charset="UTF-8" /> | |
| 5 | +<title>人物库管理</title> | |
| 6 | +<link rel="stylesheet" href="style.css" /> | |
| 7 | +<style> | |
| 8 | + .admin-wrap { padding: 18px 24px; max-width: 1400px; } | |
| 9 | + .admin-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; } | |
| 10 | + .admin-toolbar input[type="search"] { flex: 1; min-width: 220px; } | |
| 11 | + table.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; } | |
| 12 | + table.admin-table th, table.admin-table td { border-bottom: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; } | |
| 13 | + table.admin-table th { color: var(--muted); font-weight: 600; white-space: nowrap; } | |
| 14 | + table.admin-table td.name { max-width: 160px; } | |
| 15 | + table.admin-table td.desc { max-width: 320px; color: var(--muted); } | |
| 16 | + table.admin-table td input, table.admin-table td textarea { width: 100%; font-size: 12px; } | |
| 17 | + table.admin-table td textarea { min-height: 50px; } | |
| 18 | + .row-actions { display: flex; gap: 6px; white-space: nowrap; } | |
| 19 | + .row-actions button { padding: 3px 8px; font-size: 12px; } | |
| 20 | + .thumb { width: 56px; height: 80px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); } | |
| 21 | + .add-card { margin-bottom: 16px; } | |
| 22 | + .row-4 { display: grid; grid-template-columns: 1fr 1fr 1.6fr 100px; gap: 14px; align-items: start; } | |
| 23 | + .count-hint { color: var(--muted); font-size: 12px; margin-bottom: 10px; } | |
| 24 | + .default-badge { display: inline-block; padding: 2px 6px; font-size: 11px; border-radius: 4px; background: #eef7ee; color: #2e7d32; } | |
| 25 | +</style> | |
| 26 | +</head> | |
| 27 | +<body> | |
| 28 | +<header class="topbar"> | |
| 29 | + <h1>人物库管理</h1> | |
| 30 | + <div class="spacer"></div> | |
| 31 | + <a href="admin.html">视频库</a> | |
| 32 | + <a href="admin-garments.html">衣服库</a> | |
| 33 | + <a href="admin-dance-refs.html">参考跳舞视频库</a> | |
| 34 | + <a href="live.html">← 返回直播频道</a> | |
| 35 | +</header> | |
| 36 | + | |
| 37 | +<main class="admin-wrap"> | |
| 38 | + <div class="card add-card"> | |
| 39 | + <div class="field"><label>新增人物</label></div> | |
| 40 | + <div class="row-4"> | |
| 41 | + <input type="text" id="newName" placeholder="名称" /> | |
| 42 | + <input type="file" id="newImageFile" accept="image/*" /> | |
| 43 | + <input type="text" id="newTags" placeholder="标签(逗号分隔,可选)" /> | |
| 44 | + <label style="display:flex;align-items:center;gap:6px;font-size:12px;"> | |
| 45 | + <input type="checkbox" id="newIsDefault" /> 设为默认 | |
| 46 | + </label> | |
| 47 | + </div> | |
| 48 | + <div class="field" style="margin-top:8px;"> | |
| 49 | + <textarea id="newAppearanceDescriptor" placeholder="人物外观特征(发色/发型/五官/体态,2-3 条具体描述,如:shoulder-length wavy black hair, delicate features, a slender build)" style="min-height:60px;"></textarea> | |
| 50 | + </div> | |
| 51 | + <button id="addBtn" style="margin-top:8px;">新增</button> | |
| 52 | + <div id="addError" class="error-box" hidden></div> | |
| 53 | + </div> | |
| 54 | + | |
| 55 | + <div class="admin-toolbar"> | |
| 56 | + <input type="search" id="searchInput" placeholder="搜索名称 / 标签..." /> | |
| 57 | + </div> | |
| 58 | + <div class="count-hint" id="countHint"></div> | |
| 59 | + | |
| 60 | + <div style="overflow-x:auto;"> | |
| 61 | + <table class="admin-table"> | |
| 62 | + <thead> | |
| 63 | + <tr> | |
| 64 | + <th>图片</th> | |
| 65 | + <th>名称</th> | |
| 66 | + <th>外观描述</th> | |
| 67 | + <th>标签</th> | |
| 68 | + <th>默认</th> | |
| 69 | + <th>来源</th> | |
| 70 | + <th>状态</th> | |
| 71 | + <th>创建时间</th> | |
| 72 | + <th>操作</th> | |
| 73 | + </tr> | |
| 74 | + </thead> | |
| 75 | + <tbody id="tbody"></tbody> | |
| 76 | + </table> | |
| 77 | + </div> | |
| 78 | +</main> | |
| 79 | + | |
| 80 | +<script> | |
| 81 | +const _fetch = window.fetch; | |
| 82 | +window.fetch = async (...args) => { | |
| 83 | + const res = await _fetch(...args); | |
| 84 | + if (res.status === 401 && String(args[0]).startsWith("/api/admin/") && !String(args[0]).includes("/login")) { | |
| 85 | + location.href = "/admin-login.html?next=" + encodeURIComponent(location.pathname); | |
| 86 | + } | |
| 87 | + return res; | |
| 88 | +}; | |
| 89 | + | |
| 90 | +let items = []; | |
| 91 | +let editingId = null; | |
| 92 | + | |
| 93 | +function escapeHtml(s) { | |
| 94 | + const div = document.createElement("div"); | |
| 95 | + div.textContent = s || ""; | |
| 96 | + return div.innerHTML; | |
| 97 | +} | |
| 98 | + | |
| 99 | +async function uploadImage(file) { | |
| 100 | + const fd = new FormData(); | |
| 101 | + fd.append("file", file); | |
| 102 | + const res = await fetch("/api/admin/upload", { method: "POST", body: fd }); | |
| 103 | + if (!res.ok) throw new Error((await res.json()).error || "上传失败"); | |
| 104 | + return (await res.json()).url; | |
| 105 | +} | |
| 106 | + | |
| 107 | +async function load() { | |
| 108 | + const res = await fetch("/api/admin/characters"); | |
| 109 | + items = await res.json(); | |
| 110 | + render(); | |
| 111 | +} | |
| 112 | + | |
| 113 | +function filtered() { | |
| 114 | + const q = document.getElementById("searchInput").value.trim().toLowerCase(); | |
| 115 | + if (!q) return items; | |
| 116 | + return items.filter((c) => c.name.toLowerCase().includes(q) || (c.tags || []).join(",").toLowerCase().includes(q)); | |
| 117 | +} | |
| 118 | + | |
| 119 | +function render() { | |
| 120 | + const rows = filtered(); | |
| 121 | + document.getElementById("countHint").textContent = `共 ${items.length} 条,当前筛选 ${rows.length} 条`; | |
| 122 | + | |
| 123 | + const tbody = document.getElementById("tbody"); | |
| 124 | + tbody.innerHTML = rows | |
| 125 | + .map((c) => { | |
| 126 | + if (c.id === editingId) { | |
| 127 | + return ` | |
| 128 | + <tr data-id="${c.id}"> | |
| 129 | + <td><img class="thumb" src="${escapeHtml(c.imageUrl)}" /></td> | |
| 130 | + <td class="name"><input data-field="name" value="${escapeHtml(c.name)}" /></td> | |
| 131 | + <td class="desc"><textarea data-field="appearanceDescriptor">${escapeHtml(c.appearanceDescriptor || "")}</textarea></td> | |
| 132 | + <td class="name"><input data-field="tags" value="${escapeHtml((c.tags || []).join(","))}" /></td> | |
| 133 | + <td><input type="checkbox" data-field="isDefault" ${c.isDefault ? "checked" : ""} /></td> | |
| 134 | + <td>${c.source}</td> | |
| 135 | + <td>${c.status}</td> | |
| 136 | + <td>${new Date(c.createdAt).toLocaleDateString()}</td> | |
| 137 | + <td class="row-actions"> | |
| 138 | + <button data-save="${c.id}">保存</button> | |
| 139 | + <button class="secondary" data-cancel="1">取消</button> | |
| 140 | + </td> | |
| 141 | + </tr>`; | |
| 142 | + } | |
| 143 | + return ` | |
| 144 | + <tr data-id="${c.id}"> | |
| 145 | + <td><img class="thumb" src="${escapeHtml(c.imageUrl)}" /></td> | |
| 146 | + <td class="name">${escapeHtml(c.name)}</td> | |
| 147 | + <td class="desc" title="${escapeHtml(c.appearanceDescriptor || "")}">${escapeHtml(c.appearanceDescriptor || "")}</td> | |
| 148 | + <td class="name">${escapeHtml((c.tags || []).join(", "))}</td> | |
| 149 | + <td>${c.isDefault ? '<span class="default-badge">默认</span>' : ""}</td> | |
| 150 | + <td>${c.source}</td> | |
| 151 | + <td>${c.status}</td> | |
| 152 | + <td>${new Date(c.createdAt).toLocaleDateString()}</td> | |
| 153 | + <td class="row-actions"> | |
| 154 | + <button class="secondary" data-edit="${c.id}">编辑</button> | |
| 155 | + <button class="danger" data-del="${c.id}">删除</button> | |
| 156 | + </td> | |
| 157 | + </tr>`; | |
| 158 | + }) | |
| 159 | + .join(""); | |
| 160 | + | |
| 161 | + tbody.querySelectorAll("[data-edit]").forEach((b) => b.addEventListener("click", () => { editingId = b.dataset.edit; render(); })); | |
| 162 | + tbody.querySelectorAll("[data-cancel]").forEach((b) => b.addEventListener("click", () => { editingId = null; render(); })); | |
| 163 | + tbody.querySelectorAll("[data-save]").forEach((b) => | |
| 164 | + b.addEventListener("click", async () => { | |
| 165 | + const tr = b.closest("tr"); | |
| 166 | + const body = {}; | |
| 167 | + tr.querySelectorAll("[data-field]").forEach((el) => { | |
| 168 | + if (el.type === "checkbox") body[el.dataset.field] = el.checked; | |
| 169 | + else if (el.dataset.field === "tags") body.tags = el.value.split(",").map((s) => s.trim()).filter(Boolean); | |
| 170 | + else body[el.dataset.field] = el.value; | |
| 171 | + }); | |
| 172 | + const res = await fetch(`/api/admin/characters/${b.dataset.save}`, { | |
| 173 | + method: "PATCH", | |
| 174 | + headers: { "Content-Type": "application/json" }, | |
| 175 | + body: JSON.stringify(body), | |
| 176 | + }); | |
| 177 | + if (res.ok) { | |
| 178 | + editingId = null; | |
| 179 | + await load(); | |
| 180 | + } | |
| 181 | + }) | |
| 182 | + ); | |
| 183 | + tbody.querySelectorAll("[data-del]").forEach((b) => | |
| 184 | + b.addEventListener("click", async () => { | |
| 185 | + if (!confirm("确认删除这个人物?")) return; | |
| 186 | + const res = await fetch(`/api/admin/characters/${b.dataset.del}`, { method: "DELETE" }); | |
| 187 | + if (res.ok) await load(); | |
| 188 | + }) | |
| 189 | + ); | |
| 190 | +} | |
| 191 | + | |
| 192 | +document.getElementById("searchInput").addEventListener("input", render); | |
| 193 | + | |
| 194 | +document.getElementById("addBtn").addEventListener("click", async () => { | |
| 195 | + const errorBox = document.getElementById("addError"); | |
| 196 | + errorBox.hidden = true; | |
| 197 | + const name = document.getElementById("newName").value.trim(); | |
| 198 | + const file = document.getElementById("newImageFile").files[0]; | |
| 199 | + const appearanceDescriptor = document.getElementById("newAppearanceDescriptor").value.trim(); | |
| 200 | + const tags = document.getElementById("newTags").value.split(",").map((s) => s.trim()).filter(Boolean); | |
| 201 | + const isDefault = document.getElementById("newIsDefault").checked; | |
| 202 | + if (!name || !file || !appearanceDescriptor) { | |
| 203 | + errorBox.hidden = false; | |
| 204 | + errorBox.textContent = "名称、图片、外观描述都必填"; | |
| 205 | + return; | |
| 206 | + } | |
| 207 | + try { | |
| 208 | + const imageUrl = await uploadImage(file); | |
| 209 | + const res = await fetch("/api/admin/characters", { | |
| 210 | + method: "POST", | |
| 211 | + headers: { "Content-Type": "application/json" }, | |
| 212 | + body: JSON.stringify({ name, imageUrl, appearanceDescriptor, tags, isDefault }), | |
| 213 | + }); | |
| 214 | + if (res.ok) { | |
| 215 | + document.getElementById("newName").value = ""; | |
| 216 | + document.getElementById("newImageFile").value = ""; | |
| 217 | + document.getElementById("newTags").value = ""; | |
| 218 | + document.getElementById("newAppearanceDescriptor").value = ""; | |
| 219 | + document.getElementById("newIsDefault").checked = false; | |
| 220 | + await load(); | |
| 221 | + } else { | |
| 222 | + errorBox.hidden = false; | |
| 223 | + errorBox.textContent = (await res.json()).error || "新增失败"; | |
| 224 | + } | |
| 225 | + } catch (e) { | |
| 226 | + errorBox.hidden = false; | |
| 227 | + errorBox.textContent = e.message; | |
| 228 | + } | |
| 229 | +}); | |
| 230 | + | |
| 231 | +load(); | |
| 232 | +</script> | |
| 233 | +</body> | |
| 234 | +</html> | ... | ... |
admin-dance-refs.html
0 → 100644
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="zh-CN"> | |
| 3 | +<head> | |
| 4 | +<meta charset="UTF-8" /> | |
| 5 | +<title>参考跳舞视频库管理</title> | |
| 6 | +<link rel="stylesheet" href="style.css" /> | |
| 7 | +<style> | |
| 8 | + .admin-wrap { padding: 18px 24px; max-width: 1400px; } | |
| 9 | + .admin-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; } | |
| 10 | + .admin-toolbar input[type="search"] { flex: 1; min-width: 220px; } | |
| 11 | + table.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; } | |
| 12 | + table.admin-table th, table.admin-table td { border-bottom: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; } | |
| 13 | + table.admin-table th { color: var(--muted); font-weight: 600; white-space: nowrap; } | |
| 14 | + table.admin-table td.name { max-width: 160px; } | |
| 15 | + table.admin-table td.desc { max-width: 320px; color: var(--muted); } | |
| 16 | + table.admin-table td input, table.admin-table td textarea { width: 100%; font-size: 12px; } | |
| 17 | + table.admin-table td textarea { min-height: 50px; } | |
| 18 | + .row-actions { display: flex; gap: 6px; white-space: nowrap; } | |
| 19 | + .row-actions button { padding: 3px 8px; font-size: 12px; } | |
| 20 | + video.thumb { width: 90px; height: 90px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); background: #000; } | |
| 21 | + .add-card { margin-bottom: 16px; } | |
| 22 | + .row-4 { display: grid; grid-template-columns: 1fr 1fr 1fr 100px; gap: 14px; align-items: start; } | |
| 23 | + .count-hint { color: var(--muted); font-size: 12px; margin-bottom: 10px; } | |
| 24 | + .inactive-badge { display: inline-block; padding: 2px 6px; font-size: 11px; border-radius: 4px; background: #fbeaea; color: #c0392b; } | |
| 25 | + .required-hint { color: #c0392b; font-size: 11px; } | |
| 26 | +</style> | |
| 27 | +</head> | |
| 28 | +<body> | |
| 29 | +<header class="topbar"> | |
| 30 | + <h1>参考跳舞视频库管理</h1> | |
| 31 | + <div class="spacer"></div> | |
| 32 | + <a href="admin.html">视频库</a> | |
| 33 | + <a href="admin-characters.html">人物库</a> | |
| 34 | + <a href="admin-garments.html">衣服库</a> | |
| 35 | + <a href="live.html">← 返回直播频道</a> | |
| 36 | +</header> | |
| 37 | + | |
| 38 | +<main class="admin-wrap"> | |
| 39 | + <div class="card add-card"> | |
| 40 | + <div class="field"><label>新增参考跳舞视频</label></div> | |
| 41 | + <div class="row-4"> | |
| 42 | + <input type="text" id="newName" placeholder="名称" /> | |
| 43 | + <input type="file" id="newVideoFile" accept="video/mp4" /> | |
| 44 | + <input type="text" id="newStyleTags" placeholder="风格标签(逗号分隔,可选)" /> | |
| 45 | + <label style="display:flex;align-items:center;gap:6px;font-size:12px;"> | |
| 46 | + <input type="checkbox" id="newIsActive" checked /> 上架 | |
| 47 | + </label> | |
| 48 | + </div> | |
| 49 | + <div class="field" style="margin-top:8px;"> | |
| 50 | + <textarea id="newMotionDescriptor" placeholder="动作描述(必填——如:拉丁风格摆胯+举臂旋转 / a lively hip-hop inspired routine with hip sways and raised-arm turns)" style="min-height:60px;"></textarea> | |
| 51 | + <div class="required-hint">动作描述是必填项:这条视频一旦录入,后续所有引用它生成的视频都复用这段描述,不能留空事后补</div> | |
| 52 | + </div> | |
| 53 | + <button id="addBtn" style="margin-top:8px;">新增</button> | |
| 54 | + <div id="addError" class="error-box" hidden></div> | |
| 55 | + </div> | |
| 56 | + | |
| 57 | + <div class="admin-toolbar"> | |
| 58 | + <input type="search" id="searchInput" placeholder="搜索名称 / 标签..." /> | |
| 59 | + </div> | |
| 60 | + <div class="count-hint" id="countHint"></div> | |
| 61 | + | |
| 62 | + <div style="overflow-x:auto;"> | |
| 63 | + <table class="admin-table"> | |
| 64 | + <thead> | |
| 65 | + <tr> | |
| 66 | + <th>预览</th> | |
| 67 | + <th>名称</th> | |
| 68 | + <th>动作描述</th> | |
| 69 | + <th>风格标签</th> | |
| 70 | + <th>版权来源</th> | |
| 71 | + <th>上架</th> | |
| 72 | + <th>创建时间</th> | |
| 73 | + <th>操作</th> | |
| 74 | + </tr> | |
| 75 | + </thead> | |
| 76 | + <tbody id="tbody"></tbody> | |
| 77 | + </table> | |
| 78 | + </div> | |
| 79 | +</main> | |
| 80 | + | |
| 81 | +<script> | |
| 82 | +const _fetch = window.fetch; | |
| 83 | +window.fetch = async (...args) => { | |
| 84 | + const res = await _fetch(...args); | |
| 85 | + if (res.status === 401 && String(args[0]).startsWith("/api/admin/") && !String(args[0]).includes("/login")) { | |
| 86 | + location.href = "/admin-login.html?next=" + encodeURIComponent(location.pathname); | |
| 87 | + } | |
| 88 | + return res; | |
| 89 | +}; | |
| 90 | + | |
| 91 | +let items = []; | |
| 92 | +let editingId = null; | |
| 93 | + | |
| 94 | +function escapeHtml(s) { | |
| 95 | + const div = document.createElement("div"); | |
| 96 | + div.textContent = s || ""; | |
| 97 | + return div.innerHTML; | |
| 98 | +} | |
| 99 | + | |
| 100 | +async function uploadVideo(file) { | |
| 101 | + const fd = new FormData(); | |
| 102 | + fd.append("file", file); | |
| 103 | + const res = await fetch("/api/admin/upload-video", { method: "POST", body: fd }); | |
| 104 | + if (!res.ok) throw new Error((await res.json()).error || "上传失败"); | |
| 105 | + return (await res.json()).url; | |
| 106 | +} | |
| 107 | + | |
| 108 | +async function load() { | |
| 109 | + const res = await fetch("/api/admin/dance-refs"); | |
| 110 | + items = await res.json(); | |
| 111 | + render(); | |
| 112 | +} | |
| 113 | + | |
| 114 | +function filtered() { | |
| 115 | + const q = document.getElementById("searchInput").value.trim().toLowerCase(); | |
| 116 | + if (!q) return items; | |
| 117 | + return items.filter((d) => d.name.toLowerCase().includes(q) || (d.styleTags || []).join(",").toLowerCase().includes(q)); | |
| 118 | +} | |
| 119 | + | |
| 120 | +function render() { | |
| 121 | + const rows = filtered(); | |
| 122 | + document.getElementById("countHint").textContent = `共 ${items.length} 条,当前筛选 ${rows.length} 条`; | |
| 123 | + | |
| 124 | + const tbody = document.getElementById("tbody"); | |
| 125 | + tbody.innerHTML = rows | |
| 126 | + .map((d) => { | |
| 127 | + if (d.id === editingId) { | |
| 128 | + return ` | |
| 129 | + <tr data-id="${d.id}"> | |
| 130 | + <td><video class="thumb" src="${escapeHtml(d.videoUrl)}" muted></video></td> | |
| 131 | + <td class="name"><input data-field="name" value="${escapeHtml(d.name)}" /></td> | |
| 132 | + <td class="desc"><textarea data-field="motionDescriptor">${escapeHtml(d.motionDescriptor || "")}</textarea></td> | |
| 133 | + <td class="name"><input data-field="styleTags" value="${escapeHtml((d.styleTags || []).join(","))}" /></td> | |
| 134 | + <td>${d.copyrightSource}</td> | |
| 135 | + <td><input type="checkbox" data-field="isActive" ${d.isActive ? "checked" : ""} /></td> | |
| 136 | + <td>${new Date(d.createdAt).toLocaleDateString()}</td> | |
| 137 | + <td class="row-actions"> | |
| 138 | + <button data-save="${d.id}">保存</button> | |
| 139 | + <button class="secondary" data-cancel="1">取消</button> | |
| 140 | + </td> | |
| 141 | + </tr>`; | |
| 142 | + } | |
| 143 | + return ` | |
| 144 | + <tr data-id="${d.id}"> | |
| 145 | + <td><video class="thumb" src="${escapeHtml(d.videoUrl)}" muted controls></video></td> | |
| 146 | + <td class="name">${escapeHtml(d.name)}</td> | |
| 147 | + <td class="desc" title="${escapeHtml(d.motionDescriptor || "")}">${escapeHtml(d.motionDescriptor || "")}</td> | |
| 148 | + <td class="name">${escapeHtml((d.styleTags || []).join(", "))}</td> | |
| 149 | + <td>${d.copyrightSource}</td> | |
| 150 | + <td>${d.isActive ? "上架" : '<span class="inactive-badge">下架</span>'}</td> | |
| 151 | + <td>${new Date(d.createdAt).toLocaleDateString()}</td> | |
| 152 | + <td class="row-actions"> | |
| 153 | + <button class="secondary" data-edit="${d.id}">编辑</button> | |
| 154 | + <button class="danger" data-del="${d.id}">删除</button> | |
| 155 | + </td> | |
| 156 | + </tr>`; | |
| 157 | + }) | |
| 158 | + .join(""); | |
| 159 | + | |
| 160 | + tbody.querySelectorAll("[data-edit]").forEach((b) => b.addEventListener("click", () => { editingId = b.dataset.edit; render(); })); | |
| 161 | + tbody.querySelectorAll("[data-cancel]").forEach((b) => b.addEventListener("click", () => { editingId = null; render(); })); | |
| 162 | + tbody.querySelectorAll("[data-save]").forEach((b) => | |
| 163 | + b.addEventListener("click", async () => { | |
| 164 | + const tr = b.closest("tr"); | |
| 165 | + const body = {}; | |
| 166 | + tr.querySelectorAll("[data-field]").forEach((el) => { | |
| 167 | + if (el.type === "checkbox") body[el.dataset.field] = el.checked; | |
| 168 | + else if (el.dataset.field === "styleTags") body.styleTags = el.value.split(",").map((s) => s.trim()).filter(Boolean); | |
| 169 | + else body[el.dataset.field] = el.value; | |
| 170 | + }); | |
| 171 | + if ("motionDescriptor" in body && !body.motionDescriptor.trim()) { | |
| 172 | + alert("动作描述不能清空"); | |
| 173 | + return; | |
| 174 | + } | |
| 175 | + const res = await fetch(`/api/admin/dance-refs/${b.dataset.save}`, { | |
| 176 | + method: "PATCH", | |
| 177 | + headers: { "Content-Type": "application/json" }, | |
| 178 | + body: JSON.stringify(body), | |
| 179 | + }); | |
| 180 | + if (res.ok) { | |
| 181 | + editingId = null; | |
| 182 | + await load(); | |
| 183 | + } | |
| 184 | + }) | |
| 185 | + ); | |
| 186 | + tbody.querySelectorAll("[data-del]").forEach((b) => | |
| 187 | + b.addEventListener("click", async () => { | |
| 188 | + if (!confirm("确认删除这条参考视频?")) return; | |
| 189 | + const res = await fetch(`/api/admin/dance-refs/${b.dataset.del}`, { method: "DELETE" }); | |
| 190 | + if (res.ok) await load(); | |
| 191 | + }) | |
| 192 | + ); | |
| 193 | +} | |
| 194 | + | |
| 195 | +document.getElementById("searchInput").addEventListener("input", render); | |
| 196 | + | |
| 197 | +document.getElementById("addBtn").addEventListener("click", async () => { | |
| 198 | + const errorBox = document.getElementById("addError"); | |
| 199 | + errorBox.hidden = true; | |
| 200 | + const name = document.getElementById("newName").value.trim(); | |
| 201 | + const file = document.getElementById("newVideoFile").files[0]; | |
| 202 | + const motionDescriptor = document.getElementById("newMotionDescriptor").value.trim(); | |
| 203 | + const styleTags = document.getElementById("newStyleTags").value.split(",").map((s) => s.trim()).filter(Boolean); | |
| 204 | + const isActive = document.getElementById("newIsActive").checked; | |
| 205 | + if (!name || !file || !motionDescriptor) { | |
| 206 | + errorBox.hidden = false; | |
| 207 | + errorBox.textContent = "名称、视频、动作描述都必填"; | |
| 208 | + return; | |
| 209 | + } | |
| 210 | + try { | |
| 211 | + const videoUrl = await uploadVideo(file); | |
| 212 | + const res = await fetch("/api/admin/dance-refs", { | |
| 213 | + method: "POST", | |
| 214 | + headers: { "Content-Type": "application/json" }, | |
| 215 | + body: JSON.stringify({ name, videoUrl, motionDescriptor, styleTags, isActive }), | |
| 216 | + }); | |
| 217 | + if (res.ok) { | |
| 218 | + document.getElementById("newName").value = ""; | |
| 219 | + document.getElementById("newVideoFile").value = ""; | |
| 220 | + document.getElementById("newStyleTags").value = ""; | |
| 221 | + document.getElementById("newMotionDescriptor").value = ""; | |
| 222 | + document.getElementById("newIsActive").checked = true; | |
| 223 | + await load(); | |
| 224 | + } else { | |
| 225 | + errorBox.hidden = false; | |
| 226 | + errorBox.textContent = (await res.json()).error || "新增失败"; | |
| 227 | + } | |
| 228 | + } catch (e) { | |
| 229 | + errorBox.hidden = false; | |
| 230 | + errorBox.textContent = e.message; | |
| 231 | + } | |
| 232 | +}); | |
| 233 | + | |
| 234 | +load(); | |
| 235 | +</script> | |
| 236 | +</body> | |
| 237 | +</html> | ... | ... |
admin-garments.html
0 → 100644
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="zh-CN"> | |
| 3 | +<head> | |
| 4 | +<meta charset="UTF-8" /> | |
| 5 | +<title>衣服库管理</title> | |
| 6 | +<link rel="stylesheet" href="style.css" /> | |
| 7 | +<style> | |
| 8 | + .admin-wrap { padding: 18px 24px; max-width: 1400px; } | |
| 9 | + .admin-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; } | |
| 10 | + .admin-toolbar input[type="search"] { flex: 1; min-width: 220px; } | |
| 11 | + table.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; } | |
| 12 | + table.admin-table th, table.admin-table td { border-bottom: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; } | |
| 13 | + table.admin-table th { color: var(--muted); font-weight: 600; white-space: nowrap; } | |
| 14 | + table.admin-table td.name { max-width: 160px; } | |
| 15 | + table.admin-table td.desc { max-width: 320px; color: var(--muted); } | |
| 16 | + table.admin-table td input, table.admin-table td textarea { width: 100%; font-size: 12px; } | |
| 17 | + table.admin-table td textarea { min-height: 50px; } | |
| 18 | + .row-actions { display: flex; gap: 6px; white-space: nowrap; } | |
| 19 | + .row-actions button { padding: 3px 8px; font-size: 12px; } | |
| 20 | + .thumb { width: 56px; height: 80px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); } | |
| 21 | + .add-card { margin-bottom: 16px; } | |
| 22 | + .row-5 { display: grid; grid-template-columns: 1fr 1fr 1fr 1.6fr 100px; gap: 14px; align-items: start; } | |
| 23 | + .count-hint { color: var(--muted); font-size: 12px; margin-bottom: 10px; } | |
| 24 | + .inactive-badge { display: inline-block; padding: 2px 6px; font-size: 11px; border-radius: 4px; background: #fbeaea; color: #c0392b; } | |
| 25 | +</style> | |
| 26 | +</head> | |
| 27 | +<body> | |
| 28 | +<header class="topbar"> | |
| 29 | + <h1>衣服库管理</h1> | |
| 30 | + <div class="spacer"></div> | |
| 31 | + <a href="admin.html">视频库</a> | |
| 32 | + <a href="admin-characters.html">人物库</a> | |
| 33 | + <a href="admin-dance-refs.html">参考跳舞视频库</a> | |
| 34 | + <a href="live.html">← 返回直播频道</a> | |
| 35 | +</header> | |
| 36 | + | |
| 37 | +<main class="admin-wrap"> | |
| 38 | + <div class="card add-card"> | |
| 39 | + <div class="field"><label>新增衣服</label></div> | |
| 40 | + <div class="row-5"> | |
| 41 | + <input type="text" id="newName" placeholder="名称" /> | |
| 42 | + <input type="text" id="newCategory" placeholder="分类(如:古风/泳装/校服)" /> | |
| 43 | + <input type="file" id="newImageFile" accept="image/*" /> | |
| 44 | + <input type="text" id="newTags" placeholder="(此列留空,占位对齐)" disabled /> | |
| 45 | + <label style="display:flex;align-items:center;gap:6px;font-size:12px;"> | |
| 46 | + <input type="checkbox" id="newIsActive" checked /> 上架 | |
| 47 | + </label> | |
| 48 | + </div> | |
| 49 | + <div class="field" style="margin-top:8px;"> | |
| 50 | + <textarea id="newAppearanceDescriptor" placeholder="服装特征(颜色/款式/材质,如:a fitted red qipao with gold trim and a side slit)" style="min-height:60px;"></textarea> | |
| 51 | + </div> | |
| 52 | + <button id="addBtn" style="margin-top:8px;">新增</button> | |
| 53 | + <div id="addError" class="error-box" hidden></div> | |
| 54 | + </div> | |
| 55 | + | |
| 56 | + <div class="admin-toolbar"> | |
| 57 | + <input type="search" id="searchInput" placeholder="搜索名称 / 分类..." /> | |
| 58 | + </div> | |
| 59 | + <div class="count-hint" id="countHint"></div> | |
| 60 | + | |
| 61 | + <div style="overflow-x:auto;"> | |
| 62 | + <table class="admin-table"> | |
| 63 | + <thead> | |
| 64 | + <tr> | |
| 65 | + <th>图片</th> | |
| 66 | + <th>名称</th> | |
| 67 | + <th>分类</th> | |
| 68 | + <th>外观描述</th> | |
| 69 | + <th>上架</th> | |
| 70 | + <th>来源</th> | |
| 71 | + <th>创建时间</th> | |
| 72 | + <th>操作</th> | |
| 73 | + </tr> | |
| 74 | + </thead> | |
| 75 | + <tbody id="tbody"></tbody> | |
| 76 | + </table> | |
| 77 | + </div> | |
| 78 | +</main> | |
| 79 | + | |
| 80 | +<script> | |
| 81 | +const _fetch = window.fetch; | |
| 82 | +window.fetch = async (...args) => { | |
| 83 | + const res = await _fetch(...args); | |
| 84 | + if (res.status === 401 && String(args[0]).startsWith("/api/admin/") && !String(args[0]).includes("/login")) { | |
| 85 | + location.href = "/admin-login.html?next=" + encodeURIComponent(location.pathname); | |
| 86 | + } | |
| 87 | + return res; | |
| 88 | +}; | |
| 89 | + | |
| 90 | +let items = []; | |
| 91 | +let editingId = null; | |
| 92 | + | |
| 93 | +function escapeHtml(s) { | |
| 94 | + const div = document.createElement("div"); | |
| 95 | + div.textContent = s || ""; | |
| 96 | + return div.innerHTML; | |
| 97 | +} | |
| 98 | + | |
| 99 | +async function uploadImage(file) { | |
| 100 | + const fd = new FormData(); | |
| 101 | + fd.append("file", file); | |
| 102 | + const res = await fetch("/api/admin/upload", { method: "POST", body: fd }); | |
| 103 | + if (!res.ok) throw new Error((await res.json()).error || "上传失败"); | |
| 104 | + return (await res.json()).url; | |
| 105 | +} | |
| 106 | + | |
| 107 | +async function load() { | |
| 108 | + const res = await fetch("/api/admin/garments"); | |
| 109 | + items = await res.json(); | |
| 110 | + render(); | |
| 111 | +} | |
| 112 | + | |
| 113 | +function filtered() { | |
| 114 | + const q = document.getElementById("searchInput").value.trim().toLowerCase(); | |
| 115 | + if (!q) return items; | |
| 116 | + return items.filter((g) => g.name.toLowerCase().includes(q) || (g.category || "").toLowerCase().includes(q)); | |
| 117 | +} | |
| 118 | + | |
| 119 | +function render() { | |
| 120 | + const rows = filtered(); | |
| 121 | + document.getElementById("countHint").textContent = `共 ${items.length} 条,当前筛选 ${rows.length} 条`; | |
| 122 | + | |
| 123 | + const tbody = document.getElementById("tbody"); | |
| 124 | + tbody.innerHTML = rows | |
| 125 | + .map((g) => { | |
| 126 | + if (g.id === editingId) { | |
| 127 | + return ` | |
| 128 | + <tr data-id="${g.id}"> | |
| 129 | + <td><img class="thumb" src="${escapeHtml(g.thumbnailUrl || g.imageUrl)}" /></td> | |
| 130 | + <td class="name"><input data-field="name" value="${escapeHtml(g.name)}" /></td> | |
| 131 | + <td class="name"><input data-field="category" value="${escapeHtml(g.category || "")}" /></td> | |
| 132 | + <td class="desc"><textarea data-field="appearanceDescriptor">${escapeHtml(g.appearanceDescriptor || "")}</textarea></td> | |
| 133 | + <td><input type="checkbox" data-field="isActive" ${g.isActive ? "checked" : ""} /></td> | |
| 134 | + <td>${g.source}</td> | |
| 135 | + <td>${new Date(g.createdAt).toLocaleDateString()}</td> | |
| 136 | + <td class="row-actions"> | |
| 137 | + <button data-save="${g.id}">保存</button> | |
| 138 | + <button class="secondary" data-cancel="1">取消</button> | |
| 139 | + </td> | |
| 140 | + </tr>`; | |
| 141 | + } | |
| 142 | + return ` | |
| 143 | + <tr data-id="${g.id}"> | |
| 144 | + <td><img class="thumb" src="${escapeHtml(g.thumbnailUrl || g.imageUrl)}" /></td> | |
| 145 | + <td class="name">${escapeHtml(g.name)}</td> | |
| 146 | + <td class="name">${escapeHtml(g.category || "")}</td> | |
| 147 | + <td class="desc" title="${escapeHtml(g.appearanceDescriptor || "")}">${escapeHtml(g.appearanceDescriptor || "")}</td> | |
| 148 | + <td>${g.isActive ? "上架" : '<span class="inactive-badge">下架</span>'}</td> | |
| 149 | + <td>${g.source}</td> | |
| 150 | + <td>${new Date(g.createdAt).toLocaleDateString()}</td> | |
| 151 | + <td class="row-actions"> | |
| 152 | + <button class="secondary" data-edit="${g.id}">编辑</button> | |
| 153 | + <button class="danger" data-del="${g.id}">删除</button> | |
| 154 | + </td> | |
| 155 | + </tr>`; | |
| 156 | + }) | |
| 157 | + .join(""); | |
| 158 | + | |
| 159 | + tbody.querySelectorAll("[data-edit]").forEach((b) => b.addEventListener("click", () => { editingId = b.dataset.edit; render(); })); | |
| 160 | + tbody.querySelectorAll("[data-cancel]").forEach((b) => b.addEventListener("click", () => { editingId = null; render(); })); | |
| 161 | + tbody.querySelectorAll("[data-save]").forEach((b) => | |
| 162 | + b.addEventListener("click", async () => { | |
| 163 | + const tr = b.closest("tr"); | |
| 164 | + const body = {}; | |
| 165 | + tr.querySelectorAll("[data-field]").forEach((el) => { | |
| 166 | + if (el.type === "checkbox") body[el.dataset.field] = el.checked; | |
| 167 | + else body[el.dataset.field] = el.value; | |
| 168 | + }); | |
| 169 | + const res = await fetch(`/api/admin/garments/${b.dataset.save}`, { | |
| 170 | + method: "PATCH", | |
| 171 | + headers: { "Content-Type": "application/json" }, | |
| 172 | + body: JSON.stringify(body), | |
| 173 | + }); | |
| 174 | + if (res.ok) { | |
| 175 | + editingId = null; | |
| 176 | + await load(); | |
| 177 | + } | |
| 178 | + }) | |
| 179 | + ); | |
| 180 | + tbody.querySelectorAll("[data-del]").forEach((b) => | |
| 181 | + b.addEventListener("click", async () => { | |
| 182 | + if (!confirm("确认删除这件衣服?")) return; | |
| 183 | + const res = await fetch(`/api/admin/garments/${b.dataset.del}`, { method: "DELETE" }); | |
| 184 | + if (res.ok) await load(); | |
| 185 | + }) | |
| 186 | + ); | |
| 187 | +} | |
| 188 | + | |
| 189 | +document.getElementById("searchInput").addEventListener("input", render); | |
| 190 | + | |
| 191 | +document.getElementById("addBtn").addEventListener("click", async () => { | |
| 192 | + const errorBox = document.getElementById("addError"); | |
| 193 | + errorBox.hidden = true; | |
| 194 | + const name = document.getElementById("newName").value.trim(); | |
| 195 | + const category = document.getElementById("newCategory").value.trim(); | |
| 196 | + const file = document.getElementById("newImageFile").files[0]; | |
| 197 | + const appearanceDescriptor = document.getElementById("newAppearanceDescriptor").value.trim(); | |
| 198 | + const isActive = document.getElementById("newIsActive").checked; | |
| 199 | + if (!name || !file || !appearanceDescriptor) { | |
| 200 | + errorBox.hidden = false; | |
| 201 | + errorBox.textContent = "名称、图片、外观描述都必填"; | |
| 202 | + return; | |
| 203 | + } | |
| 204 | + try { | |
| 205 | + const imageUrl = await uploadImage(file); | |
| 206 | + const res = await fetch("/api/admin/garments", { | |
| 207 | + method: "POST", | |
| 208 | + headers: { "Content-Type": "application/json" }, | |
| 209 | + body: JSON.stringify({ name, category, imageUrl, appearanceDescriptor, isActive }), | |
| 210 | + }); | |
| 211 | + if (res.ok) { | |
| 212 | + document.getElementById("newName").value = ""; | |
| 213 | + document.getElementById("newCategory").value = ""; | |
| 214 | + document.getElementById("newImageFile").value = ""; | |
| 215 | + document.getElementById("newAppearanceDescriptor").value = ""; | |
| 216 | + document.getElementById("newIsActive").checked = true; | |
| 217 | + await load(); | |
| 218 | + } else { | |
| 219 | + errorBox.hidden = false; | |
| 220 | + errorBox.textContent = (await res.json()).error || "新增失败"; | |
| 221 | + } | |
| 222 | + } catch (e) { | |
| 223 | + errorBox.hidden = false; | |
| 224 | + errorBox.textContent = e.message; | |
| 225 | + } | |
| 226 | +}); | |
| 227 | + | |
| 228 | +load(); | |
| 229 | +</script> | |
| 230 | +</body> | |
| 231 | +</html> | ... | ... |
admin-pages-flow.test.js
0 → 100644
| 1 | +// End-to-end check of the exact sequence admin-characters.html / | |
| 2 | +// admin-garments.html / admin-dance-refs.html perform in the browser: | |
| 3 | +// login -> upload a real file -> create the record with the returned URL | |
| 4 | +// -> list -> confirm the shape has everything the page renders. Doesn't | |
| 5 | +// render actual HTML/JS (no browser tooling available here), but exercises | |
| 6 | +// the same API calls in the same order with real files, which is where a | |
| 7 | +// field-name mismatch between a page's JS and the backend would surface. | |
| 8 | +const test = require("node:test"); | |
| 9 | +const assert = require("node:assert/strict"); | |
| 10 | +const fs = require("node:fs"); | |
| 11 | +const path = require("node:path"); | |
| 12 | +const os = require("node:os"); | |
| 13 | +const { spawn } = require("node:child_process"); | |
| 14 | + | |
| 15 | +const PORT = 21934 + Math.floor(Math.random() * 1000); | |
| 16 | +const BASE = `http://localhost:${PORT}`; | |
| 17 | +const ADMIN_PASSWORD = "test-password-only-for-this-run"; | |
| 18 | +let cookie; | |
| 19 | +let child; | |
| 20 | +let cwd; | |
| 21 | + | |
| 22 | +const TINY_VALID_PNG = Buffer.from( | |
| 23 | + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC", | |
| 24 | + "base64" | |
| 25 | +); | |
| 26 | + | |
| 27 | +function waitForServer(url, timeoutMs = 10000) { | |
| 28 | + const deadline = Date.now() + timeoutMs; | |
| 29 | + return (async function poll() { | |
| 30 | + while (Date.now() < deadline) { | |
| 31 | + try { | |
| 32 | + await fetch(url); | |
| 33 | + return; | |
| 34 | + } catch { | |
| 35 | + await new Promise((r) => setTimeout(r, 100)); | |
| 36 | + } | |
| 37 | + } | |
| 38 | + throw new Error("server did not come up in time"); | |
| 39 | + })(); | |
| 40 | +} | |
| 41 | + | |
| 42 | +test.before(async () => { | |
| 43 | + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "admin-pages-flow-")); | |
| 44 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js"]) { | |
| 45 | + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); | |
| 46 | + } | |
| 47 | + | |
| 48 | + child = spawn(process.execPath, ["server.js"], { | |
| 49 | + cwd, | |
| 50 | + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD }, | |
| 51 | + stdio: "pipe", | |
| 52 | + }); | |
| 53 | + await waitForServer(BASE); | |
| 54 | + | |
| 55 | + const loginRes = await fetch(`${BASE}/api/admin/login`, { | |
| 56 | + method: "POST", | |
| 57 | + headers: { "Content-Type": "application/json" }, | |
| 58 | + body: JSON.stringify({ password: ADMIN_PASSWORD }), | |
| 59 | + }); | |
| 60 | + cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0]; | |
| 61 | +}); | |
| 62 | + | |
| 63 | +test.after(() => { | |
| 64 | + child.kill(); | |
| 65 | + fs.rmSync(cwd, { recursive: true, force: true }); | |
| 66 | +}); | |
| 67 | + | |
| 68 | +test("admin-characters.html flow: upload image then create, list shows imageUrl reachable via GET /uploads/*", async () => { | |
| 69 | + const fd = new FormData(); | |
| 70 | + fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "person.png"); | |
| 71 | + const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd }); | |
| 72 | + assert.equal(uploadRes.status, 200); | |
| 73 | + const { url: imageUrl } = await uploadRes.json(); | |
| 74 | + | |
| 75 | + const createRes = await fetch(`${BASE}/api/admin/characters`, { | |
| 76 | + method: "POST", | |
| 77 | + headers: { "Content-Type": "application/json", Cookie: cookie }, | |
| 78 | + body: JSON.stringify({ name: "小美", imageUrl, appearanceDescriptor: "black hair", tags: ["seed"], isDefault: true }), | |
| 79 | + }); | |
| 80 | + assert.equal(createRes.status, 200); | |
| 81 | + const created = await createRes.json(); | |
| 82 | + | |
| 83 | + const list = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } }).then((r) => r.json()); | |
| 84 | + const row = list.find((c) => c.id === created.id); | |
| 85 | + assert.ok(row); | |
| 86 | + assert.equal(row.imageUrl, imageUrl); | |
| 87 | + | |
| 88 | + const served = await fetch(`${BASE}${row.imageUrl}`); | |
| 89 | + assert.equal(served.status, 200); | |
| 90 | +}); | |
| 91 | + | |
| 92 | +test("admin-garments.html flow: upload image then create, thumbnailUrl falls back to imageUrl for the table's <img>", async () => { | |
| 93 | + const fd = new FormData(); | |
| 94 | + fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "garment.png"); | |
| 95 | + const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd }); | |
| 96 | + const { url: imageUrl } = await uploadRes.json(); | |
| 97 | + | |
| 98 | + const createRes = await fetch(`${BASE}/api/admin/garments`, { | |
| 99 | + method: "POST", | |
| 100 | + headers: { "Content-Type": "application/json", Cookie: cookie }, | |
| 101 | + body: JSON.stringify({ name: "红旗袍", category: "古风", imageUrl, appearanceDescriptor: "red qipao" }), | |
| 102 | + }); | |
| 103 | + const created = await createRes.json(); | |
| 104 | + assert.equal(created.thumbnailUrl, imageUrl); | |
| 105 | + | |
| 106 | + const served = await fetch(`${BASE}${created.thumbnailUrl}`); | |
| 107 | + assert.equal(served.status, 200); | |
| 108 | +}); | |
| 109 | + | |
| 110 | +test("admin-dance-refs.html flow: upload real mp4 then create, videoUrl reachable and motionDescriptor round-trips", async () => { | |
| 111 | + const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4")); | |
| 112 | + const fd = new FormData(); | |
| 113 | + fd.append("file", new Blob([realMp4], { type: "video/mp4" }), "dance.mp4"); | |
| 114 | + const uploadRes = await fetch(`${BASE}/api/admin/upload-video`, { method: "POST", headers: { Cookie: cookie }, body: fd }); | |
| 115 | + assert.equal(uploadRes.status, 200); | |
| 116 | + const { url: videoUrl } = await uploadRes.json(); | |
| 117 | + | |
| 118 | + const createRes = await fetch(`${BASE}/api/admin/dance-refs`, { | |
| 119 | + method: "POST", | |
| 120 | + headers: { "Content-Type": "application/json", Cookie: cookie }, | |
| 121 | + body: JSON.stringify({ name: "拉丁舞", videoUrl, motionDescriptor: "hip sways and raised-arm turns", styleTags: ["latin"] }), | |
| 122 | + }); | |
| 123 | + assert.equal(createRes.status, 200); | |
| 124 | + const created = await createRes.json(); | |
| 125 | + assert.equal(created.videoUrl, videoUrl); | |
| 126 | + assert.equal(created.motionDescriptor, "hip sways and raised-arm turns"); | |
| 127 | + | |
| 128 | + const served = await fetch(`${BASE}${created.videoUrl}`); | |
| 129 | + assert.equal(served.status, 200); | |
| 130 | + assert.equal(served.headers.get("content-type"), "video/mp4"); | |
| 131 | +}); | ... | ... |
admin.html
| ... | ... | @@ -29,6 +29,9 @@ |
| 29 | 29 | <header class="topbar"> |
| 30 | 30 | <h1>视频库管理</h1> |
| 31 | 31 | <div class="spacer"></div> |
| 32 | + <a href="admin-characters.html">人物库</a> | |
| 33 | + <a href="admin-garments.html">衣服库</a> | |
| 34 | + <a href="admin-dance-refs.html">参考跳舞视频库</a> | |
| 32 | 35 | <a href="live.html">← 返回直播频道</a> |
| 33 | 36 | </header> |
| 34 | 37 | ... | ... |