Commit 903e6fefd31541044c5cd8d1c3aef13525ff540a

Authored by yibo
1 parent 824ba494

Add T37: dance.html's 传人 tab, wired to T35's mode 3 endpoint

Same shape as T28's 传衣服 panel: file input, live price pulled from
GET /api/pricing (今 spend_character_per_segment × segmentsPerUnit,
not hardcoded), a garment picker (随机搭配 default, or any wardrobe
item, 需求文档 3.3 第 4 点), a segments-to-buy input (1-20, matches the
server-side clamp), not-logged-in opens the existing login modal
instead of failing silently, insufficient balance points at the topup
flow. Success message is visually distinct ("boosted", yellow box,
🎉 已插队播放") from mode 1/2's plain "生成中" — T37's explicit
acceptance criterion that a paid_boost submission must be recognizable
as skipping the vote queue, not just another queue entry.

Also fixes a real bug this surfaced via a live smoke test (not just
new to T37 — T28's submitGarmentUpload had the identical bug): both
upload handlers set the success/status message and *then* called
`await refreshAuthState()`, which re-renders the whole panel
(creditBalance changed -> renderAuthButton -> renderWardrobe) and wipes
whatever was just set. Both now set the message after refreshAuthState()
resolves, re-querying the fresh DOM it produced.

Seeded the live pricing.json with spend_character_per_segment (it only
existed in pricing.js's DEFAULTS, which don't retroactively apply to
an already-existing file — the same class of gap as the isDefault
character fix two commits ago).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
dance.css
@@ -72,6 +72,10 @@ @@ -72,6 +72,10 @@
72 .upload-panel button:disabled { opacity: .5; cursor: default; } 72 .upload-panel button:disabled { opacity: .5; cursor: default; }
73 .upload-panel .upload-error { color: #c0392b; font: bold 12px Tahoma, sans-serif; margin-top: 8px; } 73 .upload-panel .upload-error { color: #c0392b; font: bold 12px Tahoma, sans-serif; margin-top: 8px; }
74 .upload-panel .upload-status { font: bold 12px Tahoma, sans-serif; margin-top: 8px; } 74 .upload-panel .upload-status { font: bold 12px Tahoma, sans-serif; margin-top: 8px; }
  75 +/* T37: paid_boost 插队播放跟模式一/二的"生成中"要有视觉区别 */
  76 +.upload-panel .upload-status.boosted { color: #b8860b; background: #fff7d6; border: 2px solid #000; border-radius: 8px; padding: 6px 8px; }
  77 +.upload-panel select { width: 100%; font-size: 12px; margin-bottom: 10px; padding: 4px; }
  78 +.upload-panel .field-label { font: bold 12px Tahoma, sans-serif; margin-bottom: 4px; display: block; }
75 79
76 /* T29: balance pill + topup tier picker (reuses .nickname-modal/.nickname-box) */ 80 /* T29: balance pill + topup tier picker (reuses .nickname-modal/.nickname-box) */
77 .credit-pill { font: bold 12px Tahoma, sans-serif; } 81 .credit-pill { font: bold 12px Tahoma, sans-serif; }
dance.html
@@ -220,7 +220,7 @@ function renderAuthButton(user) { @@ -220,7 +220,7 @@ function renderAuthButton(user) {
220 authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 💰${user.creditBalance} · 退出` : "登录"; 220 authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 💰${user.creditBalance} · 退出` : "登录";
221 authBtn.dataset.loggedIn = user ? "1" : ""; 221 authBtn.dataset.loggedIn = user ? "1" : "";
222 topupBtn.hidden = !user; 222 topupBtn.hidden = !user;
223 - if (leftTab === "garment") renderWardrobe(); // price/balance-gated panel depends on login state 223 + if (leftTab === "garment" || leftTab === "character") renderWardrobe(); // price/balance-gated panels depend on login state
224 } 224 }
225 225
226 async function refreshAuthState() { 226 async function refreshAuthState() {
@@ -377,8 +377,8 @@ function renderWardrobe() { @@ -377,8 +377,8 @@ function renderWardrobe() {
377 renderGarmentUploadPanel(); 377 renderGarmentUploadPanel();
378 return; 378 return;
379 } 379 }
380 - if (leftTab !== "wardrobe") {  
381 - wardrobePanel.innerHTML = `<div class="coming-soon">传人功能开发中,敬请期待</div>`; 380 + if (leftTab === "character") {
  381 + renderCharacterUploadPanel();
382 return; 382 return;
383 } 383 }
384 if (wardrobe.length === 0) { 384 if (wardrobe.length === 0) {
@@ -457,9 +457,16 @@ async function submitGarmentUpload() { @@ -457,9 +457,16 @@ async function submitGarmentUpload() {
457 } 457 }
458 return; 458 return;
459 } 459 }
460 - statusBox.textContent = data.status === "ready" ? "生成完成,已加入播放队列!" : "生成中,完成后会自动加入播放队列";  
461 - fileInput.value = "";  
462 - await refreshAuthState(); // balance just changed 460 + const message = data.status === "ready" ? "生成完成,已加入播放队列!" : "生成中,完成后会自动加入播放队列";
  461 + // refreshAuthState() re-renders this whole panel (creditBalance changed
  462 + // -> renderAuthButton -> renderWardrobe) — setting the message before
  463 + // that would just get wiped by the fresh render, so set it after and
  464 + // re-query, not on the (now-replaced) statusBox/fileInput above.
  465 + await refreshAuthState();
  466 + document.getElementById("garmentFileInput").value = "";
  467 + const freshStatusBox = document.getElementById("garmentUploadStatus");
  468 + freshStatusBox.hidden = false;
  469 + freshStatusBox.textContent = message;
463 pollState(); // surface the new queue entry immediately, same as pickGarment 470 pollState(); // surface the new queue entry immediately, same as pickGarment
464 } catch { 471 } catch {
465 statusBox.hidden = true; 472 statusBox.hidden = true;
@@ -470,6 +477,96 @@ async function submitGarmentUpload() { @@ -470,6 +477,96 @@ async function submitGarmentUpload() {
470 } 477 }
471 } 478 }
472 479
  480 +// ---- T37: 模式三"传人"(付费,需登录+邮箱验证) ----
  481 +function renderCharacterUploadPanel() {
  482 + const spendCharacter = pricing.find((p) => p.key === "spend_character_per_segment");
  483 + const costPerSegment = spendCharacter ? Math.ceil(spendCharacter.credits / (spendCharacter.segmentsPerUnit || 1)) : null;
  484 + const priceLine = costPerSegment != null ? `上传一张人物照片,每购买 1 个 10 秒片段扣 ${costPerSegment} 积分——买得越多,这个新角色在直播间露出的时长越长` : "加载价格中…";
  485 + const garmentOptions = wardrobe.map((g) => `<option value="${g.id}">${escapeHtml(g.name)}</option>`).join("");
  486 + wardrobePanel.innerHTML = `
  487 + <div class="upload-panel">
  488 + <div class="hint">${priceLine}</div>
  489 + <input type="file" id="characterFileInput" accept="image/jpeg,image/png,image/webp" />
  490 + <label class="field-label" for="characterGarmentSelect">搭配衣服(不选则随机搭配)</label>
  491 + <select id="characterGarmentSelect">
  492 + <option value="">随机搭配</option>
  493 + ${garmentOptions}
  494 + </select>
  495 + <label class="field-label" for="characterSegmentsInput">购买片段数(每个 10 秒)</label>
  496 + <input type="number" id="characterSegmentsInput" min="1" max="20" value="3" style="width:100%;margin-bottom:10px;" />
  497 + <button id="characterUploadBtn">上传并插队播放</button>
  498 + <div class="upload-error" id="characterUploadError" hidden></div>
  499 + <div class="upload-status" id="characterUploadStatus" hidden></div>
  500 + </div>`;
  501 + document.getElementById("characterUploadBtn").addEventListener("click", submitCharacterUpload);
  502 +}
  503 +
  504 +async function submitCharacterUpload() {
  505 + const fileInput = document.getElementById("characterFileInput");
  506 + const garmentSelect = document.getElementById("characterGarmentSelect");
  507 + const segmentsInput = document.getElementById("characterSegmentsInput");
  508 + const errorBox = document.getElementById("characterUploadError");
  509 + const statusBox = document.getElementById("characterUploadStatus");
  510 + const btn = document.getElementById("characterUploadBtn");
  511 + errorBox.hidden = true;
  512 + statusBox.hidden = true;
  513 + statusBox.classList.remove("boosted");
  514 +
  515 + // 未登录/未验证先引导,而不是提交后静默失败(同 T28 验收标准)
  516 + if (!currentUser) {
  517 + openAuthModal("login");
  518 + return;
  519 + }
  520 + if (!currentUser.emailVerified) {
  521 + errorBox.hidden = false;
  522 + errorBox.textContent = "请先完成邮箱验证后再上传(查看邮箱中的验证链接)";
  523 + return;
  524 + }
  525 + const file = fileInput.files[0];
  526 + if (!file) {
  527 + errorBox.hidden = false;
  528 + errorBox.textContent = "请先选择一张人物照片";
  529 + return;
  530 + }
  531 +
  532 + btn.disabled = true;
  533 + statusBox.hidden = false;
  534 + statusBox.textContent = "上传中…";
  535 + try {
  536 + const fd = new FormData();
  537 + fd.append("file", file);
  538 + if (garmentSelect.value) fd.append("garmentId", garmentSelect.value);
  539 + fd.append("segments", segmentsInput.value || "3");
  540 + const res = await fetch("/api/dance/upload-character", { method: "POST", body: fd });
  541 + const data = await res.json().catch(() => ({}));
  542 + if (!res.ok) {
  543 + statusBox.hidden = true;
  544 + errorBox.hidden = false;
  545 + errorBox.textContent = res.status === 402 ? "积分不足,请先充值" : data.error || "上传失败";
  546 + return;
  547 + }
  548 + // 付费插队跟模式一/二的普通"排队"视觉上要有区别(T37 验收标准)——
  549 + // 不管此刻是"生成中"还是缓存命中的"ready",都是走 priorityInbox
  550 + // 插队通道,不进投票队列. Message set after refreshAuthState(), same
  551 + // reasoning as submitGarmentUpload — it re-renders this panel
  552 + // (creditBalance changed), which would wipe anything set beforehand.
  553 + const message = data.status === "ready" ? "🎉 生成完成,已插队播放!" : "🎉 生成中,完成后将直接插队播放(不用排队投票)";
  554 + await refreshAuthState();
  555 + document.getElementById("characterFileInput").value = "";
  556 + const freshStatusBox = document.getElementById("characterUploadStatus");
  557 + freshStatusBox.hidden = false;
  558 + freshStatusBox.classList.add("boosted");
  559 + freshStatusBox.textContent = message;
  560 + pollState(); // surface the new priority entry immediately
  561 + } catch {
  562 + statusBox.hidden = true;
  563 + errorBox.hidden = false;
  564 + errorBox.textContent = "网络错误,请重试";
  565 + } finally {
  566 + btn.disabled = false;
  567 + }
  568 +}
  569 +
473 document.querySelectorAll("#wardrobeTabs button").forEach((b) => 570 document.querySelectorAll("#wardrobeTabs button").forEach((b) =>
474 b.addEventListener("click", () => { 571 b.addEventListener("click", () => {
475 leftTab = b.dataset.tab; 572 leftTab = b.dataset.tab;
pricing.json
@@ -25,5 +25,14 @@ @@ -25,5 +25,14 @@
25 "segmentsPerUnit": null, 25 "segmentsPerUnit": null,
26 "isActive": true, 26 "isActive": true,
27 "updatedAt": 1788356862386 27 "updatedAt": 1788356862386
  28 + },
  29 + {
  30 + "key": "spend_character_per_segment",
  31 + "isActive": true,
  32 + "kind": "spend",
  33 + "credits": 20,
  34 + "priceCents": null,
  35 + "segmentsPerUnit": 1,
  36 + "updatedAt": 1788359892483
28 } 37 }
29 ] 38 ]
30 \ No newline at end of file 39 \ No newline at end of file