Commit 2cc989c9be87abefe18d67fa4465ca26ffea769d

Authored by yibo
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.
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  
... ...