feat: SSR 内容门控与积分钱包/签到抽奖

闭合积分解锁经济闭环:锁定壳与 unlock,以及 profile 钱包流水与每日签到/抽奖 PRG。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 06:20:12 +08:00
parent 40ec20b3df
commit 204e7fdb32
20 changed files with 818 additions and 107 deletions

View File

@@ -352,6 +352,67 @@ body.j13-body {
padding: 0.85rem 0;
border-bottom: 1px solid var(--j13-border);
}
.j13-profile__wallet {
margin: 1.25rem 0;
padding-bottom: 1rem;
border-bottom: 1px solid var(--j13-border);
}
.j13-profile__balance { margin: 0 0 0.85rem; }
.j13-profile__balance strong { font-size: 1.35rem; color: var(--j13-accent); }
.j13-profile__daily {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
margin-bottom: 1rem;
}
@media (max-width: 640px) {
.j13-profile__daily { grid-template-columns: 1fr; }
}
.j13-profile__daily-card {
padding: 0.75rem 0.85rem;
background: var(--j13-bg);
border: 1px solid var(--j13-border);
border-radius: var(--j13-radius);
}
.j13-profile__daily-card h3 {
margin: 0 0 0.4rem;
font-size: 0.95rem;
}
.j13-profile__daily-card p { margin: 0 0 0.65rem; font-size: 0.85rem; }
.j13-profile__ledger-title {
margin: 0.5rem 0 0.5rem;
font-size: 0.95rem;
}
.j13-profile__ledger {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.j13-profile__ledger th,
.j13-profile__ledger td {
text-align: left;
padding: 0.4rem 0.35rem;
border-bottom: 1px solid var(--j13-border);
vertical-align: top;
}
.j13-profile__ledger th { color: var(--j13-muted); font-weight: 600; }
.j13-delta--pos { color: #15803d; }
.j13-delta--neg { color: #b91c1c; }
.j13-btn {
display: inline-block;
padding: 0.35rem 0.85rem;
border: 1px solid var(--j13-accent);
border-radius: var(--j13-radius);
background: var(--j13-accent);
color: #fff;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.j13-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.j13-avatar {
width: 64px;
height: 64px;
@@ -431,6 +492,52 @@ body.j13-body {
.j13-msg-bubble__body { white-space: pre-wrap; overflow-wrap: anywhere; }
.j13-msg-compose { margin-top: 1.25rem; }
/* 内容门控锁定壳 */
members-only,
reply-only,
points-only {
display: block;
margin: 1rem 0;
}
.j13-gate {
padding: 1rem 1.1rem;
border: 1px dashed var(--j13-border);
border-radius: var(--j13-radius);
background: var(--j13-surface);
}
.j13-gate__title {
margin: 0 0 0.35rem;
font-weight: 600;
font-size: 0.95rem;
}
.j13-gate__meta,
.j13-gate__hint {
margin: 0.2rem 0;
color: var(--j13-muted);
font-size: 0.85rem;
}
.j13-gate a { color: var(--j13-accent); }
.j13-gate__unlock {
margin-top: 0.35rem;
padding: 0.4rem 0.85rem;
border: 0;
border-radius: var(--j13-radius);
background: var(--j13-accent);
color: #fff;
cursor: pointer;
font: inherit;
}
.j13-gate__unlock:disabled {
opacity: 0.65;
cursor: wait;
}
points-only[data-locked="false"] {
border: 0;
padding: 0;
margin: 0.75rem 0;
}

View File

@@ -1,46 +1,178 @@
// 姜十三论坛 SSR 渐进增强
document.documentElement.dataset.j13Ssr = "1";
(function () {
(function composeUploadAndGates() {
const form = document.getElementById("compose-form");
const fileInput = document.getElementById("compose-image");
const textarea = document.getElementById("compose-content");
const statusEl = document.getElementById("compose-upload-status");
if (!form || !fileInput || !textarea) return;
if (!form || !textarea) return;
const csrf = form.getAttribute("data-csrf") || "";
const uploadURL = form.getAttribute("data-upload") || "/compose/upload";
const fileInput = document.getElementById("compose-image");
const statusEl = document.getElementById("compose-upload-status");
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.value = "";
if (!file) return;
if (statusEl) statusEl.textContent = "上传中…";
const fd = new FormData();
fd.append("image", file);
fd.append("_csrf", csrf);
try {
const res = await fetch(uploadURL, {
method: "POST",
headers: { "X-CSRF-Token": csrf },
body: fd,
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || "上传失败");
function insertAtCursor(text) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
textarea.value =
textarea.value.slice(0, start) + text + textarea.value.slice(end);
const pos = start + text.length;
textarea.focus();
textarea.setSelectionRange(pos, pos);
}
function wrapSelection(openTag, closeTag, fallbackInner) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || start;
let selected = textarea.value.slice(start, end);
if (!selected.trim()) {
selected = fallbackInner || "在此填写隐藏内容";
}
// 若选区尚非段落,包一层 p 便于消毒后结构稳定
let inner = selected;
if (!/^\s*</.test(inner)) {
inner = "<p>" + selected.replace(/\n/g, "<br>\n") + "</p>";
}
const block = openTag + inner + closeTag;
textarea.value =
textarea.value.slice(0, start) + block + textarea.value.slice(end);
textarea.focus();
const pos = start + block.length;
textarea.setSelectionRange(pos, pos);
if (statusEl) statusEl.textContent = "已插入门控块";
}
if (fileInput) {
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.value = "";
if (!file) return;
if (statusEl) statusEl.textContent = "上传中…";
const fd = new FormData();
fd.append("image", file);
fd.append("_csrf", csrf);
try {
const res = await fetch(uploadURL, {
method: "POST",
headers: { "X-CSRF-Token": csrf },
body: fd,
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "上传失败");
const url = data.url;
if (!url) throw new Error("未返回图片地址");
insertAtCursor(`\n\n![](${url})\n\n`);
if (statusEl) statusEl.textContent = "已插入图片";
} catch (e) {
if (statusEl) statusEl.textContent = e.message || "上传失败";
}
const url = data.url;
if (!url) throw new Error("未返回图片地址");
const md = `\n\n![](${url})\n\n`;
const start = textarea.selectionStart || textarea.value.length;
const end = textarea.selectionEnd || start;
textarea.value =
textarea.value.slice(0, start) + md + textarea.value.slice(end);
textarea.focus();
if (statusEl) statusEl.textContent = "已插入图片";
} catch (e) {
if (statusEl) statusEl.textContent = e.message || "上传失败";
});
}
document.getElementById("compose-gate-login")?.addEventListener("click", () => {
wrapSelection(
'<members-only data-gate="login">',
"</members-only>"
);
});
document.getElementById("compose-gate-reply")?.addEventListener("click", () => {
wrapSelection(
'<reply-only data-gate="reply">',
"</reply-only>"
);
});
document.getElementById("compose-gate-points")?.addEventListener("click", () => {
let cost = window.prompt("积分价格19999", "10");
if (cost == null) return;
cost = String(cost).trim();
const n = parseInt(cost, 10);
if (!n || n < 1 || n > 9999) {
if (statusEl) statusEl.textContent = "价格须为 19999 的整数";
return;
}
wrapSelection(
`<points-only data-gate="points" data-cost="${n}">`,
"</points-only>"
);
});
})();
(function postContentGates() {
const article = document.querySelector(".j13-post[data-post-id]");
if (!article) return;
const postId = article.getAttribute("data-post-id");
const csrf = article.getAttribute("data-csrf") || "";
const loggedIn = article.getAttribute("data-logged-in") === "1";
const path = window.location.pathname + window.location.hash;
article.querySelectorAll('[data-locked="true"]').forEach((el) => {
const gate = el.getAttribute("data-gate") || "";
const shell = el.querySelector("[data-gate-shell]");
if (!shell) return;
if (gate === "login" && !loggedIn) {
const a = document.createElement("a");
a.href = "/login?redirect=" + encodeURIComponent(path || "/");
a.textContent = "登录查看";
const p = document.createElement("p");
p.appendChild(a);
shell.appendChild(p);
}
if (gate === "reply" && loggedIn) {
const a = document.createElement("a");
a.href = "#comments";
a.textContent = "去评论解锁";
const p = document.createElement("p");
p.appendChild(a);
shell.appendChild(p);
}
if (gate === "points") {
const key = el.getAttribute("data-block-key") || "";
const cost = el.getAttribute("data-cost") || "";
if (!loggedIn) {
const a = document.createElement("a");
a.href = "/login?redirect=" + encodeURIComponent(path || "/");
a.textContent = "登录后解锁";
const p = document.createElement("p");
p.appendChild(a);
shell.appendChild(p);
return;
}
if (!key) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "j13-gate__unlock";
btn.textContent = "花费 " + cost + " 积分解锁";
btn.addEventListener("click", async () => {
btn.disabled = true;
btn.textContent = "解锁中…";
try {
const res = await fetch("/post/" + postId + "/unlock", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
},
credentials: "same-origin",
body: JSON.stringify({ block_key: key }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "解锁失败");
const inner = data.unlock && data.unlock.inner_html;
if (typeof inner !== "string") throw new Error("未返回内容");
el.setAttribute("data-locked", "false");
el.innerHTML = inner;
} catch (e) {
btn.disabled = false;
btn.textContent = "花费 " + cost + " 积分解锁";
alert(e.message || "解锁失败");
}
});
const p = document.createElement("p");
p.appendChild(btn);
shell.appendChild(p);
}
});
})();