From 204e7fdb32d217d527ac75b589deabe4c1104dbf Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 29 Aug 2026 06:20:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SSR=20=E5=86=85=E5=AE=B9=E9=97=A8?= =?UTF-8?q?=E6=8E=A7=E4=B8=8E=E7=A7=AF=E5=88=86=E9=92=B1=E5=8C=85/?= =?UTF-8?q?=E7=AD=BE=E5=88=B0=E6=8A=BD=E5=A5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 闭合积分解锁经济闭环:锁定壳与 unlock,以及 profile 钱包流水与每日签到/抽奖 PRG。 Co-authored-by: Cursor --- docs/rebuild-spec/02-features.md | 26 +-- docs/rebuild-spec/06-pages-ux.md | 7 +- .../rebuild-spec/08-gitea-ssr-architecture.md | 5 +- public/assets/site.css | 107 ++++++++++ public/assets/site.js | 198 +++++++++++++++--- routers/setup.go | 1 + routers/web/chrome.go | 35 ++-- routers/web/home.go | 3 + routers/web/post.go | 34 +++ routers/web/profile.go | 110 ++++++++++ services/compose_body.go | 12 +- services/content.go | 11 +- services/sanitize_html.go | 1 + services/unlock.go | 5 +- templates/base/navbar.tmpl | 2 +- templates/compose.tmpl | 5 +- templates/post/body.tmpl | 2 +- templates/profile/view.tmpl | 56 ++++- web_src/css/site.css | 107 ++++++++++ web_src/js/site.js | 198 +++++++++++++++--- 20 files changed, 818 insertions(+), 107 deletions(-) diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index d7772ce..629ba59 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -20,7 +20,7 @@ - [ ] 列表样式可配:`title` | `excerpt` | `thumbnail` - [ ] 搜索:关键词、标签、作者、仅标题(`title_only`) - [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序) -- [ ] 登录用户右栏/侧边:签到与抽奖入口 +- [ ] 登录用户右栏/侧边:签到与抽奖入口(入口暂在 `/profile` 钱包区) - [ ] 下拉刷新(移动端) - [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配) - [x] 404 页 @@ -35,7 +35,7 @@ - [ ] 忘记密码:邮箱验证码 + 重置 - [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP) - [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员 -- [x] 个人中心:改昵称、签名、密码、上传头像 — SSR `/profile`(裁剪未做) +- [x] 个人中心:改昵称、签名、密码、上传头像、积分钱包/签到/抽奖 — SSR `/profile`(裁剪未做) - [x] 个人活动统计:帖数、评数、收藏数、获赞 — `/profile` + `/user/:id` - [x] 公开用户主页 `/user/:id`(无邮箱) - [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控) @@ -87,13 +87,13 @@ ## E. 内容门控 -- [ ] 编辑器可插入「登录可见」块 -- [ ] 编辑器可插入「回复可见」块 -- [ ] 编辑器可插入「积分可见」块(可设价格) -- [ ] 未登录:遮盖 members-only 与 reply-only 正文,保留长度提示 -- [ ] 已登录未回复:遮盖 reply-only(作者与管理员始终可见) -- [ ] 积分块:未解锁遮盖;`POST .../unlock` 扣积分并返回 inner HTML -- [ ] 搜索 / SEO 出口对门控内容做红action,不泄露正文 +- [x] 编辑器可插入「登录可见」块 — compose 工具栏 +- [x] 编辑器可插入「回复可见」块 — compose 工具栏 +- [x] 编辑器可插入「积分可见」块(可设价格)— compose 工具栏 + prompt +- [x] 未登录:遮盖 members-only 与 reply-only 正文,保留长度提示 — 锁定壳 UI +- [x] 已登录未回复:遮盖 reply-only(作者与管理员始终可见) +- [x] 积分块:未解锁遮盖;`POST /post/:id/unlock` 扣积分并返回 inner HTML +- [x] 搜索 / SEO 出口对门控内容做红action,不泄露正文(既有) --- @@ -125,10 +125,10 @@ ## H. 积分、签到、抽奖、徽章、等级 -- [ ] 积分流水查询 -- [ ] 每日签到:基础 5,连签每日 +1,封顶 15 -- [ ] 每日抽奖:奖池加权(0/2/5/10/20),成本 0 -- [ ] 积分解锁分成:读者付全额,作者约 70% +- [x] 积分流水查询 — SSR `/profile` 钱包近 N 条 +- [x] 每日签到:基础 5,连签每日 +1,封顶 15 — `POST /profile/checkin` +- [x] 每日抽奖:奖池加权(0/2/5/10/20),成本 0 — `POST /profile/lottery` +- [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务 - [ ] 短龄同 IP 互刷拒绝分成 - [ ] Exp → 等级 Lv1–10 - [ ] 自动徽章(注册天数 / 获赞 / 创作分成) diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index ff32713..c2026c3 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -27,7 +27,7 @@ | `/` | Feed | 已迁 | | `/board/:id` | 板块 Feed | 已迁 | | `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 | -| `/compose` | 发帖(normal;Markdown textarea + 图片上传) | 已迁 | +| `/compose` | 发帖(normal;textarea + 图片 + 门控插入) | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 | | `/profile` | 个人中心(资料/密码/头像;无钱包/裁剪) | 已迁 | | `/user/:id` | 公开用户页 | 已迁 | @@ -137,7 +137,7 @@ | 模块 | 行为 | |------|------| -| 门控块 | 锁定态显示长度/价格;解锁按钮调 API 后替换 inner HTML | +| 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 | | 投票卡 | 选选项提交;显示百分比;作者可结束 | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 | @@ -161,7 +161,8 @@ - 资料编辑、密码、头像直传(**本迭代无裁剪器**)— SSR `/profile` - 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱) - 收藏列表 — SSR `/favorites` -- 积分钱包面板(流水、签到)、徽章展示:**未迁** +- 积分钱包面板(余额、近 N 条流水、签到/抽奖表单 PRG)— SSR `/profile`;导航展示当前积分 +- 徽章展示:**未迁** --- diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index a41fdab..6cbce12 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -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 只读;打开会话标已读)。 diff --git a/public/assets/site.css b/public/assets/site.css index d0acadf..9d17df4 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -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; +} + + diff --git a/public/assets/site.js b/public/assets/site.js index a4443bb..9fbbde8 100644 --- a/public/assets/site.js +++ b/public/assets/site.js @@ -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*" + selected.replace(/\n/g, "
\n") + "

"; + } + 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( + '', + "" + ); + }); + document.getElementById("compose-gate-reply")?.addEventListener("click", () => { + wrapSelection( + '', + "" + ); + }); + document.getElementById("compose-gate-points")?.addEventListener("click", () => { + let cost = window.prompt("积分价格(1–9999)", "10"); + if (cost == null) return; + cost = String(cost).trim(); + const n = parseInt(cost, 10); + if (!n || n < 1 || n > 9999) { + if (statusEl) statusEl.textContent = "价格须为 1–9999 的整数"; + return; + } + wrapSelection( + ``, + "" + ); + }); +})(); + +(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); } }); })(); diff --git a/routers/setup.go b/routers/setup.go index f8dc063..d0e7cf3 100644 --- a/routers/setup.go +++ b/routers/setup.go @@ -100,6 +100,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Board: boardSvc, Post: postSvc, Comment: commentSvc, Message: messageSvc, Filter: filter, Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore, + Points: services.NewPointsService(), }, authMW) r.GET("/media/thumb/*filepath", h.ServeImageThumb) diff --git a/routers/web/chrome.go b/routers/web/chrome.go index 65ce6f1..269689e 100644 --- a/routers/web/chrome.go +++ b/routers/web/chrome.go @@ -23,6 +23,7 @@ type Deps struct { Limiter *services.RateLimiter EmailCode *services.EmailCodeService Store *services.UploadStore + Points *services.PointsService } // BoardView 侧栏 @@ -47,7 +48,8 @@ type PageChrome struct { CSRF string Flash string Error string - UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0 + UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0 + ViewerPoints int // 登录用户当前积分;未登录为 0 } 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 { unread, _ = d.Message.UnreadCount(ctx.UserID()) } + viewerPoints := 0 + if ctx.IsSigned() && ctx.Doer != nil { + viewerPoints = ctx.Doer.Points + } return PageChrome{ - Title: title, - Description: desc, - SiteName: brand.Name, - Slogan: brand.Slogan, - LogoMark: firstRuneOr(brand.LogoMark, "姜"), - LoggedIn: ctx.IsSigned(), - IsAdmin: ctx.IsAdmin(), - ViewerName: name, - Boards: bv, - Inner: inner, - CSRF: ctx.EnsureCSRF(), - Flash: ctx.TakeFlash(), - UnreadCount: unread, + Title: title, + Description: desc, + SiteName: brand.Name, + Slogan: brand.Slogan, + LogoMark: firstRuneOr(brand.LogoMark, "姜"), + LoggedIn: ctx.IsSigned(), + IsAdmin: ctx.IsAdmin(), + ViewerName: name, + Boards: bv, + Inner: inner, + CSRF: ctx.EnsureCSRF(), + Flash: ctx.TakeFlash(), + UnreadCount: unread, + ViewerPoints: viewerPoints, } } diff --git a/routers/web/home.go b/routers/web/home.go index 56f6037..ac853b3 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -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/like", authMW.RequireAuth(), deps.PostLike) g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite) + g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock) g.GET("/login", deps.LoginGet) g.POST("/login", deps.LoginPost) 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/password", authMW.RequireAuth(), deps.ProfilePasswordPost) 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("/messages", authMW.RequireAuth(), deps.MessagesList) g.GET("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesThread) diff --git a/routers/web/post.go b/routers/web/post.go index 138ea51..26332a4 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -156,6 +156,40 @@ func (d Deps) PostComment(c *gin.Context) { 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 赞 func (d Deps) PostLike(c *gin.Context) { ctx := d.ctx(c) diff --git a/routers/web/profile.go b/routers/web/profile.go index 9eeac45..53bf195 100644 --- a/routers/web/profile.go +++ b/routers/web/profile.go @@ -1,6 +1,7 @@ package web import ( + "errors" "fmt" "net/http" "strings" @@ -11,6 +12,16 @@ import ( "github.com/gin-gonic/gin" ) +const profileLedgerLimit = 15 + +type profileLedgerItem struct { + Delta int + Balance int + ReasonLabel string + Note string + CreatedAt string +} + type profileData struct { PageChrome UserID uint @@ -29,6 +40,35 @@ type profileData struct { PublicURL string AvatarMaxMB 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 个人中心 @@ -48,6 +88,7 @@ func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) { st, _ := d.User.ActivityStats(uid) chrome := d.chrome(ctx, "个人中心 · "+d.Settings.SiteBranding().Name, "", "") chrome.Error = errMsg + chrome.ViewerPoints = user.Points nick := strings.TrimSpace(user.Nickname) if nick == "" { nick = user.Username @@ -71,6 +112,21 @@ func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) { AvatarMaxMB: d.Settings.AvatarMaxMB(), 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 ctx.HTML(http.StatusOK, "profile/view", data) @@ -166,3 +222,57 @@ func (d Deps) ProfileAvatarPost(c *gin.Context) { ctx.SetFlash("头像已更新") 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") +} diff --git a/services/compose_body.go b/services/compose_body.go index 9831700..2d899f9 100644 --- a/services/compose_body.go +++ b/services/compose_body.go @@ -12,7 +12,7 @@ var ( ) // ComposeBodyToHTML 将发帖表单正文转为可消毒 HTML。 -// 若已含块级 HTML 标签则原样交 Sanitize;否则按纯文本/轻量 Markdown 转段落。 +// 若已含块级 HTML / 门控标签则原样交 Sanitize;否则按纯文本/轻量 Markdown 转段落。 func ComposeBodyToHTML(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -22,7 +22,9 @@ func ComposeBodyToHTML(raw string) string { if strings.Contains(lower, "]+src="([^"]+)"[^>]*>`) s = imgRe.ReplaceAllString(s, "![]($1)") diff --git a/services/content.go b/services/content.go index 8765bf5..8ec4d14 100644 --- a/services/content.go +++ b/services/content.go @@ -1,8 +1,8 @@ package services import ( + "fmt" "regexp" - "strconv" "strings" "unicode/utf8" ) @@ -54,10 +54,17 @@ func redactGatedBlocks(html string, re *regexp.Regexp, tag string) string { } length := gatedContentLength(inner) gate := "login" + title := "登录后可见" + hint := "请登录后查看隐藏内容" if tag == "reply-only" { gate = "reply" + title = "回复后可见" + hint = "参与评论后即可查看" } - return `<` + tag + ` data-gate="` + gate + `" data-locked="true" data-length="` + strconv.Itoa(length) + `">` + return fmt.Sprintf( + `<%s data-gate="%s" data-locked="true" data-length="%d">

%s

约 %d 字

%s

`, + tag, gate, length, gate, title, length, hint, tag, + ) }) } diff --git a/services/sanitize_html.go b/services/sanitize_html.go index d72cc8d..1cc3a88 100644 --- a/services/sanitize_html.go +++ b/services/sanitize_html.go @@ -27,6 +27,7 @@ func postContentHTMLPolicy() *bluemonday.Policy { p.AllowAttrs("colspan", "rowspan").OnElements("th", "td") p.AllowAttrs( "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-style", "data-line-numbers", "data-collapsed", "data-line-count", "data-lineno-digits", diff --git a/services/unlock.go b/services/unlock.go index 95ddd44..4bed79b 100644 --- a/services/unlock.go +++ b/services/unlock.go @@ -100,7 +100,10 @@ func RedactPointsOnlyHTML(html string, unlocked map[string]bool) string { cost = 1 } length := gatedContentLength(inner) - return fmt.Sprintf(``, cost, key, length) + return fmt.Sprintf( + `

积分可见

需 %d 积分 · 约 %d 字

登录后可解锁

`, + cost, key, length, cost, length, + ) }) } diff --git a/templates/base/navbar.tmpl b/templates/base/navbar.tmpl index 72bed1a..6009312 100644 --- a/templates/base/navbar.tmpl +++ b/templates/base/navbar.tmpl @@ -13,7 +13,7 @@ 发帖 私信{{if gt .UnreadCount 0}} {{.UnreadCount}}{{end}} 收藏 - {{.ViewerName}} + {{.ViewerName}} · {{.ViewerPoints}} 积分 {{if .IsAdmin}}后台{{end}}
diff --git a/templates/compose.tmpl b/templates/compose.tmpl index 8bc929c..23992e1 100644 --- a/templates/compose.tmpl +++ b/templates/compose.tmpl @@ -29,9 +29,12 @@ 插入图片 + + + -

本版仅发普通讨论帖;投票 / 悬赏 / 抽奖后续迭代。

+

门控:选中正文后点按钮包裹;积分可见会询问价格(1–9999)。含门控时正文以 HTML 提交。

diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl index d2b5cd1..f63feb3 100644 --- a/templates/post/body.tmpl +++ b/templates/post/body.tmpl @@ -1,5 +1,5 @@ {{define "post/body"}} -
+

{{.PostTitle}}