feat: SSR 内容门控与积分钱包/签到抽奖
闭合积分解锁经济闭环:锁定壳与 unlock,以及 profile 钱包流水与每日签到/抽奖 PRG。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 → 等级 Lv1–10
|
- [ ] Exp → 等级 Lv1–10
|
||||||
- [ ] 自动徽章(注册天数 / 获赞 / 创作分成)
|
- [ ] 自动徽章(注册天数 / 获赞 / 创作分成)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| `/` | Feed | 已迁 |
|
| `/` | Feed | 已迁 |
|
||||||
| `/board/:id` | 板块 Feed | 已迁 |
|
| `/board/:id` | 板块 Feed | 已迁 |
|
||||||
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
|
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
|
||||||
| `/compose` | 发帖(normal;Markdown textarea + 图片上传) | 已迁 |
|
| `/compose` | 发帖(normal;textarea + 图片 + 门控插入) | 已迁 |
|
||||||
| `/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`;导航展示当前积分
|
||||||
|
- 徽章展示:**未迁**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 只读;打开会话标已读)。
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,48 @@
|
|||||||
// 姜十三论坛 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");
|
||||||
|
|
||||||
|
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 () => {
|
fileInput.addEventListener("change", async () => {
|
||||||
const file = fileInput.files && fileInput.files[0];
|
const file = fileInput.files && fileInput.files[0];
|
||||||
fileInput.value = "";
|
fileInput.value = "";
|
||||||
@@ -27,20 +59,120 @@ document.documentElement.dataset.j13Ssr = "1";
|
|||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(data.error || "上传失败");
|
||||||
throw new Error(data.error || "上传失败");
|
|
||||||
}
|
|
||||||
const url = data.url;
|
const url = data.url;
|
||||||
if (!url) throw new Error("未返回图片地址");
|
if (!url) throw new Error("未返回图片地址");
|
||||||
const md = `\n\n\n\n`;
|
insertAtCursor(`\n\n\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 = "已插入图片";
|
if (statusEl) statusEl.textContent = "已插入图片";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (statusEl) statusEl.textContent = e.message || "上传失败";
|
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("积分价格(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(
|
||||||
|
`<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);
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 侧栏
|
||||||
@@ -48,6 +49,7 @@ type PageChrome struct {
|
|||||||
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,6 +80,10 @@ 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,
|
||||||
@@ -92,6 +98,7 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
|
|||||||
CSRF: ctx.EnsureCSRF(),
|
CSRF: ctx.EnsureCSRF(),
|
||||||
Flash: ctx.TakeFlash(),
|
Flash: ctx.TakeFlash(),
|
||||||
UnreadCount: unread,
|
UnreadCount: unread,
|
||||||
|
ViewerPoints: viewerPoints,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, "")
|
s = imgRe.ReplaceAllString(s, "")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}}"/>
|
||||||
|
|||||||
@@ -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">门控:选中正文后点按钮包裹;积分可见会询问价格(1–9999)。含门控时正文以 HTML 提交。</p>
|
||||||
<button type="submit">{{if .IsEdit}}保存{{else}}发布{{end}}</button>
|
<button type="submit">{{if .IsEdit}}保存{{else}}发布{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,48 @@
|
|||||||
// 姜十三论坛 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");
|
||||||
|
|
||||||
|
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 () => {
|
fileInput.addEventListener("change", async () => {
|
||||||
const file = fileInput.files && fileInput.files[0];
|
const file = fileInput.files && fileInput.files[0];
|
||||||
fileInput.value = "";
|
fileInput.value = "";
|
||||||
@@ -27,20 +59,120 @@ document.documentElement.dataset.j13Ssr = "1";
|
|||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(data.error || "上传失败");
|
||||||
throw new Error(data.error || "上传失败");
|
|
||||||
}
|
|
||||||
const url = data.url;
|
const url = data.url;
|
||||||
if (!url) throw new Error("未返回图片地址");
|
if (!url) throw new Error("未返回图片地址");
|
||||||
const md = `\n\n\n\n`;
|
insertAtCursor(`\n\n\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 = "已插入图片";
|
if (statusEl) statusEl.textContent = "已插入图片";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (statusEl) statusEl.textContent = e.message || "上传失败";
|
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("积分价格(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(
|
||||||
|
`<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);
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user