Commit 85c673077e74e2f82f5df601433f790fd9de9ac8

Authored by yibo
1 parent b16b5418

Add T28 (传衣服 tab) + T29 (credit balance + topup UI) to dance.html

T28 replaces the "开发中敬请期待" placeholder on dance.html's 传衣服
tab with a real upload panel: file input, the live spend_garment price
pulled from the new public GET /api/pricing (not hardcoded, per
dev-plan-4.md's acceptance criterion), and a submit flow that POSTs to
T27's /api/dance/upload-garment. Not logged in opens the existing T22
login modal instead of failing silently; not email-verified shows an
inline message; a 402 (insufficient balance) points at the new topup
flow; success refreshes the balance and immediately polls the queue,
same as the free wardrobe pick's own pollState() call.

T29 adds a 充值 pill (visible once logged in) opening a tier picker
built from GET /api/pricing's topup_tier entries, POSTing to T24's
/api/credits/checkout and navigating to whatever URL comes back (the
mock completion page today, Stripe's hosted Checkout once a real key
lands tomorrow — both redirect back to dance.html?topup=success/
cancelled). A one-shot banner reads that query param on load, then
scrubs it via history.replaceState. The account button's own label
now also shows creditBalance, riding on the field GET /api/auth/me
already started returning in T24.

Manually verified end to end against the live server (localhost:8934):
register -> verify -> mock checkout -> balance updates in the header
-> topup banner -> upload panel shows the live price and opens the
login modal when logged out. The upload itself surfaced that
production's one seed character had never been marked isDefault (see
the preceding commit) — not a bug in this code, but blocked a live
walk of the full generate path until fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
dance.css
... ... @@ -52,4 +52,43 @@
52 52  
53 53 .coming-soon { color: var(--muted); text-align: center; margin-top: 40px; font-size: 13px; font-weight: bold; padding: 0 12px; }
54 54  
  55 +/* T28: 传衣服 (mode 2) upload panel — same comic-panel language as
  56 + .wardrobe-card, laid out as a simple vertical form instead of a grid. */
  57 +.upload-panel { padding: 8px 4px; }
  58 +.upload-panel .hint { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.5; }
  59 +.upload-panel input[type="file"] { width: 100%; font-size: 12px; margin-bottom: 10px; }
  60 +.upload-panel .price-line { font: bold 13px Tahoma, sans-serif; margin-bottom: 10px; }
  61 +.upload-panel button {
  62 + width: 100%;
  63 + font: bold 13px Tahoma, sans-serif;
  64 + background: var(--accent);
  65 + border: 3px solid #000;
  66 + color: var(--ink);
  67 + border-radius: 12px;
  68 + padding: 8px 4px;
  69 + box-shadow: var(--shadow-sm);
  70 + cursor: pointer;
  71 +}
  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; }
  74 +.upload-panel .upload-status { font: bold 12px Tahoma, sans-serif; margin-top: 8px; }
  75 +
  76 +/* T29: balance pill + topup tier picker (reuses .nickname-modal/.nickname-box) */
  77 +.credit-pill { font: bold 12px Tahoma, sans-serif; }
  78 +.topup-tier {
  79 + display: flex;
  80 + justify-content: space-between;
  81 + align-items: center;
  82 + border: 3px solid #000;
  83 + border-radius: 12px;
  84 + padding: 8px 10px;
  85 + margin-bottom: 8px;
  86 + box-shadow: var(--shadow-sm);
  87 +}
  88 +.topup-tier .credits { font: bold 14px Tahoma, sans-serif; }
  89 +.topup-tier button { font: bold 12px Tahoma, sans-serif; background: var(--accent); border: 3px solid #000; border-radius: 10px; padding: 5px 10px; cursor: pointer; }
  90 +.topup-banner { text-align: center; font: bold 13px Tahoma, sans-serif; padding: 8px; margin: 0 0 10px; border-radius: 10px; }
  91 +.topup-banner.success { background: #d4f7d4; }
  92 +.topup-banner.cancelled { background: #f7e4d4; }
  93 +
55 94 .age-box .age-disclaimer { font-size: 12px; line-height: 1.6; color: #444; font-weight: normal; }
... ...
dance.html
... ... @@ -20,10 +20,13 @@
20 20 <div class="spacer"></div>
21 21 <div class="watching" id="watchingCount" hidden></div>
22 22 <button class="topbar-pill" id="muteBtn">🔇 静音</button>
  23 + <button class="topbar-pill" id="topupBtn" hidden>💰 充值</button>
23 24 <button class="topbar-pill" id="authBtn">登录</button>
24 25 <a class="topbar-pill" href="live.html">AI 短剧频道 →</a>
25 26 </header>
26 27  
  28 + <div id="topupBanner" hidden></div>
  29 +
27 30 <div class="body" id="body">
28 31 <aside class="left-col" id="leftCol">
29 32 <div class="wardrobe-tabs" id="wardrobeTabs">
... ... @@ -93,6 +96,15 @@
93 96 </div>
94 97 </div>
95 98  
  99 +<!-- T29: topup tier picker — reuses .nickname-modal/.nickname-box's shell -->
  100 +<div class="nickname-modal" id="topupModal" hidden>
  101 + <div class="nickname-box">
  102 + <div class="nickname-title">充值</div>
  103 + <div id="topupTierList"></div>
  104 + <button class="auth-link-btn" id="topupCloseBtn">取消</button>
  105 + </div>
  106 +</div>
  107 +
96 108 <script>
97 109 let wardrobe = [];
98 110 let leftTab = "wardrobe";
... ... @@ -190,6 +202,8 @@ function ensureIdentity() {
190 202 // viewer_nickname onto this browser so switching devices or clearing
191 203 // cookies doesn't silently start a fresh anonymous identity.
192 204 const authBtn = document.getElementById("authBtn");
  205 +const topupBtn = document.getElementById("topupBtn");
  206 +const topupBanner = document.getElementById("topupBanner");
193 207 const authModal = document.getElementById("authModal");
194 208 const authTitle = document.getElementById("authTitle");
195 209 const authEmail = document.getElementById("authEmail");
... ... @@ -199,10 +213,14 @@ const authSubmitBtn = document.getElementById(&quot;authSubmitBtn&quot;);
199 213 const authToggleBtn = document.getElementById("authToggleBtn");
200 214 const authCloseBtn = document.getElementById("authCloseBtn");
201 215 let authMode = "login";
  216 +let currentUser = null; // T28/T29 need to know login/emailVerified/creditBalance beyond just "is authBtn's dot lit"
202 217  
203 218 function renderAuthButton(user) {
204   - authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 退出` : "登录";
  219 + currentUser = user;
  220 + authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 💰${user.creditBalance} · 退出` : "登录";
205 221 authBtn.dataset.loggedIn = user ? "1" : "";
  222 + topupBtn.hidden = !user;
  223 + if (leftTab === "garment") renderWardrobe(); // price/balance-gated panel depends on login state
206 224 }
207 225  
208 226 async function refreshAuthState() {
... ... @@ -265,6 +283,82 @@ authPassword.addEventListener(&quot;keydown&quot;, (e) =&gt; {
265 283  
266 284 refreshAuthState();
267 285  
  286 +// ---- T29: 积分余额 + 充值 (dev-plan-4.md) ----
  287 +let pricing = [];
  288 +async function loadPricing() {
  289 + try {
  290 + pricing = await fetch("/api/pricing").then((r) => r.json());
  291 + } catch {
  292 + pricing = [];
  293 + }
  294 +}
  295 +
  296 +const topupModal = document.getElementById("topupModal");
  297 +const topupTierList = document.getElementById("topupTierList");
  298 +const topupCloseBtn = document.getElementById("topupCloseBtn");
  299 +
  300 +function renderTopupTiers() {
  301 + const tiers = pricing.filter((p) => p.kind === "topup_tier");
  302 + topupTierList.innerHTML = tiers.length
  303 + ? tiers
  304 + .map(
  305 + (t) => `
  306 + <div class="topup-tier">
  307 + <div><div class="credits">${t.credits} 积分</div>${t.priceCents != null ? `<div class="hint">$${(t.priceCents / 100).toFixed(2)}</div>` : ""}</div>
  308 + <button data-tier="${t.key}">购买</button>
  309 + </div>`
  310 + )
  311 + .join("")
  312 + : `<div class="empty-hint">暂无可购买档位</div>`;
  313 + topupTierList.querySelectorAll("[data-tier]").forEach((btn) =>
  314 + btn.addEventListener("click", async () => {
  315 + btn.disabled = true;
  316 + try {
  317 + const res = await fetch("/api/credits/checkout", {
  318 + method: "POST",
  319 + headers: { "Content-Type": "application/json" },
  320 + body: JSON.stringify({ tierKey: btn.dataset.tier }),
  321 + });
  322 + const data = await res.json();
  323 + if (!res.ok) {
  324 + alert(data.error || "创建支付会话失败");
  325 + return;
  326 + }
  327 + location.href = data.url; // Stripe's hosted page (real) or the mock completion URL — both redirect back here
  328 + } finally {
  329 + btn.disabled = false;
  330 + }
  331 + })
  332 + );
  333 +}
  334 +
  335 +topupBtn.addEventListener("click", async () => {
  336 + if (!currentUser) {
  337 + openAuthModal("login");
  338 + return;
  339 + }
  340 + if (!pricing.length) await loadPricing();
  341 + renderTopupTiers();
  342 + topupModal.hidden = false;
  343 +});
  344 +topupCloseBtn.addEventListener("click", () => {
  345 + topupModal.hidden = true;
  346 +});
  347 +
  348 +// A redirect back from Stripe (or the mock checkout) lands here with
  349 +// ?topup=success/cancelled — show a one-off banner, then scrub the query
  350 +// string so a page refresh doesn't keep re-showing it.
  351 +(function showTopupBanner() {
  352 + const topup = new URLSearchParams(location.search).get("topup");
  353 + if (!topup) return;
  354 + topupBanner.hidden = false;
  355 + topupBanner.className = topup === "success" ? "topup-banner success" : "topup-banner cancelled";
  356 + topupBanner.textContent = topup === "success" ? "充值成功!" : "已取消充值";
  357 + history.replaceState(null, "", location.pathname);
  358 +})();
  359 +
  360 +loadPricing();
  361 +
268 362 muteBtn.addEventListener("click", () => {
269 363 player.muted = !player.muted;
270 364 muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声";
... ... @@ -279,8 +373,12 @@ function garmentName(id) {
279 373  
280 374 function renderWardrobe() {
281 375 document.querySelectorAll("#wardrobeTabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === leftTab));
  376 + if (leftTab === "garment") {
  377 + renderGarmentUploadPanel();
  378 + return;
  379 + }
282 380 if (leftTab !== "wardrobe") {
283   - wardrobePanel.innerHTML = `<div class="coming-soon">${leftTab === "garment" ? "传衣服" : "传人"}功能开发中,敬请期待</div>`;
  381 + wardrobePanel.innerHTML = `<div class="coming-soon">传人功能开发中,敬请期待</div>`;
284 382 return;
285 383 }
286 384 if (wardrobe.length === 0) {
... ... @@ -301,6 +399,77 @@ function renderWardrobe() {
301 399 );
302 400 }
303 401  
  402 +// ---- T28: 模式二"传衣服"(付费,需登录+邮箱验证) ----
  403 +function renderGarmentUploadPanel() {
  404 + const spendGarment = pricing.find((p) => p.key === "spend_garment");
  405 + const priceLine = spendGarment ? `上传一张衣服照片,AI 换装生成跳舞视频,扣 ${spendGarment.credits} 积分` : "加载价格中…";
  406 + wardrobePanel.innerHTML = `
  407 + <div class="upload-panel">
  408 + <div class="hint">${priceLine}</div>
  409 + <input type="file" id="garmentFileInput" accept="image/jpeg,image/png,image/webp" />
  410 + <button id="garmentUploadBtn">上传并生成</button>
  411 + <div class="upload-error" id="garmentUploadError" hidden></div>
  412 + <div class="upload-status" id="garmentUploadStatus" hidden></div>
  413 + </div>`;
  414 + document.getElementById("garmentUploadBtn").addEventListener("click", submitGarmentUpload);
  415 +}
  416 +
  417 +async function submitGarmentUpload() {
  418 + const fileInput = document.getElementById("garmentFileInput");
  419 + const errorBox = document.getElementById("garmentUploadError");
  420 + const statusBox = document.getElementById("garmentUploadStatus");
  421 + const btn = document.getElementById("garmentUploadBtn");
  422 + errorBox.hidden = true;
  423 + statusBox.hidden = true;
  424 +
  425 + // 未登录/未验证先引导,而不是提交后静默失败(T28 验收标准)
  426 + if (!currentUser) {
  427 + openAuthModal("login");
  428 + return;
  429 + }
  430 + if (!currentUser.emailVerified) {
  431 + errorBox.hidden = false;
  432 + errorBox.textContent = "请先完成邮箱验证后再上传(查看邮箱中的验证链接)";
  433 + return;
  434 + }
  435 + const file = fileInput.files[0];
  436 + if (!file) {
  437 + errorBox.hidden = false;
  438 + errorBox.textContent = "请先选择一张图片";
  439 + return;
  440 + }
  441 +
  442 + btn.disabled = true;
  443 + statusBox.hidden = false;
  444 + statusBox.textContent = "上传中…";
  445 + try {
  446 + const fd = new FormData();
  447 + fd.append("file", file);
  448 + const res = await fetch("/api/dance/upload-garment", { method: "POST", body: fd });
  449 + const data = await res.json().catch(() => ({}));
  450 + if (!res.ok) {
  451 + statusBox.hidden = true;
  452 + errorBox.hidden = false;
  453 + if (res.status === 402) {
  454 + errorBox.textContent = "积分不足,请先充值";
  455 + } else {
  456 + errorBox.textContent = data.error || "上传失败";
  457 + }
  458 + return;
  459 + }
  460 + statusBox.textContent = data.status === "ready" ? "生成完成,已加入播放队列!" : "生成中,完成后会自动加入播放队列";
  461 + fileInput.value = "";
  462 + await refreshAuthState(); // balance just changed
  463 + pollState(); // surface the new queue entry immediately, same as pickGarment
  464 + } catch {
  465 + statusBox.hidden = true;
  466 + errorBox.hidden = false;
  467 + errorBox.textContent = "网络错误,请重试";
  468 + } finally {
  469 + btn.disabled = false;
  470 + }
  471 +}
  472 +
304 473 document.querySelectorAll("#wardrobeTabs button").forEach((b) =>
305 474 b.addEventListener("click", () => {
306 475 leftTab = b.dataset.tab;
... ...
pricing.json 0 → 100644
  1 +[
  2 + {
  3 + "key": "spend_garment",
  4 + "kind": "spend",
  5 + "credits": 10,
  6 + "priceCents": null,
  7 + "segmentsPerUnit": null,
  8 + "isActive": true,
  9 + "updatedAt": 1788356862386
  10 + },
  11 + {
  12 + "key": "topup_tier_1",
  13 + "kind": "topup_tier",
  14 + "credits": 100,
  15 + "priceCents": 999,
  16 + "segmentsPerUnit": null,
  17 + "isActive": true,
  18 + "updatedAt": 1788356862386
  19 + },
  20 + {
  21 + "key": "topup_tier_2",
  22 + "kind": "topup_tier",
  23 + "credits": 550,
  24 + "priceCents": 4999,
  25 + "segmentsPerUnit": null,
  26 + "isActive": true,
  27 + "updatedAt": 1788356862386
  28 + }
  29 +]
0 30 \ No newline at end of file
... ...
server.js
... ... @@ -1586,8 +1586,9 @@ http
1586 1586  
1587 1587 // ---- dance.html: public viewer-facing endpoints (T11/T12/T15/T17) ----
1588 1588 // No admin auth (T6 only gates /api/admin/*) — these are the free,
1589   - // anonymous, 模式一 (衣柜选衣服) path. Modes 二/三 (paid upload) aren't
1590   - // built yet (require accounts/credits, dev-plan-2.md 第三/四期).
  1589 + // anonymous, 模式一 (衣柜选衣服) path. 模式二 (付费传衣服) is T27,
  1590 + // below, and does require login+emailVerified on its own. 模式三 isn't
  1591 + // built yet (第五期).
1591 1592  
1592 1593 if (pathname === "/api/dance/wardrobe" && req.method === "GET") {
1593 1594 res.writeHead(200, { "Content-Type": "application/json" });
... ... @@ -1595,6 +1596,15 @@ http
1595 1596 return;
1596 1597 }
1597 1598  
  1599 + // T28/T29 need pricing (spend_garment's cost, topup_tier options) on
  1600 + // the client before any purchase decision — public/unauthenticated on
  1601 + // purpose, same reasoning as GET /api/dance/wardrobe above.
  1602 + if (pathname === "/api/pricing" && req.method === "GET") {
  1603 + res.writeHead(200, { "Content-Type": "application/json" });
  1604 + res.end(JSON.stringify(pricing.list().filter((p) => p.isActive)));
  1605 + return;
  1606 + }
  1607 +
1598 1608 if (pathname === "/api/dance/live/state" && req.method === "GET") {
1599 1609 ensureDanceSchedule();
1600 1610 const now = Date.now();
... ...