diff --git a/dance.css b/dance.css
index d96a548..c0c7abc 100644
--- a/dance.css
+++ b/dance.css
@@ -72,6 +72,10 @@
.upload-panel button:disabled { opacity: .5; cursor: default; }
.upload-panel .upload-error { color: #c0392b; font: bold 12px Tahoma, sans-serif; margin-top: 8px; }
.upload-panel .upload-status { font: bold 12px Tahoma, sans-serif; margin-top: 8px; }
+/* T37: paid_boost 插队播放跟模式一/二的"生成中"要有视觉区别 */
+.upload-panel .upload-status.boosted { color: #b8860b; background: #fff7d6; border: 2px solid #000; border-radius: 8px; padding: 6px 8px; }
+.upload-panel select { width: 100%; font-size: 12px; margin-bottom: 10px; padding: 4px; }
+.upload-panel .field-label { font: bold 12px Tahoma, sans-serif; margin-bottom: 4px; display: block; }
/* T29: balance pill + topup tier picker (reuses .nickname-modal/.nickname-box) */
.credit-pill { font: bold 12px Tahoma, sans-serif; }
diff --git a/dance.html b/dance.html
index b8bbd03..f716d34 100644
--- a/dance.html
+++ b/dance.html
@@ -220,7 +220,7 @@ function renderAuthButton(user) {
authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 💰${user.creditBalance} · 退出` : "登录";
authBtn.dataset.loggedIn = user ? "1" : "";
topupBtn.hidden = !user;
- if (leftTab === "garment") renderWardrobe(); // price/balance-gated panel depends on login state
+ if (leftTab === "garment" || leftTab === "character") renderWardrobe(); // price/balance-gated panels depend on login state
}
async function refreshAuthState() {
@@ -377,8 +377,8 @@ function renderWardrobe() {
renderGarmentUploadPanel();
return;
}
- if (leftTab !== "wardrobe") {
- wardrobePanel.innerHTML = `
传人功能开发中,敬请期待
`;
+ if (leftTab === "character") {
+ renderCharacterUploadPanel();
return;
}
if (wardrobe.length === 0) {
@@ -457,9 +457,16 @@ async function submitGarmentUpload() {
}
return;
}
- statusBox.textContent = data.status === "ready" ? "生成完成,已加入播放队列!" : "生成中,完成后会自动加入播放队列";
- fileInput.value = "";
- await refreshAuthState(); // balance just changed
+ const message = data.status === "ready" ? "生成完成,已加入播放队列!" : "生成中,完成后会自动加入播放队列";
+ // refreshAuthState() re-renders this whole panel (creditBalance changed
+ // -> renderAuthButton -> renderWardrobe) — setting the message before
+ // that would just get wiped by the fresh render, so set it after and
+ // re-query, not on the (now-replaced) statusBox/fileInput above.
+ await refreshAuthState();
+ document.getElementById("garmentFileInput").value = "";
+ const freshStatusBox = document.getElementById("garmentUploadStatus");
+ freshStatusBox.hidden = false;
+ freshStatusBox.textContent = message;
pollState(); // surface the new queue entry immediately, same as pickGarment
} catch {
statusBox.hidden = true;
@@ -470,6 +477,96 @@ async function submitGarmentUpload() {
}
}
+// ---- T37: 模式三"传人"(付费,需登录+邮箱验证) ----
+function renderCharacterUploadPanel() {
+ const spendCharacter = pricing.find((p) => p.key === "spend_character_per_segment");
+ const costPerSegment = spendCharacter ? Math.ceil(spendCharacter.credits / (spendCharacter.segmentsPerUnit || 1)) : null;
+ const priceLine = costPerSegment != null ? `上传一张人物照片,每购买 1 个 10 秒片段扣 ${costPerSegment} 积分——买得越多,这个新角色在直播间露出的时长越长` : "加载价格中…";
+ const garmentOptions = wardrobe.map((g) => ``).join("");
+ wardrobePanel.innerHTML = `
+
+
${priceLine}
+
+
+
+
+
+
+
+
+
`;
+ document.getElementById("characterUploadBtn").addEventListener("click", submitCharacterUpload);
+}
+
+async function submitCharacterUpload() {
+ const fileInput = document.getElementById("characterFileInput");
+ const garmentSelect = document.getElementById("characterGarmentSelect");
+ const segmentsInput = document.getElementById("characterSegmentsInput");
+ const errorBox = document.getElementById("characterUploadError");
+ const statusBox = document.getElementById("characterUploadStatus");
+ const btn = document.getElementById("characterUploadBtn");
+ errorBox.hidden = true;
+ statusBox.hidden = true;
+ statusBox.classList.remove("boosted");
+
+ // 未登录/未验证先引导,而不是提交后静默失败(同 T28 验收标准)
+ if (!currentUser) {
+ openAuthModal("login");
+ return;
+ }
+ if (!currentUser.emailVerified) {
+ errorBox.hidden = false;
+ errorBox.textContent = "请先完成邮箱验证后再上传(查看邮箱中的验证链接)";
+ return;
+ }
+ const file = fileInput.files[0];
+ if (!file) {
+ errorBox.hidden = false;
+ errorBox.textContent = "请先选择一张人物照片";
+ return;
+ }
+
+ btn.disabled = true;
+ statusBox.hidden = false;
+ statusBox.textContent = "上传中…";
+ try {
+ const fd = new FormData();
+ fd.append("file", file);
+ if (garmentSelect.value) fd.append("garmentId", garmentSelect.value);
+ fd.append("segments", segmentsInput.value || "3");
+ const res = await fetch("/api/dance/upload-character", { method: "POST", body: fd });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ statusBox.hidden = true;
+ errorBox.hidden = false;
+ errorBox.textContent = res.status === 402 ? "积分不足,请先充值" : data.error || "上传失败";
+ return;
+ }
+ // 付费插队跟模式一/二的普通"排队"视觉上要有区别(T37 验收标准)——
+ // 不管此刻是"生成中"还是缓存命中的"ready",都是走 priorityInbox
+ // 插队通道,不进投票队列. Message set after refreshAuthState(), same
+ // reasoning as submitGarmentUpload — it re-renders this panel
+ // (creditBalance changed), which would wipe anything set beforehand.
+ const message = data.status === "ready" ? "🎉 生成完成,已插队播放!" : "🎉 生成中,完成后将直接插队播放(不用排队投票)";
+ await refreshAuthState();
+ document.getElementById("characterFileInput").value = "";
+ const freshStatusBox = document.getElementById("characterUploadStatus");
+ freshStatusBox.hidden = false;
+ freshStatusBox.classList.add("boosted");
+ freshStatusBox.textContent = message;
+ pollState(); // surface the new priority entry immediately
+ } catch {
+ statusBox.hidden = true;
+ errorBox.hidden = false;
+ errorBox.textContent = "网络错误,请重试";
+ } finally {
+ btn.disabled = false;
+ }
+}
+
document.querySelectorAll("#wardrobeTabs button").forEach((b) =>
b.addEventListener("click", () => {
leftTab = b.dataset.tab;
diff --git a/pricing.json b/pricing.json
index 66e4bc7..4cb6cee 100644
--- a/pricing.json
+++ b/pricing.json
@@ -25,5 +25,14 @@
"segmentsPerUnit": null,
"isActive": true,
"updatedAt": 1788356862386
+ },
+ {
+ "key": "spend_character_per_segment",
+ "isActive": true,
+ "kind": "spend",
+ "credits": 20,
+ "priceCents": null,
+ "segmentsPerUnit": 1,
+ "updatedAt": 1788359892483
}
]
\ No newline at end of file