Commit 903e6fefd31541044c5cd8d1c3aef13525ff540a
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
Showing
3 changed files
with
116 additions
and
6 deletions
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 |