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

@@ -20,7 +20,7 @@
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail` - [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
- [ ] 搜索:关键词、标签、作者、仅标题(`title_only` - [ ] 搜索:关键词、标签、作者、仅标题(`title_only`
- [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序) - [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)
- [ ] 登录用户右栏/侧边:签到与抽奖入口 - [ ] 登录用户右栏/侧边:签到与抽奖入口(入口暂在 `/profile` 钱包区)
- [ ] 下拉刷新(移动端) - [ ] 下拉刷新(移动端)
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配) - [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)
- [x] 404 页 - [x] 404 页
@@ -35,7 +35,7 @@
- [ ] 忘记密码:邮箱验证码 + 重置 - [ ] 忘记密码:邮箱验证码 + 重置
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP - [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP
- [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员 - [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员
- [x] 个人中心:改昵称、签名、密码、上传头像 — SSR `/profile`(裁剪未做) - [x] 个人中心:改昵称、签名、密码、上传头像、积分钱包/签到/抽奖 — SSR `/profile`(裁剪未做)
- [x] 个人活动统计:帖数、评数、收藏数、获赞 — `/profile` + `/user/:id` - [x] 个人活动统计:帖数、评数、收藏数、获赞 — `/profile` + `/user/:id`
- [x] 公开用户主页 `/user/:id`(无邮箱) - [x] 公开用户主页 `/user/:id`(无邮箱)
- [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控) - [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控)
@@ -87,13 +87,13 @@
## E. 内容门控 ## E. 内容门控
- [ ] 编辑器可插入「登录可见」块 - [x] 编辑器可插入「登录可见」块 — compose 工具栏
- [ ] 编辑器可插入「回复可见」块 - [x] 编辑器可插入「回复可见」块 — compose 工具栏
- [ ] 编辑器可插入「积分可见」块(可设价格) - [x] 编辑器可插入「积分可见」块(可设价格)— compose 工具栏 + prompt
- [ ] 未登录:遮盖 members-only 与 reply-only 正文,保留长度提示 - [x] 未登录:遮盖 members-only 与 reply-only 正文,保留长度提示 — 锁定壳 UI
- [ ] 已登录未回复:遮盖 reply-only作者与管理员始终可见 - [x] 已登录未回复:遮盖 reply-only作者与管理员始终可见
- [ ] 积分块:未解锁遮盖;`POST .../unlock` 扣积分并返回 inner HTML - [x] 积分块:未解锁遮盖;`POST /post/:id/unlock` 扣积分并返回 inner HTML
- [ ] 搜索 / SEO 出口对门控内容做红action不泄露正文 - [x] 搜索 / SEO 出口对门控内容做红action不泄露正文(既有)
--- ---
@@ -125,10 +125,10 @@
## H. 积分、签到、抽奖、徽章、等级 ## H. 积分、签到、抽奖、徽章、等级
- [ ] 积分流水查询 - [x] 积分流水查询 — SSR `/profile` 钱包近 N 条
- [ ] 每日签到:基础 5连签每日 +1封顶 15 - [x] 每日签到:基础 5连签每日 +1封顶 15`POST /profile/checkin`
- [ ] 每日抽奖奖池加权0/2/5/10/20成本 0 - [x] 每日抽奖奖池加权0/2/5/10/20成本 0`POST /profile/lottery`
- [ ] 积分解锁分成:读者付全额,作者约 70% - [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务
- [ ] 短龄同 IP 互刷拒绝分成 - [ ] 短龄同 IP 互刷拒绝分成
- [ ] Exp → 等级 Lv110 - [ ] Exp → 等级 Lv110
- [ ] 自动徽章(注册天数 / 获赞 / 创作分成) - [ ] 自动徽章(注册天数 / 获赞 / 创作分成)

View File

@@ -27,7 +27,7 @@
| `/` | Feed | 已迁 | | `/` | Feed | 已迁 |
| `/board/:id` | 板块 Feed | 已迁 | | `/board/:id` | 板块 Feed | 已迁 |
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 | | `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
| `/compose` | 发帖normalMarkdown textarea + 图片上传 | 已迁 | | `/compose` | 发帖normaltextarea + 图片 + 门控插入 | 已迁 |
| `/post/:id/edit` | 编辑帖 | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 |
| `/profile` | 个人中心(资料/密码/头像;无钱包/裁剪) | 已迁 | | `/profile` | 个人中心(资料/密码/头像;无钱包/裁剪) | 已迁 |
| `/user/:id` | 公开用户页 | 已迁 | | `/user/:id` | 公开用户页 | 已迁 |
@@ -137,7 +137,7 @@
| 模块 | 行为 | | 模块 | 行为 |
|------|------| |------|------|
| 门控块 | 锁定态显示长度/价格;解锁按钮调 API 后替换 inner HTML | | 门控块 | 锁定壳 UI长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
| 投票卡 | 选选项提交;显示百分比;作者可结束 | | 投票卡 | 选选项提交;显示百分比;作者可结束 |
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
| 抽奖卡 | 显示参与人数;开奖;中奖名单 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 |
@@ -161,7 +161,8 @@
- 资料编辑、密码、头像直传(**本迭代无裁剪器**)— SSR `/profile` - 资料编辑、密码、头像直传(**本迭代无裁剪器**)— SSR `/profile`
- 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱) - 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱)
- 收藏列表 — SSR `/favorites` - 收藏列表 — SSR `/favorites`
- 积分钱包面板(流水、签到)、徽章展示:**未迁** - 积分钱包面板(余额、近 N 条流水、签到/抽奖表单 PRG— SSR `/profile`;导航展示当前积分
- 徽章展示:**未迁**
--- ---

View File

@@ -81,9 +81,10 @@ web_src/ → public/assets/
### 已迁路径(摘要) ### 已迁路径(摘要)
公开写:`/install``/login``/logout``/register``/compose``/post/:id/edit`、帖详情评论/赞/藏。 公开写:`/install``/login``/logout``/register``/compose`(含门控插入)`/post/:id/edit`、帖详情评论/赞/藏、积分解锁 `POST /post/:id/unlock`
个人闭环:`/profile`(昵称/签名/密码/头像)、`/user/:id``/favorites` 个人闭环:`/profile`(昵称/签名/密码/头像、积分钱包/签到/抽奖)、`/user/:id``/favorites`
`POST /profile/checkin``POST /profile/lottery`CSRF + PRG
私信:`/messages``/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。 私信:`/messages``/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。

View File

@@ -352,6 +352,67 @@ body.j13-body {
padding: 0.85rem 0; padding: 0.85rem 0;
border-bottom: 1px solid var(--j13-border); 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 { .j13-avatar {
width: 64px; width: 64px;
height: 64px; height: 64px;
@@ -431,6 +492,52 @@ body.j13-body {
.j13-msg-bubble__body { white-space: pre-wrap; overflow-wrap: anywhere; } .j13-msg-bubble__body { white-space: pre-wrap; overflow-wrap: anywhere; }
.j13-msg-compose { margin-top: 1.25rem; } .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 渐进增强 // 姜十三论坛 SSR 渐进增强
document.documentElement.dataset.j13Ssr = "1"; document.documentElement.dataset.j13Ssr = "1";
(function () { (function composeUploadAndGates() {
const form = document.getElementById("compose-form"); const form = document.getElementById("compose-form");
const fileInput = document.getElementById("compose-image");
const textarea = document.getElementById("compose-content"); const textarea = document.getElementById("compose-content");
const statusEl = document.getElementById("compose-upload-status"); if (!form || !textarea) return;
if (!form || !fileInput || !textarea) return;
const csrf = form.getAttribute("data-csrf") || ""; const csrf = form.getAttribute("data-csrf") || "";
const uploadURL = form.getAttribute("data-upload") || "/compose/upload"; 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 () => { function insertAtCursor(text) {
const file = fileInput.files && fileInput.files[0]; const start = textarea.selectionStart || 0;
fileInput.value = ""; const end = textarea.selectionEnd || start;
if (!file) return; textarea.value =
if (statusEl) statusEl.textContent = "上传中…"; textarea.value.slice(0, start) + text + textarea.value.slice(end);
const fd = new FormData(); const pos = start + text.length;
fd.append("image", file); textarea.focus();
fd.append("_csrf", csrf); textarea.setSelectionRange(pos, pos);
try { }
const res = await fetch(uploadURL, {
method: "POST", function wrapSelection(openTag, closeTag, fallbackInner) {
headers: { "X-CSRF-Token": csrf }, const start = textarea.selectionStart || 0;
body: fd, const end = textarea.selectionEnd || start;
credentials: "same-origin", let selected = textarea.value.slice(start, end);
}); if (!selected.trim()) {
const data = await res.json().catch(() => ({})); selected = fallbackInner || "在此填写隐藏内容";
if (!res.ok) { }
throw new Error(data.error || "上传失败"); // 若选区尚非段落,包一层 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; document.getElementById("compose-gate-login")?.addEventListener("click", () => {
const end = textarea.selectionEnd || start; wrapSelection(
textarea.value = '<members-only data-gate="login">',
textarea.value.slice(0, start) + md + textarea.value.slice(end); "</members-only>"
textarea.focus(); );
if (statusEl) statusEl.textContent = "已插入图片"; });
} catch (e) { document.getElementById("compose-gate-reply")?.addEventListener("click", () => {
if (statusEl) statusEl.textContent = e.message || "上传失败"; 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);
} }
}); });
})(); })();

View File

@@ -100,6 +100,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Board: boardSvc, Post: postSvc, Comment: commentSvc, Board: boardSvc, Post: postSvc, Comment: commentSvc,
Message: messageSvc, Filter: filter, Message: messageSvc, Filter: filter,
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore, Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
Points: services.NewPointsService(),
}, authMW) }, authMW)
r.GET("/media/thumb/*filepath", h.ServeImageThumb) r.GET("/media/thumb/*filepath", h.ServeImageThumb)

View File

@@ -23,6 +23,7 @@ type Deps struct {
Limiter *services.RateLimiter Limiter *services.RateLimiter
EmailCode *services.EmailCodeService EmailCode *services.EmailCodeService
Store *services.UploadStore Store *services.UploadStore
Points *services.PointsService
} }
// BoardView 侧栏 // BoardView 侧栏
@@ -47,7 +48,8 @@ type PageChrome struct {
CSRF string CSRF string
Flash string Flash string
Error string Error string
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0 UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
ViewerPoints int // 登录用户当前积分;未登录为 0
} }
func (d Deps) ctx(c *gin.Context) *webctx.Context { func (d Deps) ctx(c *gin.Context) *webctx.Context {
@@ -78,20 +80,25 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
if ctx.IsSigned() && d.Message != nil { if ctx.IsSigned() && d.Message != nil {
unread, _ = d.Message.UnreadCount(ctx.UserID()) unread, _ = d.Message.UnreadCount(ctx.UserID())
} }
viewerPoints := 0
if ctx.IsSigned() && ctx.Doer != nil {
viewerPoints = ctx.Doer.Points
}
return PageChrome{ return PageChrome{
Title: title, Title: title,
Description: desc, Description: desc,
SiteName: brand.Name, SiteName: brand.Name,
Slogan: brand.Slogan, Slogan: brand.Slogan,
LogoMark: firstRuneOr(brand.LogoMark, "姜"), LogoMark: firstRuneOr(brand.LogoMark, "姜"),
LoggedIn: ctx.IsSigned(), LoggedIn: ctx.IsSigned(),
IsAdmin: ctx.IsAdmin(), IsAdmin: ctx.IsAdmin(),
ViewerName: name, ViewerName: name,
Boards: bv, Boards: bv,
Inner: inner, Inner: inner,
CSRF: ctx.EnsureCSRF(), CSRF: ctx.EnsureCSRF(),
Flash: ctx.TakeFlash(), Flash: ctx.TakeFlash(),
UnreadCount: unread, UnreadCount: unread,
ViewerPoints: viewerPoints,
} }
} }

View File

@@ -22,6 +22,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment) g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment)
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike) g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite) g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock)
g.GET("/login", deps.LoginGet) g.GET("/login", deps.LoginGet)
g.POST("/login", deps.LoginPost) g.POST("/login", deps.LoginPost)
g.POST("/logout", deps.LogoutPost) g.POST("/logout", deps.LogoutPost)
@@ -58,6 +59,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g.POST("/profile/signature", authMW.RequireAuth(), deps.ProfileSignaturePost) g.POST("/profile/signature", authMW.RequireAuth(), deps.ProfileSignaturePost)
g.POST("/profile/password", authMW.RequireAuth(), deps.ProfilePasswordPost) g.POST("/profile/password", authMW.RequireAuth(), deps.ProfilePasswordPost)
g.POST("/profile/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost) g.POST("/profile/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost)
g.POST("/profile/checkin", authMW.RequireAuth(), deps.ProfileCheckInPost)
g.POST("/profile/lottery", authMW.RequireAuth(), deps.ProfileLotteryPost)
g.GET("/favorites", authMW.RequireAuth(), deps.FavoritesGet) g.GET("/favorites", authMW.RequireAuth(), deps.FavoritesGet)
g.GET("/messages", authMW.RequireAuth(), deps.MessagesList) g.GET("/messages", authMW.RequireAuth(), deps.MessagesList)
g.GET("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesThread) g.GET("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesThread)

View File

@@ -156,6 +156,40 @@ func (d Deps) PostComment(c *gin.Context) {
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
} }
// PostUnlock POST /post/:id/unlock — 积分解锁JSON供详情页渐进增强
func (d Deps) PostUnlock(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求,请重试"})
return
}
id, err := parsePostID(c, d)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
var req struct {
BlockKey string `json:"block_key" form:"block_key"`
}
_ = c.ShouldBindJSON(&req)
if req.BlockKey == "" {
req.BlockKey = strings.TrimSpace(c.PostForm("block_key"))
}
if req.BlockKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"})
return
}
res, err := services.UnlockPointsBlock(ctx.UserID(), id, req.BlockKey)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "解锁成功",
"unlock": res,
})
}
// PostLike 赞 // PostLike 赞
func (d Deps) PostLike(c *gin.Context) { func (d Deps) PostLike(c *gin.Context) {
ctx := d.ctx(c) ctx := d.ctx(c)

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -11,6 +12,16 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const profileLedgerLimit = 15
type profileLedgerItem struct {
Delta int
Balance int
ReasonLabel string
Note string
CreatedAt string
}
type profileData struct { type profileData struct {
PageChrome PageChrome
UserID uint UserID uint
@@ -29,6 +40,35 @@ type profileData struct {
PublicURL string PublicURL string
AvatarMaxMB int AvatarMaxMB int
SignatureMax int SignatureMax int
CheckIn services.CheckInStatus
Lottery services.LotteryStatus
Ledger []profileLedgerItem
}
func pointReasonLabel(reason string) string {
switch reason {
case models.PointReasonCheckIn:
return "签到"
case models.PointReasonLottery:
return "每日抽奖"
case models.PointReasonUnlockSpend:
return "解锁内容"
case models.PointReasonCreatorIncome:
return "创作分成"
case models.PointReasonAdminAdjust:
return "站长调整"
case models.PointReasonBountyEscrow:
return "悬赏托管"
case models.PointReasonBountyAward:
return "悬赏奖励"
case models.PointReasonBountyRefund:
return "悬赏退款"
default:
if reason == "" {
return "其他"
}
return reason
}
} }
// ProfileGet 个人中心 // ProfileGet 个人中心
@@ -48,6 +88,7 @@ func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) {
st, _ := d.User.ActivityStats(uid) st, _ := d.User.ActivityStats(uid)
chrome := d.chrome(ctx, "个人中心 · "+d.Settings.SiteBranding().Name, "", "") chrome := d.chrome(ctx, "个人中心 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg chrome.Error = errMsg
chrome.ViewerPoints = user.Points
nick := strings.TrimSpace(user.Nickname) nick := strings.TrimSpace(user.Nickname)
if nick == "" { if nick == "" {
nick = user.Username nick = user.Username
@@ -71,6 +112,21 @@ func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) {
AvatarMaxMB: d.Settings.AvatarMaxMB(), AvatarMaxMB: d.Settings.AvatarMaxMB(),
SignatureMax: d.Settings.SignatureMax(), SignatureMax: d.Settings.SignatureMax(),
} }
if d.Points != nil {
data.CheckIn, _ = d.Points.GetCheckInStatus(uid)
data.Lottery, _ = d.Points.GetLotteryStatus(uid)
rows, _, _ := d.Points.ListLedger(uid, 1, profileLedgerLimit)
data.Ledger = make([]profileLedgerItem, 0, len(rows))
for _, row := range rows {
data.Ledger = append(data.Ledger, profileLedgerItem{
Delta: row.Delta,
Balance: row.Balance,
ReasonLabel: pointReasonLabel(row.Reason),
Note: row.Note,
CreatedAt: row.CreatedAt.Format("2006-01-02 15:04"),
})
}
}
// 导航显示最新昵称 // 导航显示最新昵称
data.ViewerName = nick data.ViewerName = nick
ctx.HTML(http.StatusOK, "profile/view", data) ctx.HTML(http.StatusOK, "profile/view", data)
@@ -166,3 +222,57 @@ func (d Deps) ProfileAvatarPost(c *gin.Context) {
ctx.SetFlash("头像已更新") ctx.SetFlash("头像已更新")
ctx.Redirect("/profile") ctx.Redirect("/profile")
} }
// ProfileCheckInPost 每日签到PRG
func (d Deps) ProfileCheckInPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
if d.Points == nil {
d.renderProfile(ctx, "积分服务未就绪")
return
}
st, err := d.Points.CheckIn(ctx.UserID())
if err != nil {
if errors.Is(err, services.ErrAlreadyCheckedIn) {
ctx.SetFlash("今日已签到")
ctx.Redirect("/profile")
return
}
d.renderProfile(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("签到成功:连续 %d 天,获得 %d 积分", st.Streak, st.TodayPoints))
ctx.Redirect("/profile")
}
// ProfileLotteryPost 每日抽奖PRG
func (d Deps) ProfileLotteryPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
if d.Points == nil {
d.renderProfile(ctx, "积分服务未就绪")
return
}
st, err := d.Points.DrawLottery(ctx.UserID())
if err != nil {
if errors.Is(err, services.ErrAlreadyLottery) {
ctx.SetFlash("今日已抽奖")
ctx.Redirect("/profile")
return
}
d.renderProfile(ctx, err.Error())
return
}
if st.Points > 0 {
ctx.SetFlash(fmt.Sprintf("抽奖结果:获得 %d 积分", st.Points))
} else {
ctx.SetFlash("抽奖结果:未中奖,明天再来")
}
ctx.Redirect("/profile")
}

View File

@@ -12,7 +12,7 @@ var (
) )
// ComposeBodyToHTML 将发帖表单正文转为可消毒 HTML。 // ComposeBodyToHTML 将发帖表单正文转为可消毒 HTML。
// 若已含块级 HTML 标签则原样交 Sanitize否则按纯文本/轻量 Markdown 转段落。 // 若已含块级 HTML / 门控标签则原样交 Sanitize否则按纯文本/轻量 Markdown 转段落。
func ComposeBodyToHTML(raw string) string { func ComposeBodyToHTML(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -22,7 +22,9 @@ func ComposeBodyToHTML(raw string) string {
if strings.Contains(lower, "<p") || strings.Contains(lower, "<div") || if strings.Contains(lower, "<p") || strings.Contains(lower, "<div") ||
strings.Contains(lower, "<h1") || strings.Contains(lower, "<ul") || strings.Contains(lower, "<h1") || strings.Contains(lower, "<ul") ||
strings.Contains(lower, "<ol") || strings.Contains(lower, "<pre") || strings.Contains(lower, "<ol") || strings.Contains(lower, "<pre") ||
strings.Contains(lower, "<blockquote") { strings.Contains(lower, "<blockquote") ||
strings.Contains(lower, "<members-only") || strings.Contains(lower, "<reply-only") ||
strings.Contains(lower, "<points-only") {
return raw return raw
} }
@@ -86,6 +88,12 @@ func HTMLToComposePlain(htmlBody string) string {
if s == "" { if s == "" {
return "" return ""
} }
lower := strings.ToLower(s)
// 含门控块时保留 HTML避免编辑丢失
if strings.Contains(lower, "<members-only") || strings.Contains(lower, "<reply-only") ||
strings.Contains(lower, "<points-only") {
return s
}
// img → markdown // img → markdown
imgRe := regexp.MustCompile(`(?i)<img[^>]+src="([^"]+)"[^>]*>`) imgRe := regexp.MustCompile(`(?i)<img[^>]+src="([^"]+)"[^>]*>`)
s = imgRe.ReplaceAllString(s, "![]($1)") s = imgRe.ReplaceAllString(s, "![]($1)")

View File

@@ -1,8 +1,8 @@
package services package services
import ( import (
"fmt"
"regexp" "regexp"
"strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
) )
@@ -54,10 +54,17 @@ func redactGatedBlocks(html string, re *regexp.Regexp, tag string) string {
} }
length := gatedContentLength(inner) length := gatedContentLength(inner)
gate := "login" gate := "login"
title := "登录后可见"
hint := "请登录后查看隐藏内容"
if tag == "reply-only" { if tag == "reply-only" {
gate = "reply" gate = "reply"
title = "回复后可见"
hint = "参与评论后即可查看"
} }
return `<` + tag + ` data-gate="` + gate + `" data-locked="true" data-length="` + strconv.Itoa(length) + `"></` + tag + `>` return fmt.Sprintf(
`<%s data-gate="%s" data-locked="true" data-length="%d"><div class="j13-gate" data-gate-shell="%s"><p class="j13-gate__title">%s</p><p class="j13-gate__meta">约 %d 字</p><p class="j13-gate__hint">%s</p></div></%s>`,
tag, gate, length, gate, title, length, hint, tag,
)
}) })
} }

View File

@@ -27,6 +27,7 @@ func postContentHTMLPolicy() *bluemonday.Policy {
p.AllowAttrs("colspan", "rowspan").OnElements("th", "td") p.AllowAttrs("colspan", "rowspan").OnElements("th", "td")
p.AllowAttrs( p.AllowAttrs(
"data-locked", "data-length", "data-gate", "data-cost", "data-block-key", "data-locked", "data-length", "data-gate", "data-cost", "data-block-key",
"data-gate-shell",
"data-code-copy", "data-code-fold", "data-lang", "data-full", "data-code-copy", "data-code-fold", "data-lang", "data-full",
"data-code-style", "data-line-numbers", "data-collapsed", "data-code-style", "data-line-numbers", "data-collapsed",
"data-line-count", "data-lineno-digits", "data-line-count", "data-lineno-digits",

View File

@@ -100,7 +100,10 @@ func RedactPointsOnlyHTML(html string, unlocked map[string]bool) string {
cost = 1 cost = 1
} }
length := gatedContentLength(inner) length := gatedContentLength(inner)
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="true" data-length="%d"></points-only>`, cost, key, length) return fmt.Sprintf(
`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="true" data-length="%d"><div class="j13-gate j13-gate--points" data-gate-shell="points"><p class="j13-gate__title">积分可见</p><p class="j13-gate__meta">需 %d 积分 · 约 %d 字</p><p class="j13-gate__hint">登录后可解锁</p></div></points-only>`,
cost, key, length, cost, length,
)
}) })
} }

View File

@@ -13,7 +13,7 @@
<a href="/compose">发帖</a> <a href="/compose">发帖</a>
<a href="/messages">私信{{if gt .UnreadCount 0}} <span class="j13-badge">{{.UnreadCount}}</span>{{end}}</a> <a href="/messages">私信{{if gt .UnreadCount 0}} <span class="j13-badge">{{.UnreadCount}}</span>{{end}}</a>
<a href="/favorites">收藏</a> <a href="/favorites">收藏</a>
<a href="/profile">{{.ViewerName}}</a> <a href="/profile">{{.ViewerName}} · {{.ViewerPoints}} 积分</a>
{{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}} {{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}}
<form class="j13-inline-form" method="post" action="/logout"> <form class="j13-inline-form" method="post" action="/logout">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/> <input type="hidden" name="_csrf" value="{{.CSRF}}"/>

View File

@@ -29,9 +29,12 @@
插入图片 插入图片
<input type="file" id="compose-image" accept="image/*" hidden/> <input type="file" id="compose-image" accept="image/*" hidden/>
</label> </label>
<button type="button" class="j13-btn-secondary" id="compose-gate-login" data-gate="login">登录可见</button>
<button type="button" class="j13-btn-secondary" id="compose-gate-reply" data-gate="reply">回复可见</button>
<button type="button" class="j13-btn-secondary" id="compose-gate-points" data-gate="points">积分可见</button>
<span class="j13-muted" id="compose-upload-status"></span> <span class="j13-muted" id="compose-upload-status"></span>
</div> </div>
<p class="j13-muted">本版仅发普通讨论帖;投票 / 悬赏 / 抽奖后续迭代。</p> <p class="j13-muted">门控选中正文后点按钮包裹积分可见会询问价格19999。含门控时正文以 HTML 提交。</p>
<button type="submit">{{if .IsEdit}}保存{{else}}发布{{end}}</button> <button type="submit">{{if .IsEdit}}保存{{else}}发布{{end}}</button>
</form> </form>
</main> </main>

View File

@@ -1,5 +1,5 @@
{{define "post/body"}} {{define "post/body"}}
<article class="j13-post"> <article class="j13-post" data-post-id="{{.PostID}}" data-csrf="{{.CSRF}}" data-logged-in="{{if .LoggedIn}}1{{else}}0{{end}}">
<header class="j13-post__header"> <header class="j13-post__header">
<h1 class="j13-post__title">{{.PostTitle}}</h1> <h1 class="j13-post__title">{{.PostTitle}}</h1>
<div class="j13-post__meta"> <div class="j13-post__meta">

View File

@@ -14,13 +14,67 @@
<li><strong>{{.CommentCount}}</strong><span>评论</span></li> <li><strong>{{.CommentCount}}</strong><span>评论</span></li>
<li><strong>{{.FavoriteCount}}</strong><span>收藏</span></li> <li><strong>{{.FavoriteCount}}</strong><span>收藏</span></li>
<li><strong>{{.LikeReceived}}</strong><span>获赞</span></li> <li><strong>{{.LikeReceived}}</strong><span>获赞</span></li>
<li><strong>{{.Points}}</strong><span>积分</span></li>
</ul> </ul>
<section class="j13-profile__wallet" id="wallet">
<h2>积分钱包</h2>
<p class="j13-profile__balance">当前余额 <strong>{{.Points}}</strong> 积分</p>
<div class="j13-profile__daily">
<div class="j13-profile__daily-card">
<h3>每日签到</h3>
{{if .CheckIn.CheckedIn}}
<p class="j13-muted">今日已签到 · 连续 {{.CheckIn.Streak}} 天 · 获得 {{.CheckIn.TodayPoints}} 积分</p>
<button type="button" class="j13-btn" disabled>已签到</button>
{{else}}
<p class="j13-muted">连续将至 {{.CheckIn.Streak}} 天 · 预计可得 {{.CheckIn.TodayPoints}} 积分(基础 5连签 +1封顶 15</p>
<form method="post" action="/profile/checkin" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit" class="j13-btn">签到</button>
</form>
{{end}}
</div>
<div class="j13-profile__daily-card">
<h3>每日抽奖</h3>
{{if .Lottery.Drawn}}
<p class="j13-muted">今日已抽 · 结果 {{.Lottery.Points}} 积分</p>
<button type="button" class="j13-btn" disabled>已抽奖</button>
{{else}}
<p class="j13-muted">每日一次 · 成本 {{.Lottery.Cost}} · 奖池 0/2/5/10/20</p>
<form method="post" action="/profile/lottery" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit" class="j13-btn">抽奖</button>
</form>
{{end}}
</div>
</div>
<h3 class="j13-profile__ledger-title">最近流水</h3>
{{if .Ledger}}
<table class="j13-profile__ledger">
<thead>
<tr><th>时间</th><th>事由</th><th>变动</th><th>余额</th></tr>
</thead>
<tbody>
{{range .Ledger}}
<tr>
<td>{{.CreatedAt}}</td>
<td>{{.ReasonLabel}}{{if .Note}} <span class="j13-muted">{{.Note}}</span>{{end}}</td>
<td class="{{if gt .Delta 0}}j13-delta--pos{{else if lt .Delta 0}}j13-delta--neg{{end}}">{{if gt .Delta 0}}+{{end}}{{.Delta}}</td>
<td>{{.Balance}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="j13-muted">暂无积分流水</p>
{{end}}
</section>
<section class="j13-profile__card"> <section class="j13-profile__card">
<h2>账号</h2> <h2>账号</h2>
<p>用户名 <strong>{{.Username}}</strong></p> <p>用户名 <strong>{{.Username}}</strong></p>
<p class="j13-muted">邮箱 {{.Email}}</p> <p class="j13-muted">邮箱 {{.Email}}</p>
<p class="j13-muted">等级 Lv.{{.Level}} · 经验 {{.Exp}} · 积分 {{.Points}}</p> <p class="j13-muted">等级 Lv.{{.Level}} · 经验 {{.Exp}}</p>
{{if .Avatar}} {{if .Avatar}}
<p><img class="j13-avatar" src="{{.Avatar}}" alt="头像"/></p> <p><img class="j13-avatar" src="{{.Avatar}}" alt="头像"/></p>
{{end}} {{end}}

View File

@@ -352,6 +352,67 @@ body.j13-body {
padding: 0.85rem 0; padding: 0.85rem 0;
border-bottom: 1px solid var(--j13-border); 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 { .j13-avatar {
width: 64px; width: 64px;
height: 64px; height: 64px;
@@ -431,6 +492,52 @@ body.j13-body {
.j13-msg-bubble__body { white-space: pre-wrap; overflow-wrap: anywhere; } .j13-msg-bubble__body { white-space: pre-wrap; overflow-wrap: anywhere; }
.j13-msg-compose { margin-top: 1.25rem; } .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 渐进增强 // 姜十三论坛 SSR 渐进增强
document.documentElement.dataset.j13Ssr = "1"; document.documentElement.dataset.j13Ssr = "1";
(function () { (function composeUploadAndGates() {
const form = document.getElementById("compose-form"); const form = document.getElementById("compose-form");
const fileInput = document.getElementById("compose-image");
const textarea = document.getElementById("compose-content"); const textarea = document.getElementById("compose-content");
const statusEl = document.getElementById("compose-upload-status"); if (!form || !textarea) return;
if (!form || !fileInput || !textarea) return;
const csrf = form.getAttribute("data-csrf") || ""; const csrf = form.getAttribute("data-csrf") || "";
const uploadURL = form.getAttribute("data-upload") || "/compose/upload"; 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 () => { function insertAtCursor(text) {
const file = fileInput.files && fileInput.files[0]; const start = textarea.selectionStart || 0;
fileInput.value = ""; const end = textarea.selectionEnd || start;
if (!file) return; textarea.value =
if (statusEl) statusEl.textContent = "上传中…"; textarea.value.slice(0, start) + text + textarea.value.slice(end);
const fd = new FormData(); const pos = start + text.length;
fd.append("image", file); textarea.focus();
fd.append("_csrf", csrf); textarea.setSelectionRange(pos, pos);
try { }
const res = await fetch(uploadURL, {
method: "POST", function wrapSelection(openTag, closeTag, fallbackInner) {
headers: { "X-CSRF-Token": csrf }, const start = textarea.selectionStart || 0;
body: fd, const end = textarea.selectionEnd || start;
credentials: "same-origin", let selected = textarea.value.slice(start, end);
}); if (!selected.trim()) {
const data = await res.json().catch(() => ({})); selected = fallbackInner || "在此填写隐藏内容";
if (!res.ok) { }
throw new Error(data.error || "上传失败"); // 若选区尚非段落,包一层 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; document.getElementById("compose-gate-login")?.addEventListener("click", () => {
const end = textarea.selectionEnd || start; wrapSelection(
textarea.value = '<members-only data-gate="login">',
textarea.value.slice(0, start) + md + textarea.value.slice(end); "</members-only>"
textarea.focus(); );
if (statusEl) statusEl.textContent = "已插入图片"; });
} catch (e) { document.getElementById("compose-gate-reply")?.addEventListener("click", () => {
if (statusEl) statusEl.textContent = e.message || "上传失败"; 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);
} }
}); });
})(); })();