diff --git a/admin-characters.html b/admin-characters.html
new file mode 100644
index 0000000..7ca5099
--- /dev/null
+++ b/admin-characters.html
@@ -0,0 +1,234 @@
+
+
+
+
+人物库管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 图片 |
+ 名称 |
+ 外观描述 |
+ 标签 |
+ 默认 |
+ 来源 |
+ 状态 |
+ 创建时间 |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-dance-refs.html b/admin-dance-refs.html
new file mode 100644
index 0000000..3f86722
--- /dev/null
+++ b/admin-dance-refs.html
@@ -0,0 +1,237 @@
+
+
+
+
+参考跳舞视频库管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
动作描述是必填项:这条视频一旦录入,后续所有引用它生成的视频都复用这段描述,不能留空事后补
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 预览 |
+ 名称 |
+ 动作描述 |
+ 风格标签 |
+ 版权来源 |
+ 上架 |
+ 创建时间 |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-garments.html b/admin-garments.html
new file mode 100644
index 0000000..d781e7a
--- /dev/null
+++ b/admin-garments.html
@@ -0,0 +1,231 @@
+
+
+
+
+衣服库管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 图片 |
+ 名称 |
+ 分类 |
+ 外观描述 |
+ 上架 |
+ 来源 |
+ 创建时间 |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-pages-flow.test.js b/admin-pages-flow.test.js
new file mode 100644
index 0000000..0c61bec
--- /dev/null
+++ b/admin-pages-flow.test.js
@@ -0,0 +1,131 @@
+// End-to-end check of the exact sequence admin-characters.html /
+// admin-garments.html / admin-dance-refs.html perform in the browser:
+// login -> upload a real file -> create the record with the returned URL
+// -> list -> confirm the shape has everything the page renders. Doesn't
+// render actual HTML/JS (no browser tooling available here), but exercises
+// the same API calls in the same order with real files, which is where a
+// field-name mismatch between a page's JS and the backend would surface.
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const os = require("node:os");
+const { spawn } = require("node:child_process");
+
+const PORT = 21934 + Math.floor(Math.random() * 1000);
+const BASE = `http://localhost:${PORT}`;
+const ADMIN_PASSWORD = "test-password-only-for-this-run";
+let cookie;
+let child;
+let cwd;
+
+const TINY_VALID_PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC",
+ "base64"
+);
+
+function waitForServer(url, timeoutMs = 10000) {
+ const deadline = Date.now() + timeoutMs;
+ return (async function poll() {
+ while (Date.now() < deadline) {
+ try {
+ await fetch(url);
+ return;
+ } catch {
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ }
+ throw new Error("server did not come up in time");
+ })();
+}
+
+test.before(async () => {
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), "admin-pages-flow-"));
+ for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js"]) {
+ fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
+ }
+
+ child = spawn(process.execPath, ["server.js"], {
+ cwd,
+ env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD },
+ stdio: "pipe",
+ });
+ await waitForServer(BASE);
+
+ const loginRes = await fetch(`${BASE}/api/admin/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password: ADMIN_PASSWORD }),
+ });
+ cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
+});
+
+test.after(() => {
+ child.kill();
+ fs.rmSync(cwd, { recursive: true, force: true });
+});
+
+test("admin-characters.html flow: upload image then create, list shows imageUrl reachable via GET /uploads/*", async () => {
+ const fd = new FormData();
+ fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "person.png");
+ const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd });
+ assert.equal(uploadRes.status, 200);
+ const { url: imageUrl } = await uploadRes.json();
+
+ const createRes = await fetch(`${BASE}/api/admin/characters`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Cookie: cookie },
+ body: JSON.stringify({ name: "小美", imageUrl, appearanceDescriptor: "black hair", tags: ["seed"], isDefault: true }),
+ });
+ assert.equal(createRes.status, 200);
+ const created = await createRes.json();
+
+ const list = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } }).then((r) => r.json());
+ const row = list.find((c) => c.id === created.id);
+ assert.ok(row);
+ assert.equal(row.imageUrl, imageUrl);
+
+ const served = await fetch(`${BASE}${row.imageUrl}`);
+ assert.equal(served.status, 200);
+});
+
+test("admin-garments.html flow: upload image then create, thumbnailUrl falls back to imageUrl for the table's
", async () => {
+ const fd = new FormData();
+ fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "garment.png");
+ const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd });
+ const { url: imageUrl } = await uploadRes.json();
+
+ const createRes = await fetch(`${BASE}/api/admin/garments`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Cookie: cookie },
+ body: JSON.stringify({ name: "红旗袍", category: "古风", imageUrl, appearanceDescriptor: "red qipao" }),
+ });
+ const created = await createRes.json();
+ assert.equal(created.thumbnailUrl, imageUrl);
+
+ const served = await fetch(`${BASE}${created.thumbnailUrl}`);
+ assert.equal(served.status, 200);
+});
+
+test("admin-dance-refs.html flow: upload real mp4 then create, videoUrl reachable and motionDescriptor round-trips", async () => {
+ const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4"));
+ const fd = new FormData();
+ fd.append("file", new Blob([realMp4], { type: "video/mp4" }), "dance.mp4");
+ const uploadRes = await fetch(`${BASE}/api/admin/upload-video`, { method: "POST", headers: { Cookie: cookie }, body: fd });
+ assert.equal(uploadRes.status, 200);
+ const { url: videoUrl } = await uploadRes.json();
+
+ const createRes = await fetch(`${BASE}/api/admin/dance-refs`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Cookie: cookie },
+ body: JSON.stringify({ name: "拉丁舞", videoUrl, motionDescriptor: "hip sways and raised-arm turns", styleTags: ["latin"] }),
+ });
+ assert.equal(createRes.status, 200);
+ const created = await createRes.json();
+ assert.equal(created.videoUrl, videoUrl);
+ assert.equal(created.motionDescriptor, "hip sways and raised-arm turns");
+
+ const served = await fetch(`${BASE}${created.videoUrl}`);
+ assert.equal(served.status, 200);
+ assert.equal(served.headers.get("content-type"), "video/mp4");
+});
diff --git a/admin.html b/admin.html
index 36bf5e8..d760b51 100644
--- a/admin.html
+++ b/admin.html
@@ -29,6 +29,9 @@