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 | 72 | .upload-panel button:disabled { opacity: .5; cursor: default; } |
| 73 | 73 | .upload-panel .upload-error { color: #c0392b; font: bold 12px Tahoma, sans-serif; margin-top: 8px; } |
| 74 | 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 | 80 | /* T29: balance pill + topup tier picker (reuses .nickname-modal/.nickname-box) */ |
| 77 | 81 | .credit-pill { font: bold 12px Tahoma, sans-serif; } | ... | ... |
dance.html
| ... | ... | @@ -220,7 +220,7 @@ function renderAuthButton(user) { |
| 220 | 220 | authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 💰${user.creditBalance} · 退出` : "登录"; |
| 221 | 221 | authBtn.dataset.loggedIn = user ? "1" : ""; |
| 222 | 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 | 226 | async function refreshAuthState() { |
| ... | ... | @@ -377,8 +377,8 @@ function renderWardrobe() { |
| 377 | 377 | renderGarmentUploadPanel(); |
| 378 | 378 | return; |
| 379 | 379 | } |
| 380 | - if (leftTab !== "wardrobe") { | |
| 381 | - wardrobePanel.innerHTML = `<div class="coming-soon">传人功能开发中,敬请期待</div>`; | |
| 380 | + if (leftTab === "character") { | |
| 381 | + renderCharacterUploadPanel(); | |
| 382 | 382 | return; |
| 383 | 383 | } |
| 384 | 384 | if (wardrobe.length === 0) { |
| ... | ... | @@ -457,9 +457,16 @@ async function submitGarmentUpload() { |
| 457 | 457 | } |
| 458 | 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 | 470 | pollState(); // surface the new queue entry immediately, same as pickGarment |
| 464 | 471 | } catch { |
| 465 | 472 | statusBox.hidden = true; |
| ... | ... | @@ -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 | 570 | document.querySelectorAll("#wardrobeTabs button").forEach((b) => |
| 474 | 571 | b.addEventListener("click", () => { |
| 475 | 572 | leftTab = b.dataset.tab; | ... | ... |
pricing.json
| ... | ... | @@ -25,5 +25,14 @@ |
| 25 | 25 | "segmentsPerUnit": null, |
| 26 | 26 | "isActive": true, |
| 27 | 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 | 39 | \ No newline at end of file | ... | ... |