完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。

同步更新 README 与 ROADMAP,反映最新功能与开发状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-16 04:11:38 +08:00
parent 1d273066b0
commit b451703642
73 changed files with 1943 additions and 752 deletions

View File

@@ -66,8 +66,8 @@
</table>
<p align="center">
<img src="docs/screenshots/compose.png" alt="登录页" width="360">
<br><b>登录 / 注册</b> — 居中卡片式表单,简洁无干扰
<img src="docs/screenshots/compose.png" alt="发帖页" width="360">
<br><b>发帖</b> — TipTap 富文本编辑器,支持本地上传图片
</p>
---
@@ -80,7 +80,7 @@
|------|------|
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 |
| **已读 / 未读** | 未读高亮、角标提醒、批量标记已读 |
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 |
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 |
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
| **高密度排版** | V2EX / NGA 风格信息密度,一屏浏览更多内容 |
@@ -89,11 +89,13 @@
- 用户注册 / 登录bcrypt + JWT Cookie
- 普通用户 / 管理员两级权限,**首个注册用户自动成为管理员**
- 板块管理、发帖、Markdown / 富文本、标签、置顶
- 板块管理、发帖、TipTap 富文本编辑、正文图片本地上传、标签、置顶
- 帖子修订历史:编辑后保留版本记录,支持 diff 对比查看
- 可配置编辑时限:管理员设定普通用户修改帖子的有效窗口
- 楼层式评论,支持回复指定楼层、@ 高亮、引用回复
- 点赞、收藏、热门帖、最新动态
- 管理员后台删帖、删评论、禁言、SQLite 一键备份
- 内置敏感词过滤、发帖 / 评论限流
- 管理员后台:删帖、删评论、禁言、论坛参数配置、敏感词管理、SQLite 一键备份
- 内置敏感词过滤、发帖 / 评论 / 注册 / 登录限流(后台可配)
### 部署体验
@@ -168,7 +170,7 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
| 层级 | 技术 |
|------|------|
| **后端** | Go 1.26 · Gin · GORM · SQLite |
| **前端** | React 18 · Radix UI · Tailwind CSS · TanStack Virtual |
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
| **构建** | Vite → `go:embed` 内嵌 SPA单二进制发布 |
| **认证** | bcrypt · JWT Cookie |
@@ -224,6 +226,7 @@ data/
├── filter_words.txt # 敏感词配置
├── .jwt_secret # JWT 密钥(自动生成)
├── uploads/avatars/ # 用户头像
├── uploads/posts/ # 帖子正文图片
└── jiang13_backup_*.db # 后台导出的备份
```
@@ -235,9 +238,10 @@ data/
| 类型 | 示例 |
|------|------|
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、楼层评论 |
| ✅ 管理后台 | React SPA`/admin/dashboard` 仪表盘、帖子置顶、用户禁言等 |
| 📋 计划中 | 通知已读优化、邮件提醒 |
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
| ✅ 发帖体验 | TipTap 富文本、正文图片上传、修订历史、可配置编辑时限 |
| ✅ 管理后台 | React SPA仪表盘、帖子置顶、用户禁言、论坛参数与敏感词配置 |
| 📋 计划中 | 通知动态优化、邮件提醒 |
完整列表见 **[路线图 ROADMAP.md](ROADMAP.md)**。发现问题请提交 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues),认领任务请参考 [CONTRIBUTING.md](CONTRIBUTING.md)。

View File

@@ -27,7 +27,7 @@ _当前无已记录缺陷。发现新问题请提交 [Issue](https://git.iioio.c
| 优先级 | 功能 | 说明 |
|--------|------|------|
| 中 | 通知已读状态优化 | 右栏通知点击后的已读同步 |
| 中 | 通知态优化 | 右栏最新动态的展示与交互 |
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) |
@@ -48,7 +48,11 @@ _当前无公开认领任务。_
- [x] 浅色 / 暗色主题切换
- [x] 移动端响应式适配
- [x] 用户注册登录、JWT 鉴权
- [x] 板块管理、发帖、Markdown 编辑
- [x] 板块管理、发帖、TipTap 富文本编辑
- [x] 帖子正文图片本地上传
- [x] 帖子修订历史与 diff 对比
- [x] Feed 排序(最新发帖 / 最新回复 / 热门讨论)
- [x] 可配置编辑时限与论坛参数(限流、字数上限等)
- [x] 楼层式评论、引用回复、@ 高亮
- [x] 点赞、收藏、热门帖
- [x] 敏感词过滤、发帖限流
@@ -64,4 +68,4 @@ _当前无公开认领任务。_
---
_最后更新2026-06-15_
_最后更新2026-06-16_

View File

@@ -34,6 +34,10 @@ func Parse() (*Config, error) {
if err := os.MkdirAll(uploadDir, 0755); err != nil {
return nil, fmt.Errorf("创建上传目录失败: %w", err)
}
postImgDir := filepath.Join(*dataDir, "uploads", "posts")
if err := os.MkdirAll(postImgDir, 0755); err != nil {
return nil, fmt.Errorf("创建帖子图片目录失败: %w", err)
}
cfg := &Config{
Port: *port,
@@ -61,11 +65,21 @@ func (c *Config) DBPath() string {
return filepath.Join(c.DataDir, "jiang13.db")
}
// UploadDir 返回头像上传目录
func (c *Config) UploadDir() string {
// AvatarUploadDir 返回头像上传目录
func (c *Config) AvatarUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "avatars")
}
// PostImageUploadDir 返回帖子正文图片上传目录
func (c *Config) PostImageUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "posts")
}
// UploadDir 返回头像上传目录(兼容旧调用)
func (c *Config) UploadDir() string {
return c.AvatarUploadDir()
}
// FilterWordsPath 返回敏感词配置文件路径
func (c *Config) FilterWordsPath() string {
return filepath.Join(c.DataDir, "filter_words.txt")

View File

@@ -1 +1 @@
import{e as A,r as i,j as e}from"./react-vendor-CFzzFOsg.js";import{d as D,S as C,B as d,a as m,n as c}from"./index-L-Mx2Hle.js";import{B as N}from"./badge-e5OBoJ6g.js";import{A as y,a as b,b as k,c as S,d as _,e as z,f as B,g as E,h as P}from"./alert-dialog-Q1fPYDxo.js";import"./ui-vendor-DGgNN2YO.js";function G(){const j=A(),{ready:r}=D(),[o,g]=i.useState([]),[p,h]=i.useState(!0),[a,u]=i.useState(1),[l,f]=i.useState(1),n=(s=a)=>{h(!0),m.adminComments(s).then(t=>{g(t.comments??[]),u(t.page),f(t.total_pages)}).catch(t=>c.error(t.message)).finally(()=>h(!1))};i.useEffect(()=>{r&&n(1)},[r]);const v=async s=>{try{await m.adminDeleteComment(s),c.success("评论已删除"),n()}catch(t){c.error(t instanceof Error?t.message:"删除失败")}};return r?e.jsxs("div",{className:"admin-page",children:[e.jsxs("div",{className:"admin-page-head",children:[e.jsx("h1",{children:"评论管理"}),e.jsx("p",{children:"查看与删除楼层评论"})]}),e.jsx("div",{className:"admin-card",children:p?e.jsx("div",{className:"flex justify-center py-12",children:e.jsx(C,{size:"lg"})}):e.jsxs(e.Fragment,{children:[e.jsxs("table",{className:"admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"楼层"}),e.jsx("th",{children:"帖子"}),e.jsx("th",{children:"作者"}),e.jsx("th",{children:"内容"}),e.jsx("th",{children:"私密"}),e.jsx("th",{children:"时间"}),e.jsx("th",{children:"操作"})]})}),e.jsx("tbody",{children:o.map(s=>{var t,x;return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:["#",s.floor]}),e.jsx("td",{children:e.jsx("button",{type:"button",className:"admin-text-link",onClick:()=>j(`/post/${s.post_id}`),children:((t=s.post)==null?void 0:t.title)??`#${s.post_id}`})}),e.jsx("td",{children:((x=s.user)==null?void 0:x.nickname)||s.guest_nick||"游客"}),e.jsx("td",{className:"max-w-[200px] truncate",children:s.content}),e.jsx("td",{children:s.is_private?e.jsx(N,{variant:"secondary",children:"是"}):"—"}),e.jsx("td",{children:new Date(s.created_at).toLocaleString("zh-CN")}),e.jsx("td",{children:e.jsxs(y,{children:[e.jsx(b,{asChild:!0,children:e.jsx(d,{size:"sm",variant:"ghost",className:"text-destructive",children:"删除"})}),e.jsxs(k,{children:[e.jsxs(S,{children:[e.jsx(_,{children:"确定删除该评论?"}),e.jsx(z,{children:"此操作不可恢复。"})]}),e.jsxs(B,{children:[e.jsx(E,{children:"取消"}),e.jsx(P,{onClick:()=>v(s.id),children:"删除"})]})]})]})})]},s.id)})})]}),o.length===0&&e.jsx("div",{className:"admin-empty",children:"暂无评论"}),l>1&&e.jsxs("div",{className:"admin-pagination",children:[e.jsx(d,{size:"sm",variant:"outline",disabled:a<=1,onClick:()=>n(a-1),children:"上一页"}),e.jsxs("span",{children:["第 ",a," / ",l," 页"]}),e.jsx(d,{size:"sm",variant:"outline",disabled:a>=l,onClick:()=>n(a+1),children:"下一页"})]})]})})]}):null}export{G as default};
import{e as A,r as i,j as e}from"./react-vendor-CFzzFOsg.js";import{e as D,S as C,B as d,a as m,n as c}from"./index-5C0q-ZMs.js";import{B as N}from"./badge-DB-zL5ml.js";import{A as y,a as b,b as k,c as S,d as _,e as z,f as B,g as E,h as P}from"./alert-dialog-BoQ7HSUR.js";import"./ui-vendor-BtpOfToU.js";function G(){const j=A(),{ready:r}=D(),[o,g]=i.useState([]),[p,h]=i.useState(!0),[a,u]=i.useState(1),[l,f]=i.useState(1),n=(s=a)=>{h(!0),m.adminComments(s).then(t=>{g(t.comments??[]),u(t.page),f(t.total_pages)}).catch(t=>c.error(t.message)).finally(()=>h(!1))};i.useEffect(()=>{r&&n(1)},[r]);const v=async s=>{try{await m.adminDeleteComment(s),c.success("评论已删除"),n()}catch(t){c.error(t instanceof Error?t.message:"删除失败")}};return r?e.jsxs("div",{className:"admin-page",children:[e.jsxs("div",{className:"admin-page-head",children:[e.jsx("h1",{children:"评论管理"}),e.jsx("p",{children:"查看与删除楼层评论"})]}),e.jsx("div",{className:"admin-card",children:p?e.jsx("div",{className:"flex justify-center py-12",children:e.jsx(C,{size:"lg"})}):e.jsxs(e.Fragment,{children:[e.jsxs("table",{className:"admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"楼层"}),e.jsx("th",{children:"帖子"}),e.jsx("th",{children:"作者"}),e.jsx("th",{children:"内容"}),e.jsx("th",{children:"私密"}),e.jsx("th",{children:"时间"}),e.jsx("th",{children:"操作"})]})}),e.jsx("tbody",{children:o.map(s=>{var t,x;return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:["#",s.floor]}),e.jsx("td",{children:e.jsx("button",{type:"button",className:"admin-text-link",onClick:()=>j(`/post/${s.post_id}`),children:((t=s.post)==null?void 0:t.title)??`#${s.post_id}`})}),e.jsx("td",{children:((x=s.user)==null?void 0:x.nickname)||s.guest_nick||"游客"}),e.jsx("td",{className:"max-w-[200px] truncate",children:s.content}),e.jsx("td",{children:s.is_private?e.jsx(N,{variant:"secondary",children:"是"}):"—"}),e.jsx("td",{children:new Date(s.created_at).toLocaleString("zh-CN")}),e.jsx("td",{children:e.jsxs(y,{children:[e.jsx(b,{asChild:!0,children:e.jsx(d,{size:"sm",variant:"ghost",className:"text-destructive",children:"删除"})}),e.jsxs(k,{children:[e.jsxs(S,{children:[e.jsx(_,{children:"确定删除该评论?"}),e.jsx(z,{children:"此操作不可恢复。"})]}),e.jsxs(B,{children:[e.jsx(E,{children:"取消"}),e.jsx(P,{onClick:()=>v(s.id),children:"删除"})]})]})]})})]},s.id)})})]}),o.length===0&&e.jsx("div",{className:"admin-empty",children:"暂无评论"}),l>1&&e.jsxs("div",{className:"admin-pagination",children:[e.jsx(d,{size:"sm",variant:"outline",disabled:a<=1,onClick:()=>n(a-1),children:"上一页"}),e.jsxs("span",{children:["第 ",a," / ",l," 页"]}),e.jsx(d,{size:"sm",variant:"outline",disabled:a>=l,onClick:()=>n(a+1),children:"下一页"})]})]})})]}):null}export{G as default};

View File

@@ -1 +1 @@
import{e as h,r as n,j as s}from"./react-vendor-CFzzFOsg.js";import{d as o,a as x,S as j}from"./index-L-Mx2Hle.js";import{B as u}from"./badge-e5OBoJ6g.js";import"./ui-vendor-DGgNN2YO.js";function f(){const l=h(),{ready:t}=o(),[e,d]=n.useState(null),[r,c]=n.useState(!0);if(n.useEffect(()=>{t&&x.adminDashboard().then(d).finally(()=>c(!1))},[t]),!t||r)return s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(j,{size:"lg"})});if(!e)return null;const m=[{label:"注册用户",value:e.users,cls:"admin-stat-users"},{label:"帖子总数",value:e.posts,cls:"admin-stat-posts"},{label:"板块数量",value:e.boards,cls:"admin-stat-boards"},{label:"评论总数",value:e.comments,cls:"admin-stat-comments"},{label:"当前在线",value:e.online,cls:"admin-stat-online"}];return s.jsxs("div",{className:"admin-page",children:[s.jsxs("div",{className:"admin-page-head",children:[s.jsx("h1",{children:"仪表盘"}),s.jsx("p",{children:"论坛运行概览与最新帖子"})]}),s.jsx("div",{className:"admin-stat-grid",children:m.map(a=>s.jsxs("div",{className:`admin-stat-card ${a.cls}`,children:[s.jsx("div",{className:"admin-stat-value",children:a.value}),s.jsx("div",{className:"admin-stat-label",children:a.label})]},a.label))}),s.jsxs("div",{className:"admin-card",children:[s.jsxs("div",{className:"admin-card-head",children:[s.jsx("span",{children:"最新帖子"}),s.jsx("button",{type:"button",className:"admin-text-link",onClick:()=>l("/admin/posts"),children:"查看全部 →"})]}),s.jsxs("table",{className:"admin-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"ID"}),s.jsx("th",{children:"标题"}),s.jsx("th",{children:"作者"}),s.jsx("th",{children:"置顶"}),s.jsx("th",{children:"时间"})]})}),s.jsx("tbody",{children:e.recent_posts.map(a=>{var i;return s.jsxs("tr",{children:[s.jsx("td",{children:a.id}),s.jsx("td",{children:s.jsx("button",{type:"button",className:"admin-text-link",onClick:()=>l(`/post/${a.id}`),children:a.title})}),s.jsx("td",{children:((i=a.user)==null?void 0:i.nickname)??"—"}),s.jsx("td",{children:a.pinned?s.jsx(u,{variant:"orange",children:"是"}):"—"}),s.jsx("td",{children:new Date(a.created_at).toLocaleString("zh-CN")})]},a.id)})})]}),e.recent_posts.length===0&&s.jsx("div",{className:"admin-empty",children:"暂无帖子"})]})]})}export{f as default};
import{e as h,r as n,j as s}from"./react-vendor-CFzzFOsg.js";import{e as o,a as x,S as j}from"./index-5C0q-ZMs.js";import{B as u}from"./badge-DB-zL5ml.js";import"./ui-vendor-BtpOfToU.js";function f(){const l=h(),{ready:t}=o(),[e,d]=n.useState(null),[r,c]=n.useState(!0);if(n.useEffect(()=>{t&&x.adminDashboard().then(d).finally(()=>c(!1))},[t]),!t||r)return s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(j,{size:"lg"})});if(!e)return null;const m=[{label:"注册用户",value:e.users,cls:"admin-stat-users"},{label:"帖子总数",value:e.posts,cls:"admin-stat-posts"},{label:"板块数量",value:e.boards,cls:"admin-stat-boards"},{label:"评论总数",value:e.comments,cls:"admin-stat-comments"},{label:"当前在线",value:e.online,cls:"admin-stat-online"}];return s.jsxs("div",{className:"admin-page",children:[s.jsxs("div",{className:"admin-page-head",children:[s.jsx("h1",{children:"仪表盘"}),s.jsx("p",{children:"论坛运行概览与最新帖子"})]}),s.jsx("div",{className:"admin-stat-grid",children:m.map(a=>s.jsxs("div",{className:`admin-stat-card ${a.cls}`,children:[s.jsx("div",{className:"admin-stat-value",children:a.value}),s.jsx("div",{className:"admin-stat-label",children:a.label})]},a.label))}),s.jsxs("div",{className:"admin-card",children:[s.jsxs("div",{className:"admin-card-head",children:[s.jsx("span",{children:"最新帖子"}),s.jsx("button",{type:"button",className:"admin-text-link",onClick:()=>l("/admin/posts"),children:"查看全部 →"})]}),s.jsxs("table",{className:"admin-table",children:[s.jsx("thead",{children:s.jsxs("tr",{children:[s.jsx("th",{children:"ID"}),s.jsx("th",{children:"标题"}),s.jsx("th",{children:"作者"}),s.jsx("th",{children:"置顶"}),s.jsx("th",{children:"时间"})]})}),s.jsx("tbody",{children:e.recent_posts.map(a=>{var i;return s.jsxs("tr",{children:[s.jsx("td",{children:a.id}),s.jsx("td",{children:s.jsx("button",{type:"button",className:"admin-text-link",onClick:()=>l(`/post/${a.id}`),children:a.title})}),s.jsx("td",{children:((i=a.user)==null?void 0:i.nickname)??"—"}),s.jsx("td",{children:a.pinned?s.jsx(u,{variant:"orange",children:"是"}):"—"}),s.jsx("td",{children:new Date(a.created_at).toLocaleString("zh-CN")})]},a.id)})})]}),e.recent_posts.length===0&&s.jsx("div",{className:"admin-empty",children:"暂无帖子"})]})]})}export{f as default};

View File

@@ -1 +0,0 @@
import{e as w,r as i,j as e}from"./react-vendor-CFzzFOsg.js";import{d as P,B as n,S,a as o,n as r}from"./index-L-Mx2Hle.js";import{I as z}from"./input-CIPYpxcv.js";import{B as v}from"./badge-e5OBoJ6g.js";import{A as E,a as _,b as L,c as F,d as B,e as I,f as T,g as G,h as H}from"./alert-dialog-Q1fPYDxo.js";import{c as K}from"./feedCache-D2GnU_BI.js";import{l as O,ac as R,E as $}from"./ui-vendor-DGgNN2YO.js";function X(){const p=w(),{ready:h}=P(),[j,k]=i.useState([]),[y,m]=i.useState(!0),[c,A]=i.useState(1),[x,D]=i.useState(1),[g,u]=i.useState(""),[d,f]=i.useState(""),l=(s=c,t=d)=>{m(!0),o.adminPosts({page:s,keyword:t}).then(a=>{k(a.posts??[]),A(a.page),D(a.total_pages)}).catch(a=>r.error(a.message)).finally(()=>m(!1))};i.useEffect(()=>{h&&l(1,d)},[h,d]);const N=async s=>{try{const t=await o.adminPinPost(s.id,!s.pinned);K(),window.dispatchEvent(new Event("posts-refresh")),r.success(t.message),l()}catch(t){r.error(t instanceof Error?t.message:"操作失败")}},b=async s=>{try{const t=await o.adminLockPost(s.id,!s.edit_locked);r.success(t.message),l()}catch(t){r.error(t instanceof Error?t.message:"操作失败")}},C=async s=>{try{await o.adminDeletePost(s),r.success("帖子已删除"),l()}catch(t){r.error(t instanceof Error?t.message:"删除失败")}};return h?e.jsxs("div",{className:"admin-page",children:[e.jsxs("div",{className:"admin-page-head",children:[e.jsx("h1",{children:"帖子管理"}),e.jsx("p",{children:"置顶、删除帖子,搜索标题关键词"})]}),e.jsxs("form",{className:"admin-search-bar",onSubmit:s=>{s.preventDefault(),f(g.trim())},children:[e.jsx(z,{value:g,onChange:s=>u(s.target.value),placeholder:"搜索帖子标题…"}),e.jsxs(n,{type:"submit",children:[e.jsx(O,{size:16}),"搜索"]}),d&&e.jsx(n,{type:"button",variant:"outline",onClick:()=>{u(""),f("")},children:"清除"})]}),e.jsx("div",{className:"admin-card",children:y?e.jsx("div",{className:"flex justify-center py-12",children:e.jsx(S,{size:"lg"})}):e.jsxs(e.Fragment,{children:[e.jsxs("table",{className:"admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"标题"}),e.jsx("th",{children:"板块"}),e.jsx("th",{children:"作者"}),e.jsx("th",{children:"置顶"}),e.jsx("th",{children:"锁定"}),e.jsx("th",{children:"点赞"}),e.jsx("th",{children:"浏览"}),e.jsx("th",{children:"时间"}),e.jsx("th",{children:"操作"})]})}),e.jsx("tbody",{children:j.map(s=>{var t,a;return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsx("td",{className:"max-w-[220px] truncate",children:e.jsx("button",{type:"button",className:"admin-text-link",onClick:()=>p(`/post/${s.id}`),children:s.title})}),e.jsx("td",{children:((t=s.board)==null?void 0:t.name)??"—"}),e.jsx("td",{children:((a=s.user)==null?void 0:a.nickname)??"—"}),e.jsx("td",{children:s.pinned?e.jsx(v,{variant:"orange",children:"是"}):"—"}),e.jsx("td",{children:s.edit_locked?e.jsx(v,{variant:"destructive",children:"是"}):"—"}),e.jsx("td",{children:s.like_count}),e.jsx("td",{children:s.view_count}),e.jsx("td",{children:new Date(s.created_at).toLocaleString("zh-CN")}),e.jsx("td",{children:e.jsxs("div",{className:"flex gap-1",children:[e.jsx(n,{size:"sm",variant:"outline",onClick:()=>N(s),children:s.pinned?"取消置顶":"置顶"}),e.jsx(n,{size:"sm",variant:"outline",onClick:()=>b(s),children:s.edit_locked?e.jsxs(e.Fragment,{children:[e.jsx(R,{size:14})," 解锁"]}):e.jsxs(e.Fragment,{children:[e.jsx($,{size:14})," 锁定"]})}),e.jsxs(E,{children:[e.jsx(_,{asChild:!0,children:e.jsx(n,{size:"sm",variant:"ghost",className:"text-destructive",children:"删除"})}),e.jsxs(L,{children:[e.jsxs(F,{children:[e.jsx(B,{children:"确定删除该帖子?"}),e.jsx(I,{children:"相关评论也将一并删除,不可恢复。"})]}),e.jsxs(T,{children:[e.jsx(G,{children:"取消"}),e.jsx(H,{onClick:()=>C(s.id),children:"删除"})]})]})]})]})})]},s.id)})})]}),j.length===0&&e.jsx("div",{className:"admin-empty",children:"没有找到帖子"}),x>1&&e.jsxs("div",{className:"admin-pagination",children:[e.jsx(n,{size:"sm",variant:"outline",disabled:c<=1,onClick:()=>l(c-1),children:"上一页"}),e.jsxs("span",{children:["第 ",c," / ",x," 页"]}),e.jsx(n,{size:"sm",variant:"outline",disabled:c>=x,onClick:()=>l(c+1),children:"下一页"})]})]})})]}):null}export{X as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{r as a,j as s}from"./react-vendor-CFzzFOsg.js";import{d as _,a as c,S as N,B as x,n}from"./index-L-Mx2Hle.js";import{I as v}from"./input-CIPYpxcv.js";import{am as y}from"./ui-vendor-DGgNN2YO.js";function F(){const{ready:r}=_(),[d,l]=a.useState(null),[j,p]=a.useState(!0),[f,o]=a.useState(!1),[m,h]=a.useState("24"),[g,u]=a.useState(!1);a.useEffect(()=>{r&&c.adminSettings().then(e=>{l(e),h(String(e.post_edit_window_hours??24))}).finally(()=>p(!1))},[r]);const w=async()=>{const e=parseInt(m,10);if(Number.isNaN(e)||e<0){n.warning("请输入有效的小时数0 表示不限)");return}u(!0);try{const i=await c.adminUpdateForumSettings({post_edit_window_hours:e});n.success(i.message),l(t=>t&&{...t,post_edit_window_hours:i.post_edit_window_hours})}catch(i){n.error(i instanceof Error?i.message:"保存失败")}finally{u(!1)}},S=async()=>{o(!0);try{const e=await c.adminBackup();n.success(e.message),window.location.href=e.download}catch(e){n.error(e instanceof Error?e.message:"备份失败")}finally{o(!1)}};return!r||j?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(N,{size:"lg"})}):d?s.jsxs("div",{className:"admin-page",children:[s.jsxs("div",{className:"admin-page-head",children:[s.jsx("h1",{children:"系统设置"}),s.jsx("p",{children:"数据目录、敏感词配置与数据库备份"})]}),s.jsxs("div",{className:"admin-card",children:[s.jsx("div",{className:"admin-card-head",children:"论坛规则"}),s.jsx("p",{className:"admin-card-desc",children:"设置普通用户发布后可编辑帖子的时限。超过时限后仅管理员可编辑。设为 0 表示不限时。"}),s.jsxs("div",{className:"admin-form-row",children:[s.jsx("label",{htmlFor:"edit-window-hours",children:"编辑时限(小时)"}),s.jsx(v,{id:"edit-window-hours",type:"number",min:0,value:m,onChange:e=>h(e.target.value),className:"max-w-[120px]"}),s.jsx(x,{onClick:w,loading:g,children:"保存"})]})]}),s.jsxs("div",{className:"admin-card",children:[s.jsx("div",{className:"admin-card-head",children:"运行信息"}),s.jsxs("dl",{className:"admin-dl",children:[s.jsx("dt",{children:"数据目录"}),s.jsx("dd",{children:s.jsx("code",{children:d.data_dir})}),s.jsx("dt",{children:"数据库路径"}),s.jsx("dd",{children:s.jsx("code",{children:d.db_path})}),s.jsx("dt",{children:"敏感词配置"}),s.jsx("dd",{children:s.jsx("code",{children:d.filter_path})}),s.jsx("dt",{children:"监听端口"}),s.jsx("dd",{children:d.port})]})]}),s.jsxs("div",{className:"admin-card",children:[s.jsx("div",{className:"admin-card-head",children:"数据备份"}),s.jsxs("p",{className:"admin-card-desc",children:["导出当前 SQLite 数据库副本,文件名格式为 ",s.jsx("code",{children:"jiang13_backup_YYYYMMDD_HHMMSS.db"})]}),s.jsxs(x,{onClick:S,loading:f,children:[s.jsx(y,{size:16}),"立即备份并下载"]})]})]}):null}export{F as default};

View File

@@ -1 +1 @@
import{r as i,j as e}from"./react-vendor-CFzzFOsg.js";import{d as v,S as y,B as l,a as j,n as r}from"./index-L-Mx2Hle.js";import{B as h}from"./badge-e5OBoJ6g.js";import"./ui-vendor-DGgNN2YO.js";function z(){const{ready:d}=v(),[o,m]=i.useState([]),[g,x]=i.useState(!0),[n,p]=i.useState(1),[c,f]=i.useState(1),t=(s=n)=>{x(!0),j.adminUsers(s).then(a=>{m(a.users??[]),p(a.page),f(a.total_pages)}).catch(a=>r.error(a.message)).finally(()=>x(!1))};i.useEffect(()=>{d&&t(1)},[d]);const u=async s=>{if(s.role==="admin"){r.warning("不能禁言管理员");return}try{const a=await j.adminBanUser(s.id,!s.banned);r.success(a.message),t()}catch(a){r.error(a instanceof Error?a.message:"操作失败")}};return d?e.jsxs("div",{className:"admin-page",children:[e.jsxs("div",{className:"admin-page-head",children:[e.jsx("h1",{children:"用户管理"}),e.jsx("p",{children:"查看注册用户,禁言或解除禁言"})]}),e.jsx("div",{className:"admin-card",children:g?e.jsx("div",{className:"flex justify-center py-12",children:e.jsx(y,{size:"lg"})}):e.jsxs(e.Fragment,{children:[e.jsxs("table",{className:"admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"用户名"}),e.jsx("th",{children:"昵称"}),e.jsx("th",{children:"角色"}),e.jsx("th",{children:"状态"}),e.jsx("th",{children:"注册时间"}),e.jsx("th",{children:"操作"})]})}),e.jsx("tbody",{children:o.map(s=>e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsx("td",{children:s.username}),e.jsx("td",{children:s.nickname}),e.jsx("td",{children:s.role==="admin"?e.jsx(h,{variant:"orange",children:"管理员"}):e.jsx(h,{variant:"secondary",children:"用户"})}),e.jsx("td",{children:s.banned?e.jsx(h,{variant:"destructive",children:"已禁言"}):"正常"}),e.jsx("td",{children:s.created_at?new Date(s.created_at).toLocaleString("zh-CN"):"—"}),e.jsx("td",{children:s.role!=="admin"&&e.jsx(l,{size:"sm",variant:"outline",onClick:()=>u(s),children:s.banned?"解除禁言":"禁言"})})]},s.id))})]}),o.length===0&&e.jsx("div",{className:"admin-empty",children:"暂无用户"}),c>1&&e.jsxs("div",{className:"admin-pagination",children:[e.jsx(l,{size:"sm",variant:"outline",disabled:n<=1,onClick:()=>t(n-1),children:"上一页"}),e.jsxs("span",{children:["第 ",n," / ",c," 页"]}),e.jsx(l,{size:"sm",variant:"outline",disabled:n>=c,onClick:()=>t(n+1),children:"下一页"})]})]})})]}):null}export{z as default};
import{r as i,j as e}from"./react-vendor-CFzzFOsg.js";import{e as v,S as y,B as l,a as j,n as r}from"./index-5C0q-ZMs.js";import{B as h}from"./badge-DB-zL5ml.js";import"./ui-vendor-BtpOfToU.js";function z(){const{ready:d}=v(),[o,m]=i.useState([]),[g,x]=i.useState(!0),[n,p]=i.useState(1),[c,f]=i.useState(1),t=(s=n)=>{x(!0),j.adminUsers(s).then(a=>{m(a.users??[]),p(a.page),f(a.total_pages)}).catch(a=>r.error(a.message)).finally(()=>x(!1))};i.useEffect(()=>{d&&t(1)},[d]);const u=async s=>{if(s.role==="admin"){r.warning("不能禁言管理员");return}try{const a=await j.adminBanUser(s.id,!s.banned);r.success(a.message),t()}catch(a){r.error(a instanceof Error?a.message:"操作失败")}};return d?e.jsxs("div",{className:"admin-page",children:[e.jsxs("div",{className:"admin-page-head",children:[e.jsx("h1",{children:"用户管理"}),e.jsx("p",{children:"查看注册用户,禁言或解除禁言"})]}),e.jsx("div",{className:"admin-card",children:g?e.jsx("div",{className:"flex justify-center py-12",children:e.jsx(y,{size:"lg"})}):e.jsxs(e.Fragment,{children:[e.jsxs("table",{className:"admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"用户名"}),e.jsx("th",{children:"昵称"}),e.jsx("th",{children:"角色"}),e.jsx("th",{children:"状态"}),e.jsx("th",{children:"注册时间"}),e.jsx("th",{children:"操作"})]})}),e.jsx("tbody",{children:o.map(s=>e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsx("td",{children:s.username}),e.jsx("td",{children:s.nickname}),e.jsx("td",{children:s.role==="admin"?e.jsx(h,{variant:"orange",children:"管理员"}):e.jsx(h,{variant:"secondary",children:"用户"})}),e.jsx("td",{children:s.banned?e.jsx(h,{variant:"destructive",children:"已禁言"}):"正常"}),e.jsx("td",{children:s.created_at?new Date(s.created_at).toLocaleString("zh-CN"):"—"}),e.jsx("td",{children:s.role!=="admin"&&e.jsx(l,{size:"sm",variant:"outline",onClick:()=>u(s),children:s.banned?"解除禁言":"禁言"})})]},s.id))})]}),o.length===0&&e.jsx("div",{className:"admin-empty",children:"暂无用户"}),c>1&&e.jsxs("div",{className:"admin-pagination",children:[e.jsx(l,{size:"sm",variant:"outline",disabled:n<=1,onClick:()=>t(n-1),children:"上一页"}),e.jsxs("span",{children:["第 ",n," / ",c," 页"]}),e.jsx(l,{size:"sm",variant:"outline",disabled:n>=c,onClick:()=>t(n+1),children:"下一页"})]})]})})]}):null}export{z as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{e as j,r as n,j as s}from"./react-vendor-CFzzFOsg.js";import{u as f,a as g,n as N,S as v,B as m}from"./index-L-Mx2Hle.js";import{f as y}from"./content-BrFHUpp8.js";import{A as k}from"./ui-vendor-DGgNN2YO.js";function S(){const a=j(),{user:t,loading:i}=f(),[r,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!i){if(!t){a("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>N.error(e.message)).finally(()=>x(!1))}},[t,i,a]),i||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(v,{size:"lg"})}):t?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(m,{variant:"ghost",className:"mb-3",onClick:()=>a("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",r.length," 篇收藏帖子"]}),r.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(m,{onClick:()=>a("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:r.map(e=>{var o,l,c,d,p;return s.jsx("div",{className:"post-row",onClick:()=>a(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((p=(d=e.post)==null?void 0:d.user)==null?void 0:p.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{S as default};
import{e as j,r as n,j as s}from"./react-vendor-CFzzFOsg.js";import{u as f,a as g,n as N,S as v,B as m}from"./index-5C0q-ZMs.js";import{f as y}from"./content-BrFHUpp8.js";import{A as k}from"./ui-vendor-BtpOfToU.js";function S(){const a=j(),{user:t,loading:i}=f(),[r,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!i){if(!t){a("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>N.error(e.message)).finally(()=>x(!1))}},[t,i,a]),i||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(v,{size:"lg"})}):t?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(m,{variant:"ghost",className:"mb-3",onClick:()=>a("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",r.length," 篇收藏帖子"]}),r.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(m,{onClick:()=>a("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:r.map(e=>{var o,l,c,d,p;return s.jsx("div",{className:"post-row",onClick:()=>a(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((p=(d=e.post)==null?void 0:d.user)==null?void 0:p.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{S as default};

View File

@@ -1 +1 @@
import{e as f,r as g,j as s,L as b}from"./react-vendor-CFzzFOsg.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,f as i,o as N,s as m}from"./form-xbbJUieP.js";import{u as v,B as L,a as S,n as d}from"./index-L-Mx2Hle.js";import{I as u}from"./input-CIPYpxcv.js";import"./ui-vendor-DGgNN2YO.js";const C=N({username:m().min(1,"请输入用户名"),password:m().min(1,"请输入密码")});function z(){const x=f(),{refresh:p}=v(),[h,a]=g.useState(!1),r=w({resolver:F(C),defaultValues:{username:"",password:""}}),j=async e=>{a(!0);try{await S.login(e.username,e.password),await p(),d.success("登录成功"),x("/",{replace:!0})}catch(o){d.error(o instanceof Error?o.message:"登录失败")}finally{a(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"登录姜十三论坛"}),s.jsx("p",{className:"subtitle",children:"拾三一隅,自在交流"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(j),className:"space-y-4",children:[s.jsx(t,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"用户名"}),s.jsx(c,{children:s.jsx(u,{placeholder:"用户名",autoComplete:"username",...e})}),s.jsx(i,{})]})}),s.jsx(t,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"密码"}),s.jsx(c,{children:s.jsx(u,{type:"password",placeholder:"密码",autoComplete:"current-password",...e})}),s.jsx(i,{})]})}),s.jsx(L,{type:"submit",className:"w-full",loading:h,children:"登录"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["没有账号?",s.jsx(b,{to:"/register",children:"注册"})]})]})})}export{z as default};
import{e as f,r as g,j as s,L as b}from"./react-vendor-CFzzFOsg.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,f as i,o as N,s as m}from"./form-OSLy-cSS.js";import{u as v,B as L,a as S,n as d}from"./index-5C0q-ZMs.js";import{I as u}from"./input-CE0PJTgJ.js";import"./ui-vendor-BtpOfToU.js";const C=N({username:m().min(1,"请输入用户名"),password:m().min(1,"请输入密码")});function z(){const x=f(),{refresh:p}=v(),[h,a]=g.useState(!1),r=w({resolver:F(C),defaultValues:{username:"",password:""}}),j=async e=>{a(!0);try{await S.login(e.username,e.password),await p(),d.success("登录成功"),x("/",{replace:!0})}catch(o){d.error(o instanceof Error?o.message:"登录失败")}finally{a(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"登录姜十三论坛"}),s.jsx("p",{className:"subtitle",children:"拾三一隅,自在交流"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(j),className:"space-y-4",children:[s.jsx(t,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"用户名"}),s.jsx(c,{children:s.jsx(u,{placeholder:"用户名",autoComplete:"username",...e})}),s.jsx(i,{})]})}),s.jsx(t,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"密码"}),s.jsx(c,{children:s.jsx(u,{type:"password",placeholder:"密码",autoComplete:"current-password",...e})}),s.jsx(i,{})]})}),s.jsx(L,{type:"submit",className:"w-full",loading:h,children:"登录"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["没有账号?",s.jsx(b,{to:"/register",children:"注册"})]})]})})}export{z as default};

View File

@@ -1 +1 @@
import{j as n}from"./react-vendor-CFzzFOsg.js";import{c as i}from"./index-L-Mx2Hle.js";import{N as s}from"./ui-vendor-DGgNN2YO.js";function m({className:o,size:r=16}){return n.jsx(s,{className:i("post-pinned-icon",o),size:r,fill:"currentColor","aria-label":"置顶",role:"img"})}export{m as P};
import{j as n}from"./react-vendor-CFzzFOsg.js";import{c as i}from"./index-5C0q-ZMs.js";import{N as s}from"./ui-vendor-BtpOfToU.js";function m({className:o,size:r=16}){return n.jsx(s,{className:i("post-pinned-icon",o),size:r,fill:"currentColor","aria-label":"置顶",role:"img"})}export{m as P};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{e as f,r as g,j as s,L as w}from"./react-vendor-CFzzFOsg.js";import{u as b,a as F,F as y,b as a,c as n,d as o,e as t,f as c,o as k,s as l}from"./form-xbbJUieP.js";import{u as N,B as S,a as v,n as x}from"./index-L-Mx2Hle.js";import{I as i}from"./input-CIPYpxcv.js";import"./ui-vendor-DGgNN2YO.js";const C=k({username:l().min(1,"请输入用户名"),nickname:l().optional(),password:l().min(6,"密码至少 6 位")});function R(){const u=f(),{refresh:j}=N(),[p,m]=g.useState(!1),r=b({resolver:F(C),defaultValues:{username:"",nickname:"",password:""}}),h=async e=>{m(!0);try{await v.register(e.username,e.password,e.nickname||e.username),await j(),x.success("注册成功"),u("/",{replace:!0})}catch(d){x.error(d instanceof Error?d.message:"注册失败")}finally{m(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"注册账号"}),s.jsx("p",{className:"subtitle",children:"首个注册用户自动成为管理员"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(h),className:"space-y-4",children:[s.jsx(a,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"用户名"}),s.jsx(t,{children:s.jsx(i,{placeholder:"3-32 位字母数字下划线",autoComplete:"username",...e})}),s.jsx(c,{})]})}),s.jsx(a,{control:r.control,name:"nickname",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"昵称"}),s.jsx(t,{children:s.jsx(i,{placeholder:"显示名称(可选)",autoComplete:"nickname",...e})}),s.jsx(c,{})]})}),s.jsx(a,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"密码"}),s.jsx(t,{children:s.jsx(i,{type:"password",placeholder:"至少 6 位",autoComplete:"new-password",...e})}),s.jsx(c,{})]})}),s.jsx(S,{type:"submit",className:"w-full",loading:p,children:"注册"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["已有账号?",s.jsx(w,{to:"/login",children:"登录"})]})]})})}export{R as default};

View File

@@ -0,0 +1 @@
import{e as g,r as w,j as s,L as F}from"./react-vendor-CFzzFOsg.js";import{u as b,a as y,F as k,b as n,c as o,d as t,e as i,f as l,o as N,s as c}from"./form-OSLy-cSS.js";import{d as S,u as v,B as C,a as L,n as x}from"./index-5C0q-ZMs.js";import{I as m}from"./input-CE0PJTgJ.js";import"./ui-vendor-BtpOfToU.js";const _=r=>N({username:c().min(1,"请输入用户名"),nickname:c().optional(),password:c().min(r,`密码至少 ${r}`)});function $(){const{limits:r}=S(),p=g(),{refresh:j}=v(),[h,d]=w.useState(!1),a=b({resolver:y(_(r.password_min_len)),defaultValues:{username:"",nickname:"",password:""}}),f=async e=>{d(!0);try{await L.register(e.username,e.password,e.nickname||e.username),await j(),x.success("注册成功"),p("/",{replace:!0})}catch(u){x.error(u instanceof Error?u.message:"注册失败")}finally{d(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"注册账号"}),s.jsx("p",{className:"subtitle",children:"首个注册用户自动成为管理员"}),s.jsx(k,{...a,children:s.jsxs("form",{onSubmit:a.handleSubmit(f),className:"space-y-4",children:[s.jsx(n,{control:a.control,name:"username",render:({field:e})=>s.jsxs(o,{children:[s.jsx(t,{children:"用户名"}),s.jsx(i,{children:s.jsx(m,{placeholder:"3-32 位字母数字下划线",autoComplete:"username",...e})}),s.jsx(l,{})]})}),s.jsx(n,{control:a.control,name:"nickname",render:({field:e})=>s.jsxs(o,{children:[s.jsx(t,{children:"昵称"}),s.jsx(i,{children:s.jsx(m,{placeholder:"显示名称(可选)",autoComplete:"nickname",...e})}),s.jsx(l,{})]})}),s.jsx(n,{control:a.control,name:"password",render:({field:e})=>s.jsxs(o,{children:[s.jsx(t,{children:"密码"}),s.jsx(i,{children:s.jsx(m,{type:"password",placeholder:`至少 ${r.password_min_len}`,autoComplete:"new-password",...e})}),s.jsx(l,{})]})}),s.jsx(C,{type:"submit",className:"w-full",loading:h,children:"注册"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["已有账号?",s.jsx(F,{to:"/login",children:"登录"})]})]})})}export{$ as default};

View File

@@ -0,0 +1 @@
import{r as l,j as s}from"./react-vendor-CFzzFOsg.js";import{ad as g,ae as x,af as N,ag as i,ah as d,ai as r,aj as n,ak as m,al as c}from"./ui-vendor-BtpOfToU.js";import{c as o,g as f}from"./index-5C0q-ZMs.js";const C=g,T=x,y=N,p=l.forwardRef(({className:a,...e},t)=>s.jsx(c,{className:o("fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",a),...e,ref:t}));p.displayName=c.displayName;const u=l.forwardRef(({className:a,...e},t)=>s.jsxs(y,{children:[s.jsx(p,{}),s.jsx(i,{ref:t,className:o("fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",a),...e})]}));u.displayName=i.displayName;const A=({className:a,...e})=>s.jsx("div",{className:o("flex flex-col space-y-2 text-center sm:text-left",a),...e});A.displayName="AlertDialogHeader";const D=({className:a,...e})=>s.jsx("div",{className:o("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",a),...e});D.displayName="AlertDialogFooter";const j=l.forwardRef(({className:a,...e},t)=>s.jsx(d,{ref:t,className:o("text-lg font-semibold",a),...e}));j.displayName=d.displayName;const w=l.forwardRef(({className:a,...e},t)=>s.jsx(r,{ref:t,className:o("text-sm text-muted-foreground",a),...e}));w.displayName=r.displayName;const b=l.forwardRef(({className:a,...e},t)=>s.jsx(m,{ref:t,className:o(f(),a),...e}));b.displayName=m.displayName;const R=l.forwardRef(({className:a,...e},t)=>s.jsx(n,{ref:t,className:o(f({variant:"outline"}),"mt-2 sm:mt-0",a),...e}));R.displayName=n.displayName;export{C as A,T as a,u as b,A as c,j as d,w as e,D as f,R as g,b as h};

View File

@@ -1 +0,0 @@
import{r as l,j as s}from"./react-vendor-CFzzFOsg.js";import{ad as g,ae as x,af as N,ag as i,ah as d,ai as r,aj as n,ak as m,al as c}from"./ui-vendor-DGgNN2YO.js";import{c as o,f}from"./index-L-Mx2Hle.js";const C=g,T=x,y=N,p=l.forwardRef(({className:a,...e},t)=>s.jsx(c,{className:o("fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",a),...e,ref:t}));p.displayName=c.displayName;const u=l.forwardRef(({className:a,...e},t)=>s.jsxs(y,{children:[s.jsx(p,{}),s.jsx(i,{ref:t,className:o("fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",a),...e})]}));u.displayName=i.displayName;const A=({className:a,...e})=>s.jsx("div",{className:o("flex flex-col space-y-2 text-center sm:text-left",a),...e});A.displayName="AlertDialogHeader";const D=({className:a,...e})=>s.jsx("div",{className:o("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",a),...e});D.displayName="AlertDialogFooter";const j=l.forwardRef(({className:a,...e},t)=>s.jsx(d,{ref:t,className:o("text-lg font-semibold",a),...e}));j.displayName=d.displayName;const w=l.forwardRef(({className:a,...e},t)=>s.jsx(r,{ref:t,className:o("text-sm text-muted-foreground",a),...e}));w.displayName=r.displayName;const b=l.forwardRef(({className:a,...e},t)=>s.jsx(m,{ref:t,className:o(f(),a),...e}));b.displayName=m.displayName;const R=l.forwardRef(({className:a,...e},t)=>s.jsx(n,{ref:t,className:o(f({variant:"outline"}),"mt-2 sm:mt-0",a),...e}));R.displayName=n.displayName;export{C as A,T as a,u as b,A as c,j as d,w as e,D as f,R as g,b as h};

View File

@@ -1 +1 @@
import{j as n}from"./react-vendor-CFzzFOsg.js";import{c as a,e as o}from"./index-L-Mx2Hle.js";const s=o("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground shadow",secondary:"border-transparent bg-secondary text-secondary-foreground",destructive:"border-transparent bg-destructive text-destructive-foreground shadow",outline:"text-foreground",green:"border-transparent bg-[var(--j13-green-bg)] text-[var(--j13-green)]",orange:"border-transparent bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300"}},defaultVariants:{variant:"default"}});function g({className:r,variant:e,...t}){return n.jsx("div",{className:a(s({variant:e}),r),...t})}export{g as B};
import{j as n}from"./react-vendor-CFzzFOsg.js";import{c as a,f as o}from"./index-5C0q-ZMs.js";const s=o("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground shadow",secondary:"border-transparent bg-secondary text-secondary-foreground",destructive:"border-transparent bg-destructive text-destructive-foreground shadow",outline:"text-foreground",green:"border-transparent bg-[var(--j13-green-bg)] text-[var(--j13-green)]",orange:"border-transparent bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300"}},defaultVariants:{variant:"default"}});function g({className:r,variant:e,...t}){return n.jsx("div",{className:a(s({variant:e}),r),...t})}export{g as B};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{r as t,j as i}from"./react-vendor-CFzzFOsg.js";import{c as n}from"./index-L-Mx2Hle.js";const a=t.forwardRef(({className:e,type:o,...r},s)=>i.jsx("input",{type:o,className:n("flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",e),ref:s,...r}));a.displayName="Input";export{a as I};
import{r as t,j as i}from"./react-vendor-CFzzFOsg.js";import{c as n}from"./index-5C0q-ZMs.js";const a=t.forwardRef(({className:e,type:o,...r},s)=>i.jsx("input",{type:o,className:n("flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",e),ref:s,...r}));a.displayName="Input";export{a as I};

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{p as l}from"./markdown-vendor-DxR1h-Bq.js";const m={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
import{p as l}from"./purify-vendor-Cx4rtWv4.js";const m={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
<div class="post-members-only__badge">
<span class="post-members-only__badge-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{r as s,j as a}from"./react-vendor-CFzzFOsg.js";import{c as t}from"./index-5C0q-ZMs.js";const i=s.forwardRef(({className:e,...r},o)=>a.jsx("textarea",{className:t("flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",e),ref:o,...r}));i.displayName="Textarea";export{i as T};

View File

@@ -27,10 +27,10 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<script type="module" crossorigin src="/assets/index-L-Mx2Hle.js"></script>
<script type="module" crossorigin src="/assets/index-5C0q-ZMs.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-vendor-CFzzFOsg.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DGgNN2YO.js">
<link rel="stylesheet" crossorigin href="/assets/index-7vBz8mtL.css">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BtpOfToU.js">
<link rel="stylesheet" crossorigin href="/assets/index-DG22moG5.css">
</head>
<body>
<div id="root"></div>

View File

@@ -31,9 +31,7 @@
"diff": "^9.0.0",
"dompurify": "^3.4.10",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",
"postcss": "^8.5.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.79.0",
"react-router-dom": "^6.28.0",
@@ -3181,18 +3179,6 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/marked": {
"version": "18.0.5",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3655,6 +3641,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},

View File

@@ -32,9 +32,7 @@
"diff": "^9.0.0",
"dompurify": "^3.4.10",
"lucide-react": "^1.18.0",
"marked": "^18.0.5",
"postcss": "^8.5.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.79.0",
"react-router-dom": "^6.28.0",

View File

@@ -1,4 +1,4 @@
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, PostDetailResponse, PostRevision } from './types';
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision } from './types';
const BASE = '';
@@ -24,13 +24,17 @@ async function request<T>(url: string, opts: RequestInit = {}): Promise<T> {
export const api = {
me: () => request<{ user: User | null }>('/api/me'),
stats: () => request<ForumStats>('/api/stats'),
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
boards: () => request<{ boards: Board[] }>('/api/boards'),
posts: (params: Record<string, string | number>) => {
const q = new URLSearchParams(params as Record<string, string>).toString();
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
},
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
post: (id: number) => request<PostDetailResponse>(`/api/posts/${id}`),
post: (id: number, opts?: { skipView?: boolean }) => {
const q = opts?.skipView ? '?skip_view=1' : '';
return request<PostDetailResponse>(`/api/posts/${id}${q}`);
},
comments: (id: number, myIds?: number[]) => {
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
@@ -64,10 +68,14 @@ export const api = {
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
method: 'POST', body: JSON.stringify({ locked }),
}),
adminUpdateForumSettings: (body: { post_edit_window_hours: number }) =>
request<{ message: string; post_edit_window_hours: number }>('/api/admin/settings/forum', {
adminUpdateForumSettings: (body: ForumLimits) =>
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
method: 'PUT', body: JSON.stringify(body),
}),
adminUpdateFilterWords: (content: string) =>
request<{ message: string; word_count: number }>('/api/admin/settings/filter-words', {
method: 'PUT', body: JSON.stringify({ content }),
}),
postRevisions: (id: number) =>
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
postRevision: (id: number, revId: number) =>
@@ -104,6 +112,11 @@ export const api = {
fd.append('avatar', file);
return request<{ avatar: string }>('/api/profile/avatar', { method: 'POST', body: fd, headers: {} });
},
uploadPostImage: (file: File) => {
const fd = new FormData();
fd.append('image', file);
return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} });
},
createPost: (data: { board_id: string; title: string; content: string; tags?: string }) => {
const fd = new FormData();
fd.append('board_id', data.board_id);

View File

@@ -90,12 +90,44 @@ export interface AdminDashboard {
recent_posts: PostItem[];
}
export interface ForumLimits {
post_edit_window_hours: number;
rate_limit_post: number;
rate_limit_comment: number;
rate_limit_register: number;
rate_limit_login: number;
rate_limit_window_sec: number;
post_title_max: number;
post_tags_max: number;
post_content_max: number;
comment_max: number;
search_keyword_min: number;
search_keyword_max: number;
page_size_default: number;
page_size_max: number;
password_min_len: number;
avatar_max_mb: number;
}
export interface ForumLimitsPublic {
post_title_max: number;
post_tags_max: number;
post_content_max: number;
comment_max: number;
search_keyword_min: number;
search_keyword_max: number;
password_min_len: number;
avatar_max_mb: number;
}
export interface AdminSettings {
filter_path: string;
data_dir: string;
db_path: string;
port: number;
post_edit_window_hours: number;
limits: ForumLimits;
filter_words: string;
filter_word_count: number;
}
export interface Paginated<T> {

View File

@@ -13,7 +13,9 @@ import {
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
} from 'lucide-react';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { countWords } from '../utils/markdown';
import { countWords } from '../utils/text';
import { api } from '../api/client';
import { notify } from '@/lib/notify';
import { MembersOnly } from './editor/MembersOnlyExtension';
export interface ArticleEditorHandle {
@@ -134,9 +136,20 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const setImage = useCallback(() => {
if (!editor) return;
const url = window.prompt('图片地址');
if (!url?.trim()) return;
editor.chain().focus().setImage({ src: url.trim() }).run();
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
const { url } = await api.uploadPostImage(file);
editor.chain().focus().setImage({ src: url }).run();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
}
};
input.click();
}, [editor]);
const wrapMembersOnly = useCallback(() => {
@@ -213,7 +226,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
},
{
icon: <ImageIcon size={15} />,
title: '图片',
title: '上传图片',
action: setImage,
},
{

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { ForumLimitsPublic } from '../api/types';
const DEFAULT_LIMITS: ForumLimitsPublic = {
post_title_max: 128,
post_tags_max: 256,
post_content_max: 50000,
comment_max: 5000,
search_keyword_min: 1,
search_keyword_max: 50,
password_min_len: 6,
avatar_max_mb: 2,
};
let cached: ForumLimitsPublic | null = null;
let inflight: Promise<ForumLimitsPublic> | null = null;
function fetchLimits(): Promise<ForumLimitsPublic> {
if (cached) return Promise.resolve(cached);
if (inflight) return inflight;
inflight = api.forumLimits()
.then(limits => {
cached = limits;
return limits;
})
.catch(() => DEFAULT_LIMITS)
.finally(() => { inflight = null; });
return inflight;
}
/** 获取前台可见的论坛限制配置 */
export function useForumLimits() {
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
const [loading, setLoading] = useState(!cached);
useEffect(() => {
fetchLimits().then(setLimits).finally(() => setLoading(false));
}, []);
return { limits, loading };
}
export function invalidateForumLimitsCache() {
cached = null;
}

View File

@@ -16,7 +16,9 @@ import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel';
import { useForumLimits } from '../hooks/useForumLimits';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { notify } from '@/lib/notify';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
@@ -36,6 +38,7 @@ export default function MainLayout() {
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
@@ -85,8 +88,21 @@ export default function MainLayout() {
}, [refreshBoards, refreshOnline]);
const doSearch = () => {
if (keyword.trim()) nav(`/?keyword=${encodeURIComponent(keyword.trim())}`);
else nav('/');
const kw = keyword.trim();
if (!kw) {
nav('/');
return;
}
const len = [...kw].length;
if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
return;
}
if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
return;
}
nav(`/?keyword=${encodeURIComponent(kw)}`);
};
const userInitial = user?.nickname?.charAt(0) || '?';
@@ -111,6 +127,7 @@ export default function MainLayout() {
placeholder="搜索帖子..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
onKeyDown={e => e.key === 'Enter' && doSearch()}
/>
{keyword && (

View File

@@ -6,6 +6,7 @@ import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
import { isHtmlEmpty } from '../utils/postContent';
import { useForumLimits } from '../hooks/useForumLimits';
import ArticleEditor from '../components/ArticleEditor';
import { Spinner } from '@/components/ui/spinner';
@@ -17,6 +18,7 @@ export default function ComposePage() {
const [params] = useSearchParams();
const defaultBoard = params.get('board') || '';
const { user, loading: authLoading } = useAuth();
const { limits } = useForumLimits();
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState(defaultBoard);
@@ -32,7 +34,7 @@ export default function ComposePage() {
if (isEdit) {
setLoading(true);
Promise.all([api.boards(), api.post(editId!)])
Promise.all([api.boards(), api.post(editId!, { skipView: true })])
.then(([boardsData, postData]) => {
const list = boardsData.boards ?? [];
setBoards(list);
@@ -187,7 +189,7 @@ export default function ComposePage() {
placeholder="添加标签,逗号分隔"
value={tags}
onChange={e => setTags(e.target.value)}
maxLength={128}
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/>
</div>
</div>
@@ -199,7 +201,7 @@ export default function ComposePage() {
placeholder="输入文章标题…"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={256}
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
/>
{currentBoard && (
<div className="compose-subtitle">

View File

@@ -12,14 +12,15 @@ import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client';
import { useForumLimits } from '../hooks/useForumLimits';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
});
const pwdSchema = z.object({
const pwdSchema = (minLen: number) => z.object({
old_password: z.string().min(1, '请输入当前密码'),
new_password: z.string().min(6, '新密码至少 6 位'),
new_password: z.string().min(minLen, `新密码至少 ${minLen}`),
confirm_password: z.string().min(1, '请确认新密码'),
}).refine(d => d.new_password === d.confirm_password, {
message: '两次输入的新密码不一致',
@@ -27,7 +28,7 @@ const pwdSchema = z.object({
});
type NickValues = z.infer<typeof nickSchema>;
type PwdValues = z.infer<typeof pwdSchema>;
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
export default function ProfilePage() {
const nav = useNavigate();
@@ -37,13 +38,15 @@ export default function ProfilePage() {
const [avatarLoading, setAvatarLoading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const { limits } = useForumLimits();
const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema),
values: { nickname: user?.nickname ?? '' },
});
const pwdForm = useForm<PwdValues>({
resolver: zodResolver(pwdSchema),
resolver: zodResolver(pwdSchema(limits.password_min_len)),
defaultValues: { old_password: '', new_password: '', confirm_password: '' },
});
@@ -90,8 +93,8 @@ export default function ProfilePage() {
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
notify.error('头像不能超过 2MB');
if (file.size > limits.avatar_max_mb * 1024 * 1024) {
notify.error(`头像不能超过 ${limits.avatar_max_mb}MB`);
return;
}
setAvatarLoading(true);

View File

@@ -8,22 +8,24 @@ import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useForumLimits } from '../hooks/useForumLimits';
import { useAuth } from '../hooks/useAuth';
const schema = z.object({
const schema = (minLen: number) => z.object({
username: z.string().min(1, '请输入用户名'),
nickname: z.string().optional(),
password: z.string().min(6, '密码至少 6 位'),
password: z.string().min(minLen, `密码至少 ${minLen}`),
});
type FormValues = z.infer<typeof schema>;
type FormValues = z.infer<ReturnType<typeof schema>>;
export default function RegisterPage() {
const { limits } = useForumLimits();
const nav = useNavigate();
const { refresh } = useAuth();
const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
resolver: zodResolver(schema(limits.password_min_len)),
defaultValues: { username: '', nickname: '', password: '' },
});
@@ -82,7 +84,7 @@ export default function RegisterPage() {
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="至少 6 位" autoComplete="new-password" {...field} />
<Input type="password" placeholder={`至少 ${limits.password_min_len}`} autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -15,6 +15,16 @@ import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { PostItem } from '../../api/types';
import { clearAllFeedCache } from '../../utils/feedCache';
import { isTimeDiffSignificant } from '../../utils/content';
function formatAdminTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
export default function AdminPostsPage() {
const nav = useNavigate();
@@ -80,7 +90,7 @@ export default function AdminPostsPage() {
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
<p></p>
</div>
<form
@@ -90,7 +100,7 @@ export default function AdminPostsPage() {
<Input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索帖子标题…"
placeholder="搜索标题、标签或正文…"
/>
<Button type="submit"><Search size={16} /></Button>
{search && (
@@ -112,6 +122,8 @@ export default function AdminPostsPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@@ -121,21 +133,33 @@ export default function AdminPostsPage() {
</tr>
</thead>
<tbody>
{posts.map(p => (
{posts.map(p => {
const edited = p.updated_at && isTimeDiffSignificant(p.created_at, p.updated_at);
return (
<tr key={p.id}>
<td>{p.id}</td>
<td className="max-w-[220px] truncate">
<td className="max-w-[200px] truncate">
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
{p.title}
</button>
{edited && <Badge variant="secondary" className="ml-1"></Badge>}
</td>
<td>{p.board?.name ?? '—'}</td>
<td>{p.user?.nickname ?? '—'}</td>
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
<td>{p.comment_count ?? 0}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{p.edit_locked ? <Badge variant="destructive"></Badge> : '—'}</td>
<td>{p.like_count}</td>
<td>{p.view_count}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
<td className="text-sm whitespace-nowrap">
<span>{formatAdminTime(p.created_at)}</span>
{edited && p.updated_at && (
<span className="block text-muted-foreground text-xs">
{formatAdminTime(p.updated_at)}
</span>
)}
</td>
<td>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
@@ -162,7 +186,8 @@ export default function AdminPostsPage() {
</div>
</td>
</tr>
))}
);
})}
</tbody>
</table>
{posts.length === 0 && <div className="admin-empty"></div>}

View File

@@ -1,42 +1,173 @@
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Database, Shield, Server, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminSettings } from '../../api/types';
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
import type { AdminSettings, ForumLimits } from '../../api/types';
type TabId = 'limits' | 'filter' | 'system';
type SettingRow = {
key: keyof ForumLimits;
label: string;
unit?: string;
hint?: string;
min?: number;
};
type SettingSection = {
id: string;
title: string;
summary: string;
rows: SettingRow[];
};
const SETTING_SECTIONS: SettingSection[] = [
{
id: 'rule',
title: '编辑规则',
summary: '控制普通用户修改自己帖子的时限',
rows: [
{ key: 'post_edit_window_hours', label: '可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
],
},
{
id: 'rate',
title: '操作限流',
summary: '同一用户或 IP 在窗口期内的最大请求次数',
rows: [
{ key: 'rate_limit_window_sec', label: '限流窗口', unit: '秒', min: 10 },
{ key: 'rate_limit_post', label: '发帖', unit: '次', min: 1 },
{ key: 'rate_limit_comment', label: '评论', unit: '次', min: 1 },
{ key: 'rate_limit_register', label: '注册', unit: '次', min: 1 },
{ key: 'rate_limit_login', label: '登录', unit: '次', min: 1 },
],
},
{
id: 'content',
title: '内容长度',
summary: '发帖与评论的字数上限,服务端强制校验',
rows: [
{ key: 'post_title_max', label: '帖子标题', unit: '字', min: 1 },
{ key: 'post_tags_max', label: '帖子标签', unit: '字', hint: '0 = 不限', min: 0 },
{ key: 'post_content_max', label: '帖子正文', unit: '字', hint: '0 = 不限', min: 0 },
{ key: 'comment_max', label: '评论内容', unit: '字', min: 1 },
],
},
{
id: 'search',
title: '搜索与列表',
summary: '关键词长度与帖子列表分页',
rows: [
{ key: 'search_keyword_min', label: '关键词最短', unit: '字', min: 0 },
{ key: 'search_keyword_max', label: '关键词最长', unit: '字', min: 1 },
{ key: 'page_size_default', label: '默认每页', unit: '条', min: 1 },
{ key: 'page_size_max', label: '最大每页', unit: '条', min: 1 },
],
},
{
id: 'user',
title: '用户账号',
summary: '注册、改密与头像上传限制',
rows: [
{ key: 'password_min_len', label: '密码最短', unit: '位', min: 4 },
{ key: 'avatar_max_mb', label: '头像上限', unit: 'MB', min: 1 },
],
},
];
const TABS: { id: TabId; label: string; icon: typeof SlidersHorizontal }[] = [
{ id: 'limits', label: '论坛限制', icon: SlidersHorizontal },
{ id: 'filter', label: '敏感词', icon: Shield },
{ id: 'system', label: '系统维护', icon: Server },
];
function SettingTable({
sections,
limits,
onChange,
}: {
sections: SettingSection[];
limits: ForumLimits;
onChange: (key: keyof ForumLimits, value: string) => void;
}) {
return (
<div className="admin-settings-sections">
{sections.map(section => (
<section key={section.id} className="admin-settings-section" id={`settings-${section.id}`}>
<div className="admin-settings-section-head">
<h3>{section.title}</h3>
<p>{section.summary}</p>
</div>
<div className="admin-settings-table" role="group" aria-label={section.title}>
{section.rows.map(row => (
<div key={row.key} className="admin-settings-row">
<label htmlFor={`limit-${row.key}`} className="admin-settings-row-label">
{row.label}
</label>
<div className="admin-settings-row-input">
<Input
id={`limit-${row.key}`}
type="number"
min={row.min ?? 0}
value={limits[row.key]}
onChange={e => onChange(row.key, e.target.value)}
className="admin-settings-input"
/>
{row.unit && <span className="admin-settings-unit">{row.unit}</span>}
</div>
<span className="admin-settings-row-hint">{row.hint ?? ''}</span>
</div>
))}
</div>
</section>
))}
</div>
);
}
export default function AdminSettingsPage() {
const { ready } = useAdminGuard();
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [limits, setLimits] = useState<ForumLimits | null>(null);
const [filterWords, setFilterWords] = useState('');
const [activeTab, setActiveTab] = useState<TabId>('limits');
const [loading, setLoading] = useState(true);
const [backing, setBacking] = useState(false);
const [editWindowHours, setEditWindowHours] = useState('24');
const [savingForum, setSavingForum] = useState(false);
const [savingFilter, setSavingFilter] = useState(false);
useEffect(() => {
if (!ready) return;
api.adminSettings()
.then(s => {
setSettings(s);
setEditWindowHours(String(s.post_edit_window_hours ?? 24));
setLimits(s.limits);
setFilterWords(s.filter_words);
})
.finally(() => setLoading(false));
}, [ready]);
const handleLimitChange = (key: keyof ForumLimits, value: string) => {
const n = parseInt(value, 10);
if (Number.isNaN(n)) return;
setLimits(prev => prev ? { ...prev, [key]: n } : prev);
};
const handleSaveForumSettings = async () => {
const hours = parseInt(editWindowHours, 10);
if (Number.isNaN(hours) || hours < 0) {
notify.warning('请输入有效的小时数0 表示不限)');
return;
}
if (!limits) return;
setSavingForum(true);
try {
const r = await api.adminUpdateForumSettings({ post_edit_window_hours: hours });
const r = await api.adminUpdateForumSettings(limits);
notify.success(r.message);
setSettings(s => s ? { ...s, post_edit_window_hours: r.post_edit_window_hours } : s);
invalidateForumLimitsCache();
setLimits(r.limits);
setSettings(s => s ? { ...s, limits: r.limits } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
@@ -44,6 +175,19 @@ export default function AdminSettingsPage() {
}
};
const handleSaveFilterWords = async () => {
setSavingFilter(true);
try {
const r = await api.adminUpdateFilterWords(filterWords);
notify.success(r.message);
setSettings(s => s ? { ...s, filter_words: filterWords, filter_word_count: r.word_count } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingFilter(false);
}
};
const handleBackup = async () => {
setBacking(true);
try {
@@ -60,50 +204,112 @@ export default function AdminSettingsPage() {
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!settings) return null;
if (!settings || !limits) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<div className="admin-settings-page">
<header className="admin-page-head">
<h1></h1>
<p></p>
</div>
<p></p>
</header>
<div className="admin-card">
<div className="admin-card-head"></div>
<p className="admin-card-desc">
0
</p>
<div className="admin-form-row">
<label htmlFor="edit-window-hours"></label>
<Input
id="edit-window-hours"
type="number"
min={0}
value={editWindowHours}
onChange={e => setEditWindowHours(e.target.value)}
className="max-w-[120px]"
/>
<nav className="admin-settings-tabs" aria-label="设置分类">
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
className={`admin-settings-tab${activeTab === id ? ' active' : ''}`}
onClick={() => setActiveTab(id)}
aria-selected={activeTab === id}
>
<Icon size={15} />
{label}
</button>
))}
</nav>
{activeTab === 'limits' && (
<div className="admin-settings-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span></span>
<span className="admin-settings-card-badge"> {SETTING_SECTIONS.length} </span>
</div>
<div className="admin-card-body">
<SettingTable sections={SETTING_SECTIONS} limits={limits} onChange={handleLimitChange} />
</div>
</div>
<div className="admin-settings-bar">
<p></p>
<Button onClick={handleSaveForumSettings} loading={savingForum}>
</Button>
</div>
</div>
)}
<div className="admin-card">
{activeTab === 'filter' && (
<div className="admin-settings-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span></span>
<span className="admin-settings-card-badge">{settings.filter_word_count} </span>
</div>
<div className="admin-card-body admin-settings-filter-body">
<p className="admin-settings-filter-tip">
<code>#</code> <code>filter_words.txt</code>
</p>
<Textarea
rows={16}
value={filterWords}
onChange={e => setFilterWords(e.target.value)}
className="admin-settings-filter-textarea"
spellCheck={false}
placeholder={'# 示例\n违禁词\n广告刷单'}
/>
</div>
</div>
<div className="admin-settings-bar">
<p> *</p>
<Button onClick={handleSaveFilterWords} loading={savingFilter}>
</Button>
</div>
</div>
)}
{activeTab === 'system' && (
<div className="admin-settings-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head"></div>
<dl className="admin-dl">
<dt></dt><dd><code>{settings.data_dir}</code></dd>
<dt></dt><dd><code>{settings.db_path}</code></dd>
<dt></dt><dd><code>{settings.filter_path}</code></dd>
<dt></dt><dd>{settings.port}</dd>
<div className="admin-card-body">
<dl className="admin-settings-info">
<div className="admin-settings-info-row">
<dt></dt>
<dd><code>{settings.data_dir}</code></dd>
</div>
<div className="admin-settings-info-row">
<dt></dt>
<dd><code>{settings.db_path}</code></dd>
</div>
<div className="admin-settings-info-row">
<dt></dt>
<dd><code>{settings.filter_path}</code></dd>
</div>
<div className="admin-settings-info-row">
<dt></dt>
<dd>{settings.port}</dd>
</div>
</dl>
</div>
</div>
<div className="admin-card">
<div className="admin-card admin-settings-card">
<div className="admin-card-head"></div>
<p className="admin-card-desc">
SQLite <code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
<div className="admin-card-body admin-settings-backup-body">
<p> SQLite 便</p>
<p className="admin-settings-backup-name">
<code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
</p>
<Button onClick={handleBackup} loading={backing}>
<Database size={16} />
@@ -111,5 +317,8 @@ export default function AdminSettingsPage() {
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -2988,10 +2988,10 @@ a.waline-comment-author:hover { color: var(--j13-green); }
display: inline-flex; align-items: center; gap: 6px; font-size: 13px;
color: var(--j13-green); background: none; border: none; cursor: pointer;
}
.admin-body { display: flex; min-height: calc(100vh - 56px); }
.admin-body { display: flex; height: calc(100vh - 56px); overflow: hidden; }
.admin-sidebar {
width: 200px; flex-shrink: 0; padding: 16px 10px; border-right: 1px solid var(--j13-border);
background: hsl(var(--card));
background: hsl(var(--card)); overflow-y: auto;
}
.admin-nav-item {
display: flex; align-items: center; gap: 8px; width: 100%;
@@ -3000,7 +3000,7 @@ a.waline-comment-author:hover { color: var(--j13-green); }
}
.admin-nav-item:hover { background: var(--j13-green-bg); color: var(--j13-green); }
.admin-nav-item.active { background: var(--j13-green-bg); color: var(--j13-green); font-weight: 600; }
.admin-main { flex: 1; padding: 24px; overflow: auto; }
.admin-main { flex: 1; min-width: 0; min-height: 0; padding: 24px; overflow-y: auto; }
.admin-page-head { margin-bottom: 20px; }
.admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); }
@@ -3015,13 +3015,14 @@ a.waline-comment-author:hover { color: var(--j13-green); }
.admin-stat-label { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 4px; }
.admin-card {
border: 1px solid var(--j13-border); border-radius: 10px; background: hsl(var(--card));
overflow: hidden; margin-bottom: 16px;
margin-bottom: 16px;
}
.admin-card-body { padding: 16px 20px 20px; }
.admin-card-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--j13-border); font-weight: 600; font-size: 14px;
}
.admin-card-desc { padding: 12px 16px 0; font-size: 13px; color: hsl(var(--muted-foreground)); }
.admin-card-desc { padding: 12px 16px 0; font-size: 13px; color: hsl(var(--muted-foreground)); line-height: 1.5; }
.admin-card .admin-card-desc + button { margin: 12px 16px 16px; }
.admin-form-row {
display: flex;
@@ -3032,6 +3033,189 @@ a.waline-comment-author:hover { color: var(--j13-green); }
font-size: 13px;
}
.admin-form-row label { color: hsl(var(--muted-foreground)); white-space: nowrap; }
/* ========== 系统设置页 ========== */
.admin-settings-page {
max-width: 880px;
padding-bottom: 24px;
}
.admin-settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 20px;
padding-bottom: 0;
border-bottom: 1px solid var(--j13-border);
}
.admin-settings-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
margin-bottom: -1px;
border: none;
border-bottom: 2px solid transparent;
background: none;
font-size: 13px;
font-weight: 500;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: color .15s, border-color .15s;
}
.admin-settings-tab:hover { color: hsl(var(--foreground)); }
.admin-settings-tab.active {
color: var(--j13-green);
border-bottom-color: var(--j13-green);
}
.admin-settings-panel { display: flex; flex-direction: column; gap: 0; }
.admin-settings-card { margin-bottom: 0; }
.admin-settings-card-badge {
font-size: 12px;
font-weight: 500;
color: hsl(var(--muted-foreground));
padding: 2px 8px;
border-radius: 999px;
background: hsl(var(--muted) / 0.45);
}
.admin-settings-sections { display: flex; flex-direction: column; gap: 20px; }
.admin-settings-section-head h3 {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px;
}
.admin-settings-section-head p {
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 1.45;
margin-bottom: 10px;
}
.admin-settings-table {
border: 1px solid var(--j13-border);
border-radius: 8px;
overflow: hidden;
background: hsl(var(--background));
}
.admin-settings-row {
display: grid;
grid-template-columns: 140px 140px 1fr;
align-items: center;
gap: 8px 16px;
padding: 10px 14px;
border-bottom: 1px solid var(--j13-border);
font-size: 13px;
}
.admin-settings-row:last-child { border-bottom: none; }
.admin-settings-row-label {
color: hsl(var(--foreground));
font-weight: 500;
}
.admin-settings-row-input {
display: flex;
align-items: center;
gap: 8px;
}
.admin-settings-input {
width: 88px;
height: 32px;
padding-top: 0;
padding-bottom: 0;
}
.admin-settings-unit {
font-size: 12px;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
.admin-settings-row-hint {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.admin-settings-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding: 14px 16px;
border: 1px solid var(--j13-border);
border-radius: 10px;
background: hsl(var(--card));
}
.admin-settings-bar p {
margin: 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 1.45;
max-width: 420px;
}
.admin-settings-filter-body { display: flex; flex-direction: column; gap: 12px; }
.admin-settings-filter-tip {
margin: 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
}
.admin-settings-filter-tip code,
.admin-settings-backup-name code {
font-size: 11px;
background: hsl(var(--muted));
padding: 1px 5px;
border-radius: 4px;
}
.admin-settings-filter-textarea {
min-height: 320px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
resize: vertical;
}
.admin-settings-info { display: flex; flex-direction: column; gap: 0; }
.admin-settings-info-row {
display: grid;
grid-template-columns: 88px 1fr;
gap: 12px 16px;
padding: 10px 0;
border-bottom: 1px solid var(--j13-border);
font-size: 13px;
}
.admin-settings-info-row:last-child { border-bottom: none; padding-bottom: 0; }
.admin-settings-info-row:first-child { padding-top: 0; }
.admin-settings-info-row dt { color: hsl(var(--muted-foreground)); }
.admin-settings-info-row dd { margin: 0; word-break: break-all; }
.admin-settings-info-row dd code {
font-size: 12px;
background: hsl(var(--muted));
padding: 2px 6px;
border-radius: 4px;
}
.admin-settings-backup-body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.admin-settings-backup-body > p {
margin: 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
}
.admin-settings-backup-name { font-size: 12px !important; }
@media (max-width: 640px) {
.admin-settings-row {
grid-template-columns: 1fr;
gap: 6px;
padding: 12px 14px;
}
.admin-settings-row-hint { padding-left: 0; }
.admin-settings-bar {
flex-direction: column;
align-items: stretch;
}
.admin-settings-bar p { max-width: none; }
.admin-settings-info-row { grid-template-columns: 1fr; gap: 4px; }
}
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.admin-table th, .admin-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--j13-border); }
.admin-table th { font-weight: 600; color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.3); }

View File

@@ -1,130 +0,0 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
marked.setOptions({ breaks: true, gfm: true });
const MEMBERS_BLOCK_RE = /:::members[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*:::/g;
type MdPart = { type: 'text' | 'members'; content: string };
/** 拆分普通 Markdown 与 :::members 区块 */
function splitMembersBlocks(md: string): MdPart[] {
const parts: MdPart[] = [];
let lastIndex = 0;
const re = new RegExp(MEMBERS_BLOCK_RE.source, 'g');
let match: RegExpExecArray | null;
while ((match = re.exec(md)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: md.slice(lastIndex, match.index) });
}
parts.push({ type: 'members', content: match[1] });
lastIndex = re.lastIndex;
}
if (lastIndex < md.length) {
parts.push({ type: 'text', content: md.slice(lastIndex) });
}
if (parts.length === 0) {
parts.push({ type: 'text', content: md });
}
return parts;
}
/** Markdown 转 HTML用于预览与发布 */
export function markdownToHtml(md: string): string {
const parts = splitMembersBlocks(md);
const html = parts.map(part => {
if (part.type === 'members') {
const inner = marked.parse(part.content.trim()) as string;
return `<members-only>${inner}</members-only>`;
}
return marked.parse(part.content) as string;
}).join('');
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
}
/** HTML 转 Markdown用于编辑已有帖子时回填编辑器 */
export function htmlToMarkdown(html: string): string {
if (!html.trim()) return '';
const doc = new DOMParser().parseFromString(
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
'text/html',
);
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
const inner = () => Array.from(el.childNodes).map(walk).join('');
switch (tag) {
case 'members-only': {
if (el.getAttribute('data-locked') === 'true') return '';
const body = el.querySelector('.post-members-only__body');
const content = body ? Array.from(body.childNodes).map(walk).join('') : inner();
return `:::members\n${content.trim()}\n:::\n\n`;
}
case 'br': return '\n';
case 'p': return inner().trimEnd() + '\n\n';
case 'h1': return `# ${inner().trim()}\n\n`;
case 'h2': return `## ${inner().trim()}\n\n`;
case 'h3': return `### ${inner().trim()}\n\n`;
case 'h4': return `#### ${inner().trim()}\n\n`;
case 'h5': return `##### ${inner().trim()}\n\n`;
case 'h6': return `###### ${inner().trim()}\n\n`;
case 'strong':
case 'b': return `**${inner()}**`;
case 'em':
case 'i': return `*${inner()}*`;
case 'code': return `\`${inner()}\``;
case 'pre': return `\`\`\`\n${el.textContent ?? ''}\n\`\`\`\n\n`;
case 'a': return `[${inner()}](${el.getAttribute('href') ?? ''})`;
case 'img': return `![${el.getAttribute('alt') ?? ''}](${el.getAttribute('src') ?? ''})`;
case 'ul':
return Array.from(el.children)
.map(li => `- ${walk(li).trim()}`)
.join('\n') + '\n\n';
case 'ol':
return Array.from(el.children)
.map((li, i) => `${i + 1}. ${walk(li).trim()}`)
.join('\n') + '\n\n';
case 'li': return inner();
case 'blockquote': {
const text = inner().trim().replace(/\n/g, '\n> ');
return `> ${text}\n\n`;
}
case 'hr': return '---\n\n';
case 'div':
if (el.classList.contains('post-members-only__badge')
|| el.classList.contains('post-members-only__gate')
|| el.classList.contains('post-members-only__gate-icon')) {
return '';
}
if (el.classList.contains('post-members-only__body')) {
return inner();
}
return inner();
default: return inner();
}
};
return walk(doc.body).replace(/\n{3,}/g, '\n\n').trim();
}
/** 统计正文字数(不含空白) */
export function countWords(text: string): number {
const t = text.trim();
if (!t) return 0;
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
return cjk + en;
}
/** 编辑器插入用的会员专属区块模板 */
export const MEMBERS_ONLY_TEMPLATE = ':::members\n在此输入仅登录用户可见的内容…\n:::';

View File

@@ -0,0 +1,8 @@
/** 统计正文字数CJK 按字、英文按词) */
export function countWords(text: string): number {
const t = text.trim();
if (!t) return 0;
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
return cjk + en;
}

View File

@@ -21,7 +21,7 @@ export default defineConfig({
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('marked') || id.includes('dompurify')) return 'markdown-vendor';
if (id.includes('dompurify')) return 'purify-vendor';
if (id.includes('@tanstack/react-virtual')) return 'virtual-vendor';
if (id.includes('@radix-ui') || id.includes('lucide-react')) return 'ui-vendor';
if (

View File

@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"path/filepath"
"strconv"
@@ -53,6 +54,11 @@ func (h *Handlers) APIStats(c *gin.Context) {
})
}
// APIForumLimits 前台可见的论坛限制配置
func (h *Handlers) APIForumLimits(c *gin.Context) {
c.JSON(http.StatusOK, h.Settings.PublicLimits())
}
// APIAdminCreateBoard 管理员创建板块JSON
func (h *Handlers) APIAdminCreateBoard(c *gin.Context) {
var req struct {
@@ -125,13 +131,13 @@ func (h *Handlers) APIAdminPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
keyword := strings.TrimSpace(c.Query("keyword"))
posts, total, err := h.Post.List(service.PostListQuery{Page: page, Size: size, Keyword: keyword})
posts, total, err := h.Post.ListItems(service.PostListQuery{Page: page, Size: size, Keyword: keyword})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if posts == nil {
posts = []model.Post{}
posts = []service.PostListItem{}
}
c.JSON(http.StatusOK, gin.H{
"posts": posts, "total": total, "page": page,
@@ -286,39 +292,73 @@ func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
// APIAdminSettings 系统设置信息
func (h *Handlers) APIAdminSettings(c *gin.Context) {
forum := h.Settings.ForumSettings()
limits := h.Settings.Limits()
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
c.JSON(http.StatusOK, gin.H{
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"post_edit_window_hours": forum["post_edit_window_hours"],
"limits": limits,
"filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent),
})
}
// APIAdminUpdateForumSettings 更新论坛设置
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
var req struct {
PostEditWindowHours int `json:"post_edit_window_hours"`
}
var req service.ForumLimits
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.SetPostEditWindowHours(req.PostEditWindowHours); err != nil {
if err := h.Settings.UpdateLimits(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "设置已保存",
"post_edit_window_hours": req.PostEditWindowHours,
"limits": h.Settings.Limits(),
})
}
// APIAdminFilterWords 读取敏感词配置
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取敏感词配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"content": content,
"word_count": service.CountFilterWords(content),
"path": h.Cfg.FilterWordsPath(),
})
}
// APIAdminUpdateFilterWords 更新敏感词配置
func (h *Handlers) APIAdminUpdateFilterWords(c *gin.Context) {
var req struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := service.WriteFilterWordsFile(h.Cfg.FilterWordsPath(), req.Content, h.Filter); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存敏感词配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "敏感词已保存并生效",
"word_count": service.CountFilterWords(req.Content),
})
}
// APIPosts 帖子列表(分页)
func (h *Handlers) APIPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
size, _ := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(h.Settings.PageSizeDefault())))
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
keyword := c.Query("keyword")
@@ -331,6 +371,10 @@ func (h *Handlers) APIPosts(c *gin.Context) {
}
items, total, err := h.Post.ListItems(q)
if err != nil {
if isClientLimitError(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -349,11 +393,14 @@ func (h *Handlers) APIPosts(c *gin.Context) {
// APIPostDetail 帖子详情
func (h *Handlers) APIPostDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
if c.Query("skip_view") != "1" {
h.Post.RecordView(uint(id))
}
uid := h.currentUserID(c)
if uid == 0 {
post.Content = service.RedactMembersOnlyHTML(post.Content)
@@ -381,7 +428,7 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
// APIPostComments 楼层列表
func (h *Handlers) APIPostComments(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
@@ -463,7 +510,7 @@ func (h *Handlers) APIFavorites(c *gin.Context) {
// APIPostRevisions 帖子编辑历史列表(作者或管理员)
func (h *Handlers) APIPostRevisions(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
@@ -486,7 +533,7 @@ func (h *Handlers) APIPostRevisions(c *gin.Context) {
func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
@@ -508,3 +555,8 @@ func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
func (h *Handlers) APIPing(c *gin.Context) {
h.APIPresence(c)
}
func isClientLimitError(err error) bool {
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
errors.Is(err, service.ErrSearchKeywordTooLong)
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
@@ -136,7 +137,7 @@ func (h *Handlers) PostNewPage(c *gin.Context) {
func (h *Handlers) PostEditPage(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
post, err := h.Post.GetByID(uint(id))
post, err := h.Post.FindByID(uint(id))
if err != nil || (!h.isAdmin(c) && post.UserID != h.currentUserID(c)) {
c.Redirect(http.StatusFound, "/")
return
@@ -238,6 +239,11 @@ func (h *Handlers) APIUploadAvatar(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择头像文件"})
return
}
maxBytes := int64(h.Settings.AvatarMaxMB()) * 1024 * 1024
if file.Size > maxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "头像文件过大"})
return
}
url, err := h.User.UploadAvatar(h.currentUserID(c), file, h.Cfg.UploadDir())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -246,6 +252,26 @@ func (h *Handlers) APIUploadAvatar(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "头像已更新", "avatar": url})
}
func (h *Handlers) APIUploadPostImage(c *gin.Context) {
file, err := c.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
return
}
uid := h.currentUserID(c)
url, err := service.SaveUploadedImage(
file,
h.Cfg.PostImageUploadDir(),
"/uploads/posts",
fmt.Sprintf("%d", uid),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "图片已上传", "url": url})
}
func (h *Handlers) APICreatePost(c *gin.Context) {
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
title := c.PostForm("title")

View File

@@ -142,11 +142,11 @@ func respondBanned(c *gin.Context) {
// RateLimitMiddleware 限流中间件
func RateLimitMiddleware(limiter *service.RateLimiter, action string) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.ClientIP() + ":" + action
key := c.ClientIP()
if uid, ok := c.Get(CtxUserID); ok {
key = fmt.Sprintf("%s:%d", action, uid.(uint))
key = fmt.Sprintf("%d", uid.(uint))
}
if !limiter.Allow(key) {
if !limiter.Allow(action, key) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
c.Abort()
return

View File

@@ -47,6 +47,7 @@ type Post struct {
UserID uint `gorm:"index;not null" json:"user_id"`
Title string `gorm:"size:256;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
Tags string `gorm:"size:256" json:"tags"`
Pinned bool `gorm:"default:false" json:"pinned"`
EditLocked bool `gorm:"default:false" json:"edit_locked"`

View File

@@ -3,7 +3,6 @@ package router
import (
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/config"
@@ -27,15 +26,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
_ = service.WriteDefaultFilterWords(cfg.FilterWordsPath())
filter.LoadFromFile(cfg.FilterWordsPath())
authSvc := service.NewAuthService(cfg.JWTSecret, filter)
userSvc := service.NewUserService(filter)
boardSvc := service.NewBoardService()
settingsSvc := service.NewForumSettingsService()
authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
userSvc := service.NewUserService(filter, settingsSvc)
boardSvc := service.NewBoardService()
postSvc := service.NewPostService(filter, settingsSvc)
commentSvc := service.NewCommentService(filter)
commentSvc := service.NewCommentService(filter, settingsSvc)
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
onlineSvc := service.NewOnlineService()
limiter := service.NewRateLimiter(10, time.Minute)
limiter := service.NewRateLimiter(settingsSvc)
h := &handler.Handlers{
Cfg: cfg, Auth: authSvc, User: userSvc, Board: boardSvc,
@@ -53,6 +52,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/me", h.APIMe)
pubAPI.GET("/boards", h.APIBoards)
pubAPI.GET("/stats", h.APIStats)
pubAPI.GET("/forum-limits", h.APIForumLimits)
pubAPI.GET("/posts", h.APIPosts)
pubAPI.GET("/posts/hot", h.APIHotPosts)
pubAPI.GET("/notifications", h.APINotifications)
@@ -74,6 +74,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/profile/nickname", h.APIUpdateProfile)
api.POST("/profile/password", h.APIUpdatePassword)
api.POST("/profile/avatar", h.APIUploadAvatar)
api.POST("/uploads/image", h.APIUploadPostImage)
api.POST("/posts", middleware.RateLimitMiddleware(limiter, "post"), h.APICreatePost)
api.PUT("/posts/:id", h.APIUpdatePost)
api.DELETE("/posts/:id", h.APIDeletePost)
@@ -90,6 +91,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.GET("/dashboard", h.APIAdminDashboard)
adminAPI.GET("/settings", h.APIAdminSettings)
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
adminAPI.GET("/settings/filter-words", h.APIAdminFilterWords)
adminAPI.PUT("/settings/filter-words", h.APIAdminUpdateFilterWords)
adminAPI.POST("/boards", h.APIAdminCreateBoard)
adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard)
adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard)

View File

@@ -0,0 +1,104 @@
/**
* 补全评论、点赞、置顶/锁定(帖子已存在时使用)
* 用法node scripts/seed-interactions.mjs
*/
const BASE = process.env.SEED_BASE || 'http://localhost:3000';
let cookie = '';
function captureCookie(res) {
const raw = res.headers.getSetCookie?.() ?? [];
for (const line of raw) {
const part = line.split(';')[0].trim();
if (part.startsWith('jiang13_token=')) cookie = part;
}
}
async function request(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (cookie) headers.Cookie = cookie;
if (opts.body instanceof FormData) delete headers['Content-Type'];
const res = await fetch(`${BASE}${path}`, { ...opts, headers });
captureCookie(res);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `${res.status} ${path}`);
return data;
}
async function login(username, password) {
const fd = new FormData();
fd.append('username', username);
fd.append('password', password);
await request('/api/login', { method: 'POST', body: fd });
}
async function addComment(postId, content, replyTo) {
const fd = new FormData();
fd.append('content', content);
if (replyTo) fd.append('reply_to', String(replyTo));
const data = await request(`/api/posts/${postId}/comments`, { method: 'POST', body: fd });
return data.id;
}
async function likePost(postId) {
await request(`/api/posts/${postId}/like`, { method: 'POST' });
}
async function pinPost(postId) {
await request(`/api/admin/posts/${postId}/pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned: true }),
});
}
async function lockPost(postId) {
await request(`/api/admin/posts/${postId}/lock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locked: true }),
});
}
async function main() {
const { posts } = await request('/api/posts?page=1&size=50');
const list = posts ?? [];
if (!list.length) throw new Error('没有帖子,请先运行 node scripts/seed.mjs');
const byTitle = (kw) => list.find((p) => p.title.includes(kw));
const first = list[0];
const qa = byTitle('embed') ?? first;
const hot = list.filter((p) => p.title.includes('热门候选'));
const lockTarget = byTitle('夜班') ?? list.at(-1);
console.log('补全评论与互动…');
await login('alice', 'alice123');
await addComment(first.id, '第一条评论,测试楼层显示。');
const aliceC = await addComment(first.id, '同意,排版帖很有参考价值。');
await addComment(qa.id, '同问embed 路径我也踩过坑。');
await login('bob', 'bob123');
await addComment(first.id, '引用回复测试', aliceC);
for (const p of hot.slice(0, 2)) await addComment(p.id, '热门帖评论一条。');
await login('admin', 'admin123');
await addComment(first.id, '管理员也来回复一楼。');
for (const p of hot) await likePost(p.id);
await login('alice', 'alice123');
for (const p of hot) await likePost(p.id);
await login('bob', 'bob123');
for (const p of hot) await likePost(p.id);
await login('admin', 'admin123');
await pinPost(first.id);
if (list[1]) await pinPost(list[1].id);
if (lockTarget) await lockPost(lockTarget.id);
const stats = await request('/api/stats');
console.log(`完成。帖子 ${stats.posts} · 会员 ${stats.users}`);
}
main().catch((e) => {
console.error('失败:', e.message);
process.exit(1);
});

View File

@@ -1,181 +0,0 @@
/**
* 批量创建测试帖子,用于验证编辑器渲染、列表滚动、登录可见等功能
*/
const BASE = 'http://localhost:8080';
const TOKEN =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6InRlc3R1Iiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzgyMDYyODI2LCJpYXQiOjE3ODE0NTgwMjZ9.QmsRTyj_2YqmGHw_Mw7_gwmo-WbHtTeyqkWrTRfccV4';
const longBody = Array.from({ length: 30 }, (_, i) => {
const n = i + 1;
return `<h3>第 ${n} 节</h3><p>虚拟滚动测试段落 ${n}Lorem ipsum 论坛长文压测内容,包含中英文混排 Mixed Content 以及标点符号——「」、『』、…、!?。重复文本有助于观察滚动条、已读标记与列表项高度是否稳定。</p>`;
}).join('');
const posts = [
{
board_id: '1',
title: '【测试】富文本排版大全',
tags: '测试,排版,富文本',
content: `<h2>标题与段落</h2>
<p>这是一段普通正文,包含 <strong>加粗</strong>、<em>斜体</em>、<u>下划线</u> 和 <s>删除线</s> 样式。</p>
<h3>无序列表</h3>
<ul>
<li>第一项Go 单二进制部署</li>
<li>第二项React SPA 内嵌</li>
<li>第三项SQLite 零依赖</li>
</ul>
<h3>有序列表</h3>
<ol>
<li>注册账号</li>
<li>选择板块发帖</li>
<li>楼层式回复互动</li>
</ol>
<blockquote><p>引用块:论坛的价值在于记录,而不只是展示。</p></blockquote>
<p>行内代码示例:<code>npm run build</code>,多行代码块:</p>
<pre><code>func main() {
fmt.Println("Hello Jiang13 Forum")
}</code></pre>
<p>外链测试:<a href="https://git.iioio.com/freefire/jiang13-forum" target="_blank" rel="noopener noreferrer">姜十三论坛仓库</a></p>
<hr>
<p>分隔线以上,排版元素应全部正常显示。</p>`,
},
{
board_id: '2',
title: '【测试】登录可见区块',
tags: '测试,会员专属',
content: `<p>以下内容对游客隐藏,登录后可完整阅读。</p>
<members-only><p>这是会员专属段落:包含内部讨论纪要、未公开方案和敏感数据摘要。游客应看到模糊占位与登录引导按钮。</p></members-only>
<p>公开结尾:欢迎登录后查看上文隐藏内容。</p>`,
},
{
board_id: '1',
title: '【测试】图片与图文混排',
tags: '测试,图片',
content: `<p>下图使用占位图服务,用于验证图片自适应与懒加载:</p>
<p><img src="https://picsum.photos/seed/jiang13/800/400" alt="论坛测试配图"></p>
<p>图片下方继续正文,检查间距与圆角是否正常。</p>
<p><img src="https://picsum.photos/seed/forum2/400/300" alt="小图测试"></p>
<p>两张不同尺寸图片混排,移动端不应溢出容器。</p>`,
},
{
board_id: '2',
title: '【测试】超长帖子 · 虚拟滚动压测',
tags: '测试,长文',
content: `<h2>长文压测说明</h2>
<p>本帖用于测试首页虚拟滚动与详情页渲染性能,正文重复段落以撑满屏幕。</p>
${longBody}
<p><strong>长文结束</strong>,如滚动流畅则通过。</p>`,
},
{
board_id: '1',
title: '【测试】短帖 · 单行标题',
tags: '测试',
content: '<p>极简短帖,仅一句话,用于测试列表项最小高度与摘要截取。</p>',
},
{
board_id: '2',
title: '【测试】多级标题 H2~H6',
tags: '测试,标题',
content: `<h2>二级标题 H2</h2>
<p>H2 下方正文。</p>
<h3>三级标题 H3</h3>
<p>H3 下方正文。</p>
<h4>四级标题 H4</h4>
<p>H4 下方正文。</p>
<h5>五级标题 H5</h5>
<p>H5 下方正文。</p>
<h6>六级标题 H6</h6>
<p>H6 下方正文,六级标题字号应明显小于 H2。</p>`,
},
{
board_id: '1',
title: '【测试】混合登录可见 + 富文本',
tags: '测试,会员专属,排版',
content: `<h2>公开前言</h2>
<p>所有人可见的导读部分。</p>
<members-only>
<p>隐藏区 <strong>加粗</strong> 与 <em>斜体</em></p>
<ul>
<li>内部链接 <a href="/compose">发帖入口</a></li>
<li>代码 <code>members-only</code> 标签</li>
</ul>
</members-only>
<blockquote><p>公开引用:登录后上文应展开为完整富文本。</p></blockquote>`,
},
{
board_id: '2',
title: '【测试】特殊字符与 Emoji',
tags: '测试,编码',
content: `<p>特殊符号:&lt;script&gt;alert(1)&lt;/script&gt; 应被转义或过滤,不可执行。</p>
<p>Emoji🎉 🚀 ✅ ❤️ 🔥 中日韩:姜十三论坛</p>
<p>数学符号:α + β = γ,箭头 → ← ↑ ↓</p>
<p>全角标点:,。!?;:""''【】</p>`,
},
{
board_id: '1',
title: '【测试】多标签帖子',
tags: 'Go,React,SQLite,部署,开源',
content: `<p>本帖携带五个标签,用于测试标签展示、搜索与筛选。</p>
<p>技术栈Go + Gin + GORM + SQLite + React + TipTap。</p>`,
},
{
board_id: '2',
title: '【测试】空行与换行保留',
tags: '测试,换行',
content: `<p>第一段,后面有两个空行。</p>
<p></p>
<p></p>
<p>第二段,中间有<br>手动换行<br>应保留。</p>
<p>第三段结束。</p>`,
},
];
async function createPost(post) {
const fd = new FormData();
fd.append('board_id', post.board_id);
fd.append('title', post.title);
fd.append('content', post.content);
fd.append('tags', post.tags || '');
const res = await fetch(`${BASE}/api/posts`, {
method: 'POST',
headers: { Cookie: `jiang13_token=${TOKEN}` },
body: fd,
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || res.statusText);
console.log(` OK post_id=${data.post_id} ${post.title}`);
return data.post_id;
}
async function pinPost(postId) {
const res = await fetch(`${BASE}/api/admin/posts/${postId}/pin`, {
method: 'POST',
headers: {
Cookie: `jiang13_token=${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ pinned: true }),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || res.statusText);
console.log(` PIN post_id=${postId}`);
}
async function main() {
console.log(`开始创建 ${posts.length} 篇测试帖子...`);
const ids = [];
for (const post of posts) {
ids.push(await createPost(post));
await new Promise((r) => setTimeout(r, 150));
}
if (ids.length > 0) await pinPost(ids[0]);
const list = await fetch(`${BASE}/api/posts?page=1&size=50`).then((r) => r.json());
console.log(`\n完成!当前帖子总数:${list.total}`);
console.log(`新建帖子 ID${ids.join(', ')}`);
}
main().catch((err) => {
console.error('失败:', err.message);
process.exit(1);
});

391
scripts/seed.mjs Normal file
View File

@@ -0,0 +1,391 @@
/**
* 全量测试数据种子脚本(开发环境)
* 用法node scripts/seed.mjs
* 环境变量SEED_BASE=http://localhost:3000
*/
const BASE = process.env.SEED_BASE || 'http://localhost:3000';
let cookie = '';
function captureCookie(res) {
const raw = res.headers.getSetCookie?.() ?? [];
for (const line of raw) {
const part = line.split(';')[0].trim();
if (part.startsWith('jiang13_token=')) cookie = part;
}
const legacy = res.headers.get('set-cookie');
if (legacy && !cookie) {
const part = legacy.split(';')[0].trim();
if (part.startsWith('jiang13_token=')) cookie = part;
}
}
async function request(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (cookie) headers.Cookie = cookie;
if (opts.body instanceof FormData) {
delete headers['Content-Type'];
}
const res = await fetch(`${BASE}${path}`, { ...opts, headers });
captureCookie(res);
let data = {};
try {
data = await res.json();
} catch {
/* empty */
}
if (!res.ok) throw new Error(data.error || `${res.status} ${path}`);
return data;
}
async function register(username, password, nickname) {
const fd = new FormData();
fd.append('username', username);
fd.append('password', password);
fd.append('nickname', nickname);
await request('/api/register', { method: 'POST', body: fd });
console.log(` 用户 ${username}${nickname}`);
}
async function login(username, password) {
const fd = new FormData();
fd.append('username', username);
fd.append('password', password);
await request('/api/login', { method: 'POST', body: fd });
}
async function ensureUser(username, password, nickname) {
try {
await register(username, password, nickname);
} catch {
await login(username, password);
console.log(` 用户 ${username} 已存在,已登录`);
}
}
async function ensureBoards() {
const { boards } = await request('/api/boards');
if (boards?.length > 0) {
console.log(` 已有 ${boards.length} 个板块,跳过创建`);
return boards.map((b) => b.id);
}
const ids = [
await createBoard('技术交流', 'Go、前端、架构与工具', 1),
await createBoard('生活杂谈', '日常、户外、读书与闲聊', 2),
await createBoard('问答求助', '提问与互助', 3),
];
return ids;
}
async function createBoard(name, description, sortOrder) {
const data = await request('/api/admin/boards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, sort_order: sortOrder }),
});
console.log(` 板块「${name}」 id=${data.board.id}`);
return data.board.id;
}
async function createPost(boardId, title, content, tags = '') {
const fd = new FormData();
fd.append('board_id', String(boardId));
fd.append('title', title);
fd.append('content', content);
fd.append('tags', tags);
const data = await request('/api/posts', { method: 'POST', body: fd });
return data.post_id;
}
let postActionCount = 0;
/** 发帖限流:每分钟最多 10 次 */
async function createPostThrottled(boardId, title, content, tags = '') {
if (postActionCount >= 9) {
console.log(' (发帖限流,等待 62 秒…)');
await sleep(62000);
postActionCount = 0;
}
postActionCount++;
return createPost(boardId, title, content, tags);
}
async function updatePost(id, title, content, tags = '') {
const fd = new FormData();
fd.append('title', title);
fd.append('content', content);
fd.append('tags', tags);
await request(`/api/posts/${id}`, { method: 'PUT', body: fd });
}
async function addComment(postId, content, replyTo) {
const fd = new FormData();
fd.append('content', content);
if (replyTo) fd.append('reply_to', String(replyTo));
const data = await request(`/api/posts/${postId}/comments`, { method: 'POST', body: fd });
return data.id;
}
async function likePost(postId) {
await request(`/api/posts/${postId}/like`, { method: 'POST' });
}
async function pinPost(postId) {
await request(`/api/admin/posts/${postId}/pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned: true }),
});
}
async function lockPost(postId) {
await request(`/api/admin/posts/${postId}/lock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locked: true }),
});
}
const longSection = Array.from({ length: 20 }, (_, i) => {
const n = i + 1;
return `<h3>第 ${n} 节</h3><p>虚拟滚动压测段落 ${n}:中英文混排 Mixed Content标点——「」、…、。用于检验列表滚动与详情渲染性能。</p>`;
}).join('');
const richPost = {
title: '【测试】富文本排版大全',
tags: '测试,排版,富文本',
content: `<h2>标题与段落</h2>
<p>普通正文:<strong>加粗</strong>、<em>斜体</em>、<u>下划线</u>、<s>删除线</s>。</p>
<ul><li>Go 单二进制</li><li>React SPA</li><li>SQLite</li></ul>
<ol><li>注册</li><li>发帖</li><li>回复</li></ol>
<blockquote><p>引用:论坛的价值在于记录。</p></blockquote>
<p>代码:<code>npm run build</code></p>
<pre><code>func main() { fmt.Println("Hello") }</code></pre>
<p><a href="https://git.iioio.com/freefire/jiang13-forum" target="_blank" rel="noopener noreferrer">仓库链接</a></p>
<hr><p>排版元素应全部正常。</p>`,
};
const postTemplates = [
richPost,
{
title: '【测试】登录可见区块',
tags: '测试,会员专属',
content: `<p>公开导读。</p>
<members-only><p>仅登录可见:内部纪要、未公开方案与敏感摘要。游客应见模糊占位与登录引导。</p></members-only>
<p>公开结尾。</p>`,
},
{
title: '【测试】图片图文混排',
tags: '测试,图片',
content: `<p>占位图测试:</p>
<p><img src="https://picsum.photos/seed/jiang13/800/400" alt="配图"></p>
<p><img src="https://picsum.photos/seed/forum2/400/300" alt="小图"></p>`,
},
{
title: '【测试】超长帖 · 虚拟滚动',
tags: '测试,长文',
content: `<h2>长文说明</h2><p>压测首页虚拟滚动。</p>${longSection}<p><strong>结束</strong></p>`,
},
{
title: '【测试】短帖',
tags: '测试',
content: '<p>极简短帖,测试列表最小高度。</p>',
},
{
title: '【测试】多级标题 H2~H6',
tags: '测试,标题',
content: '<h2>H2</h2><p>正文</p><h3>H3</h3><p>正文</p><h4>H4</h4><p>正文</p><h5>H5</h5><p>正文</p><h6>H6</h6><p>正文</p>',
},
{
title: '【测试】混合登录可见 + 富文本',
tags: '测试,会员专属',
content: `<h2>公开</h2><p>所有人可见。</p>
<members-only><p><strong>隐藏</strong> <em>斜体</em> <code>tag</code></p></members-only>
<blockquote><p>公开引用。</p></blockquote>`,
},
{
title: '【测试】特殊字符与 Emoji',
tags: '测试,编码',
content: '<p>&lt;script&gt; 应转义。Emoji 🎉 🚀 中日韩 姜十三</p><p>α+β=γ →←</p>',
},
{
title: '【测试】多标签',
tags: 'Go,React,SQLite,部署,开源',
content: '<p>五标签帖测搜索与展示。Go + Gin + React + TipTap。</p>',
},
{
title: '【测试】换行保留',
tags: '测试,换行',
content: '<p>第一段。</p><p></p><p>第二段<br>换行<br>保留。</p>',
},
{
title: '【搜索】姜十三论坛入门指南',
tags: '教程,入门',
content: '<p>姜十三论坛是一款轻量社区,支持富文本发帖与楼层回复。关键词:入门、部署、单二进制。</p>',
},
{
title: '【搜索】SQLite 备份与恢复',
tags: 'SQLite,运维',
content: '<p>在管理后台可一键导出 jiang13_backup 数据库副本,适合小圈子冷备份。</p>',
},
{
title: '【搜索】TipTap 编辑器使用技巧',
tags: 'TipTap,编辑器',
content: '<p>选中文字后使用工具栏设置格式,支持本地上传图片与登录可见区块。</p>',
},
{
title: '周末徒步记录 · 西湖环线',
tags: '生活,户外',
content: '<p>周六走了西湖环线,天气不错,分享几张沿途风景。</p>',
},
{
title: '求助Go embed 静态资源路径',
tags: '求助,Go',
content: '<p>embed 打包 SPA 后 NoRoute 如何判断 SPA 路由?有经验的朋友吗?</p>',
},
{
title: '分享:我的 homelab 配置',
tags: 'homelab,分享',
content: '<p>一台小主机跑论坛 + Gitea2GB 内存够用SQLite 省心。</p>',
},
{
title: '【热门候选】年度最爱开源项目',
tags: '讨论,开源',
content: '<p>大家今年最喜欢的开源项目是什么?我先投 React 和 Go 一票。</p>',
},
{
title: '【热门候选】轻量论坛选型讨论',
tags: '讨论,论坛',
content: '<p>小团队内部交流,更看重部署简单还是功能全?欢迎讨论。</p>',
},
{
title: '【热门候选】前端虚拟滚动体验',
tags: 'React,性能',
content: '<p>TanStack Virtual 在长列表下确实流畅,欢迎分享调参经验。</p>',
},
{
title: '草稿感帖子 · 待补充内容',
tags: '随笔',
content: '<p>先占个坑,晚点再写正文。</p>',
},
{
title: '夜班摸鱼闲聊楼',
tags: '闲聊',
content: '<p>夜班同事来报到,今天咖啡喝了第几杯?</p>',
},
{
title: '【修订测试】原始标题',
tags: '测试,修订',
content: '<p>这篇帖子将被编辑,用于测试修订历史与 diff 面板。</p>',
},
];
async function main() {
console.log(`连接 ${BASE} ...`);
await request('/api/stats');
console.log('\n1. 注册用户');
await ensureUser('admin', 'admin123', '管理员');
await ensureUser('alice', 'alice123', '爱丽丝');
await ensureUser('bob', 'bob123', '鲍勃');
console.log('\n2. 创建板块');
await login('admin', 'admin123');
const boards = await ensureBoards();
console.log('\n3. 创建帖子');
const postIds = [];
let revisionPostId = null;
for (let i = 0; i < postTemplates.length; i++) {
const t = postTemplates[i];
const boardId = boards[i % boards.length];
const id = await createPostThrottled(boardId, t.title, t.content, t.tags);
postIds.push(id);
if (t.title.includes('修订测试')) revisionPostId = id;
console.log(` #${id} ${t.title}`);
}
for (let i = 1; i <= 12; i++) {
const id = await createPostThrottled(
boards[i % boards.length],
`列表填充帖 #${i}`,
`<p>填充首页列表与分页,编号 ${i}。内容简短,用于测排序与滚动。</p>`,
'填充',
);
postIds.push(id);
console.log(` #${id} 列表填充帖 #${i}`);
}
if (revisionPostId) {
await updatePost(
revisionPostId,
'【修订测试】已修改标题',
'<p>正文已更新用于验证修订历史、diff 展示与「已编辑」标记。</p><p>新增第二段内容。</p>',
'测试,修订,已改',
);
console.log(` 已编辑帖子 #${revisionPostId}(修订历史)`);
}
console.log('\n4. 评论与互动');
const hotIds = postIds.filter((_, i) => postTemplates[i]?.title?.includes('热门候选'));
const qaPostId = postIds[postTemplates.findIndex((t) => t.title.includes('embed'))] ?? postIds[0];
await login('alice', 'alice123');
await addComment(postIds[0], '第一条评论,测试楼层显示。');
const aliceComment = await addComment(postIds[0], '同意,排版帖很有参考价值。');
await addComment(qaPostId, '同问embed 路径我也踩过坑。');
await login('bob', 'bob123');
await addComment(postIds[0], '引用回复测试', aliceComment);
await addComment(hotIds[0] ?? postIds[0], '我投 Vite + Go embed');
await addComment(hotIds[1] ?? postIds[0], '部署简单更重要,小圈子够用就行。');
await login('admin', 'admin123');
await addComment(postIds[0], '管理员也来回复一楼。');
for (const id of hotIds) {
await likePost(id);
}
await login('alice', 'alice123');
for (const id of hotIds) {
await likePost(id);
}
await login('bob', 'bob123');
for (const id of hotIds) {
await likePost(id);
}
console.log('\n5. 置顶与锁定');
await login('admin', 'admin123');
await pinPost(postIds[0]);
console.log(` 置顶 #${postIds[0]}`);
if (postIds[1]) {
await pinPost(postIds[1]);
console.log(` 置顶 #${postIds[1]}`);
}
const lockId = postIds[postTemplates.length - 2] ?? postIds.at(-1);
if (lockId) {
await lockPost(lockId);
console.log(` 锁定编辑 #${lockId}`);
}
const stats = await request('/api/stats');
const list = await request('/api/posts?page=1&size=1');
console.log('\n========== 种子数据完成 ==========');
console.log(`用户admin / admin123管理员`);
console.log(` alice / alice123bob / bob123`);
console.log(`板块:${boards.length}`);
console.log(`帖子:${list.total}`);
console.log(`统计:会员 ${stats.users} · 帖子 ${stats.posts} · 板块 ${stats.boards}`);
console.log('==================================\n');
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
main().catch((err) => {
console.error('\n种子失败', err.message);
console.error('请确认服务已启动,例如:.\\dist\\jiang13.exe --port 3000 --data ./data');
process.exit(1);
});

View File

@@ -20,10 +20,11 @@ type Claims struct {
type AuthService struct {
jwtSecret string
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewAuthService(jwtSecret string, filter *SensitiveFilter) *AuthService {
return &AuthService{jwtSecret: jwtSecret, filter: filter}
func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSettingsService) *AuthService {
return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
}
// Register 用户注册
@@ -31,7 +32,7 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User
if err := ValidateUsername(username); err != nil {
return nil, err
}
if err := ValidatePassword(password); err != nil {
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
return nil, err
}
var exist model.User

View File

@@ -11,10 +11,11 @@ import (
type CommentService struct {
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewCommentService(filter *SensitiveFilter) *CommentService {
return &CommentService{filter: filter}
func NewCommentService(filter *SensitiveFilter, settings *ForumSettingsService) *CommentService {
return &CommentService{filter: filter, settings: settings}
}
type CommentCreateInput struct {
@@ -98,6 +99,9 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
if content == "" {
return nil, errors.New("评论内容不能为空")
}
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
return nil, err
}
var post model.Post
if err := model.DB.First(&post, in.PostID).Error; err != nil {

View File

@@ -2,6 +2,7 @@ package service
import (
"errors"
"fmt"
"regexp"
"strings"
"sync"
@@ -24,6 +25,12 @@ var (
ErrPostEditExpired = errors.New("已超过可编辑时限")
ErrRevisionNotFound = errors.New("历史版本不存在")
ErrInvalidSetting = errors.New("无效的设置值")
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
ErrPostTitleTooLong = errors.New("标题过长")
ErrPostTagsTooLong = errors.New("标签过长")
ErrPostContentTooLong = errors.New("正文过长")
ErrCommentTooLong = errors.New("评论内容过长")
)
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
@@ -48,9 +55,12 @@ func ValidateUsername(username string) error {
}
// ValidatePassword 校验密码强度
func ValidatePassword(password string) error {
if utf8.RuneCountInString(password) < 6 {
return ErrWeakPassword
func ValidatePassword(password string, minLen int) error {
if minLen <= 0 {
minLen = 6
}
if utf8.RuneCountInString(password) < minLen {
return fmt.Errorf("密码至少 %d 位", minLen)
}
return nil
}

View File

@@ -35,3 +35,13 @@ func membersContentLength(html string) int {
}
return utf8.RuneCountInString(text)
}
// StripHTMLForSearch 剥离 HTML 标签,生成用于全文搜索的纯文本
func StripHTMLForSearch(html string) string {
if html == "" {
return ""
}
text := htmlTagRe.ReplaceAllString(html, " ")
text = strings.ReplaceAll(text, "&nbsp;", " ")
return strings.Join(strings.Fields(text), " ")
}

55
service/filter_words.go Normal file
View File

@@ -0,0 +1,55 @@
package service
import (
"os"
"strings"
)
// ReadFilterWordsFile 读取敏感词配置文件内容
func ReadFilterWordsFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
// WriteFilterWordsFile 写入敏感词配置并热加载到过滤器
func WriteFilterWordsFile(path string, content string, filter *SensitiveFilter) error {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return err
}
filter.LoadFromFile(path)
return nil
}
// CountFilterWords 统计有效敏感词数量(不含空行与注释)
func CountFilterWords(content string) int {
count := 0
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
count++
}
}
return count
}
// FilterWordsPreview 返回前几行敏感词预览
func FilterWordsPreview(content string, maxLines int) []string {
if maxLines <= 0 {
maxLines = 5
}
var preview []string
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
preview = append(preview, line)
if len(preview) >= maxLines {
break
}
}
return preview
}

View File

@@ -143,8 +143,13 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
if q.Page < 1 {
q.Page = 1
}
if q.Size < 1 {
q.Size = 20
q.Size = s.settings.NormalizePageSize(q.Size)
if q.Keyword != "" {
kw, err := s.settings.NormalizeSearchKeyword(q.Keyword)
if err != nil {
return nil, 0, err
}
q.Keyword = kw
}
db := model.DB.Model(&model.Post{}).Preload("User").Preload("Board")
if q.BoardID > 0 {
@@ -152,7 +157,7 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
db = db.Where("title LIKE ? OR content LIKE ? OR tags LIKE ?", kw, kw, kw)
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
}
var total int64
db.Count(&total)
@@ -188,16 +193,29 @@ func normalizePostSort(sort string) string {
}
}
func (s *PostService) GetByID(id uint) (*model.Post, error) {
func (s *PostService) FindByID(id uint) (*model.Post, error) {
var post model.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
if err != nil {
return nil, ErrPostNotFound
}
model.DB.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
return &post, nil
}
func (s *PostService) RecordView(id uint) {
model.DB.Model(&model.Post{}).Where("id = ?", id).
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
}
func (s *PostService) GetByID(id uint) (*model.Post, error) {
post, err := s.FindByID(id)
if err != nil {
return nil, err
}
s.RecordView(id)
return post, nil
}
func (s *PostService) Create(userID, boardID uint, title, content, tags string) (*model.Post, error) {
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(content)
@@ -205,12 +223,25 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags string)
if title == "" || content == "" {
return nil, errors.New("标题和内容不能为空")
}
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
return nil, err
}
if err := s.settings.ValidateTextLength(tags, s.settings.PostTagsMax(), ErrPostTagsTooLong); err != nil {
return nil, err
}
if err := s.settings.ValidateTextLength(content, s.settings.PostContentMax(), ErrPostContentTooLong); err != nil {
return nil, err
}
if _, err := NewBoardService().GetByID(boardID); err != nil {
return nil, err
}
post := &model.Post{
BoardID: boardID, UserID: userID,
Title: title, Content: content, Tags: tags,
BoardID: boardID,
UserID: userID,
Title: title,
Content: content,
ContentPlain: StripHTMLForSearch(content),
Tags: tags,
}
return post, model.DB.Create(post).Error
}
@@ -229,6 +260,15 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(content)
tags = s.filter.Filter(strings.TrimSpace(tags))
if err := s.settings.ValidateTextLength(title, s.settings.PostTitleMax(), ErrPostTitleTooLong); err != nil {
return err
}
if err := s.settings.ValidateTextLength(tags, s.settings.PostTagsMax(), ErrPostTagsTooLong); err != nil {
return err
}
if err := s.settings.ValidateTextLength(content, s.settings.PostContentMax(), ErrPostContentTooLong); err != nil {
return err
}
return model.DB.Transaction(func(tx *gorm.DB) error {
rev := model.PostRevision{
PostID: postID, EditorID: userID,
@@ -238,7 +278,10 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
return err
}
return tx.Model(&post).Updates(map[string]interface{}{
"title": title, "content": content, "tags": tags,
"title": title,
"content": content,
"content_plain": StripHTMLForSearch(content),
"tags": tags,
}).Error
})
}

View File

@@ -9,39 +9,44 @@ import (
type RateLimiter struct {
mu sync.Mutex
records map[string][]time.Time
limit int // 窗口内最大次数
window time.Duration // 时间窗口
settings *ForumSettingsService
}
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
func NewRateLimiter(settings *ForumSettingsService) *RateLimiter {
r := &RateLimiter{
records: make(map[string][]time.Time),
limit: limit,
window: window,
settings: settings,
}
go r.cleanup()
return r
}
// Allow 检查 key如 userID+action是否允许操作
func (r *RateLimiter) Allow(key string) bool {
// Allow 检查 action+key 是否允许操作
func (r *RateLimiter) Allow(action, key string) bool {
limit := r.settings.RateLimitFor(action)
window := time.Duration(r.settings.RateLimitWindowSec()) * time.Second
if limit <= 0 {
return true
}
fullKey := action + ":" + key
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
cutoff := now.Add(-r.window)
times := r.records[key]
cutoff := now.Add(-window)
times := r.records[fullKey]
var valid []time.Time
for _, t := range times {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) >= r.limit {
r.records[key] = valid
if len(valid) >= limit {
r.records[fullKey] = valid
return false
}
valid = append(valid, now)
r.records[key] = valid
r.records[fullKey] = valid
return true
}
@@ -49,8 +54,8 @@ func (r *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
r.mu.Lock()
now := time.Now()
cutoff := now.Add(-r.window * 2)
window := time.Duration(r.settings.RateLimitWindowSec()) * time.Second
cutoff := time.Now().Add(-window * 2)
for k, times := range r.records {
var valid []time.Time
for _, t := range times {

View File

@@ -7,11 +7,102 @@ import (
"git.iioio.com/freefire/jiang13-forum/model"
)
// 论坛设置键名
const (
SettingPostEditWindowHours = "post_edit_window_hours"
defaultEditWindowHours = 24
SettingRateLimitPost = "rate_limit_post"
SettingRateLimitComment = "rate_limit_comment"
SettingRateLimitRegister = "rate_limit_register"
SettingRateLimitLogin = "rate_limit_login"
SettingRateLimitWindow = "rate_limit_window_sec"
SettingPostTitleMax = "post_title_max"
SettingPostTagsMax = "post_tags_max"
SettingPostContentMax = "post_content_max"
SettingCommentMax = "comment_max"
SettingSearchKeywordMin = "search_keyword_min"
SettingSearchKeywordMax = "search_keyword_max"
SettingPageSizeDefault = "page_size_default"
SettingPageSizeMax = "page_size_max"
SettingPasswordMinLen = "password_min_len"
SettingAvatarMaxMB = "avatar_max_mb"
)
// ForumLimits 论坛可配置限制API 传输结构)
type ForumLimits struct {
PostEditWindowHours int `json:"post_edit_window_hours"`
RateLimitPost int `json:"rate_limit_post"`
RateLimitComment int `json:"rate_limit_comment"`
RateLimitRegister int `json:"rate_limit_register"`
RateLimitLogin int `json:"rate_limit_login"`
RateLimitWindowSec int `json:"rate_limit_window_sec"`
PostTitleMax int `json:"post_title_max"`
PostTagsMax int `json:"post_tags_max"`
PostContentMax int `json:"post_content_max"`
CommentMax int `json:"comment_max"`
SearchKeywordMin int `json:"search_keyword_min"`
SearchKeywordMax int `json:"search_keyword_max"`
PageSizeDefault int `json:"page_size_default"`
PageSizeMax int `json:"page_size_max"`
PasswordMinLen int `json:"password_min_len"`
AvatarMaxMB int `json:"avatar_max_mb"`
}
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
type ForumLimitsPublic struct {
PostTitleMax int `json:"post_title_max"`
PostTagsMax int `json:"post_tags_max"`
PostContentMax int `json:"post_content_max"`
CommentMax int `json:"comment_max"`
SearchKeywordMin int `json:"search_keyword_min"`
SearchKeywordMax int `json:"search_keyword_max"`
PasswordMinLen int `json:"password_min_len"`
AvatarMaxMB int `json:"avatar_max_mb"`
}
type settingDef struct {
key string
defaultVal string
min int
max int // 0 表示不限制上限
}
var forumSettingDefs = []settingDef{
{SettingPostEditWindowHours, "24", 0, 0},
{SettingRateLimitPost, "10", 1, 1000},
{SettingRateLimitComment, "10", 1, 1000},
{SettingRateLimitRegister, "10", 1, 1000},
{SettingRateLimitLogin, "10", 1, 1000},
{SettingRateLimitWindow, "60", 10, 3600},
{SettingPostTitleMax, "128", 1, 512},
{SettingPostTagsMax, "256", 0, 512},
{SettingPostContentMax, "50000", 0, 0},
{SettingCommentMax, "5000", 1, 50000},
{SettingSearchKeywordMin, "1", 0, 100},
{SettingSearchKeywordMax, "50", 1, 200},
{SettingPageSizeDefault, "30", 1, 200},
{SettingPageSizeMax, "50", 1, 200},
{SettingPasswordMinLen, "6", 4, 128},
{SettingAvatarMaxMB, "2", 1, 20},
}
// ForumSettingsService 论坛全局设置
type ForumSettingsService struct {
mu sync.RWMutex
@@ -24,47 +115,193 @@ func NewForumSettingsService() *ForumSettingsService {
}
func (s *ForumSettingsService) ensureDefaults() {
for _, def := range forumSettingDefs {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", SettingPostEditWindowHours).Count(&count)
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", def.key).Count(&count)
if count == 0 {
model.DB.Create(&model.ForumSetting{
Key: SettingPostEditWindowHours,
Value: strconv.Itoa(defaultEditWindowHours),
})
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
}
}
}
// PostEditWindowHours 返回用户可编辑帖子的时限小时0 表示不限
func (s *ForumSettingsService) PostEditWindowHours() int {
s.mu.RLock()
defer s.mu.RUnlock()
func (s *ForumSettingsService) getInt(key string, fallback int) int {
var setting model.ForumSetting
if err := model.DB.First(&setting, "`key` = ?", SettingPostEditWindowHours).Error; err != nil {
return defaultEditWindowHours
if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil {
return fallback
}
h, err := strconv.Atoi(setting.Value)
if err != nil || h < 0 {
return defaultEditWindowHours
v, err := strconv.Atoi(setting.Value)
if err != nil {
return fallback
}
return h
return v
}
// SetPostEditWindowHours 设置编辑时限小时0 表示不限
func (s *ForumSettingsService) SetPostEditWindowHours(hours int) error {
if hours < 0 {
func (s *ForumSettingsService) setInt(key string, value int) error {
for _, def := range forumSettingDefs {
if def.key != key {
continue
}
if value < def.min {
return ErrInvalidSetting
}
if def.max > 0 && value > def.max {
return ErrInvalidSetting
}
s.mu.Lock()
defer s.mu.Unlock()
return model.DB.Save(&model.ForumSetting{
Key: SettingPostEditWindowHours,
Value: strconv.Itoa(hours),
}).Error
return model.DB.Save(&model.ForumSetting{Key: key, Value: strconv.Itoa(value)}).Error
}
return ErrInvalidSetting
}
// ForumSettings 返回所有可配置的论坛设置
func (s *ForumSettingsService) ForumSettings() map[string]int {
return map[string]int{
"post_edit_window_hours": s.PostEditWindowHours(),
func (s *ForumSettingsService) Limits() ForumLimits {
return ForumLimits{
PostEditWindowHours: s.PostEditWindowHours(),
RateLimitPost: s.RateLimitFor("post"),
RateLimitComment: s.RateLimitFor("comment"),
RateLimitRegister: s.RateLimitFor("register"),
RateLimitLogin: s.RateLimitFor("login"),
RateLimitWindowSec: s.RateLimitWindowSec(),
PostTitleMax: s.PostTitleMax(),
PostTagsMax: s.PostTagsMax(),
PostContentMax: s.PostContentMax(),
CommentMax: s.CommentMax(),
SearchKeywordMin: s.SearchKeywordMin(),
SearchKeywordMax: s.SearchKeywordMax(),
PageSizeDefault: s.PageSizeDefault(),
PageSizeMax: s.PageSizeMax(),
PasswordMinLen: s.PasswordMinLen(),
AvatarMaxMB: s.AvatarMaxMB(),
}
}
func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
limits := s.Limits()
return ForumLimitsPublic{
PostTitleMax: limits.PostTitleMax,
PostTagsMax: limits.PostTagsMax,
PostContentMax: limits.PostContentMax,
CommentMax: limits.CommentMax,
SearchKeywordMin: limits.SearchKeywordMin,
SearchKeywordMax: limits.SearchKeywordMax,
PasswordMinLen: limits.PasswordMinLen,
AvatarMaxMB: limits.AvatarMaxMB,
}
}
func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
updates := map[string]int{
SettingPostEditWindowHours: in.PostEditWindowHours,
SettingRateLimitPost: in.RateLimitPost,
SettingRateLimitComment: in.RateLimitComment,
SettingRateLimitRegister: in.RateLimitRegister,
SettingRateLimitLogin: in.RateLimitLogin,
SettingRateLimitWindow: in.RateLimitWindowSec,
SettingPostTitleMax: in.PostTitleMax,
SettingPostTagsMax: in.PostTagsMax,
SettingPostContentMax: in.PostContentMax,
SettingCommentMax: in.CommentMax,
SettingSearchKeywordMin: in.SearchKeywordMin,
SettingSearchKeywordMax: in.SearchKeywordMax,
SettingPageSizeDefault: in.PageSizeDefault,
SettingPageSizeMax: in.PageSizeMax,
SettingPasswordMinLen: in.PasswordMinLen,
SettingAvatarMaxMB: in.AvatarMaxMB,
}
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
return ErrInvalidSetting
}
if in.PageSizeDefault > in.PageSizeMax {
return ErrInvalidSetting
}
for key, val := range updates {
if err := s.setInt(key, val); err != nil {
return err
}
}
return nil
}
func (s *ForumSettingsService) PostEditWindowHours() int {
return s.getInt(SettingPostEditWindowHours, 24)
}
func (s *ForumSettingsService) RateLimitFor(action string) int {
switch action {
case "post":
return s.getInt(SettingRateLimitPost, 10)
case "comment":
return s.getInt(SettingRateLimitComment, 10)
case "register":
return s.getInt(SettingRateLimitRegister, 10)
case "login", "admin_login":
return s.getInt(SettingRateLimitLogin, 10)
default:
return 10
}
}
func (s *ForumSettingsService) RateLimitWindowSec() int {
return s.getInt(SettingRateLimitWindow, 60)
}
func (s *ForumSettingsService) PostTitleMax() int { return s.getInt(SettingPostTitleMax, 128) }
func (s *ForumSettingsService) PostTagsMax() int { return s.getInt(SettingPostTagsMax, 256) }
func (s *ForumSettingsService) PostContentMax() int { return s.getInt(SettingPostContentMax, 50000) }
func (s *ForumSettingsService) CommentMax() int { return s.getInt(SettingCommentMax, 5000) }
func (s *ForumSettingsService) SearchKeywordMin() int { return s.getInt(SettingSearchKeywordMin, 1) }
func (s *ForumSettingsService) SearchKeywordMax() int { return s.getInt(SettingSearchKeywordMax, 50) }
func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPageSizeDefault, 30) }
func (s *ForumSettingsService) PageSizeMax() int { return s.getInt(SettingPageSizeMax, 50) }
func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) }
// NormalizeSearchKeyword 校验并规范化搜索关键词,空字符串表示无搜索
func (s *ForumSettingsService) NormalizeSearchKeyword(keyword string) (string, error) {
kw := trimRunes(keyword)
if kw == "" {
return "", nil
}
minLen := s.SearchKeywordMin()
maxLen := s.SearchKeywordMax()
runeLen := runeLen(kw)
if minLen > 0 && runeLen < minLen {
return "", ErrSearchKeywordTooShort
}
if maxLen > 0 && runeLen > maxLen {
return "", ErrSearchKeywordTooLong
}
return kw, nil
}
// NormalizePageSize 规范化分页大小
func (s *ForumSettingsService) NormalizePageSize(size int) int {
if size < 1 {
return s.PageSizeDefault()
}
maxSize := s.PageSizeMax()
if maxSize > 0 && size > maxSize {
return maxSize
}
return size
}
// ValidateTextLength 校验文本长度max=0 表示不限
func (s *ForumSettingsService) ValidateTextLength(text string, max int, tooLongErr error) error {
if max <= 0 {
return nil
}
if runeLen(text) > max {
return tooLongErr
}
return nil
}

14
service/settings_util.go Normal file
View File

@@ -0,0 +1,14 @@
package service
import (
"strings"
"unicode/utf8"
)
func runeLen(s string) int {
return utf8.RuneCountInString(s)
}
func trimRunes(s string) string {
return strings.TrimSpace(s)
}

53
service/upload.go Normal file
View File

@@ -0,0 +1,53 @@
package service
import (
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
)
var allowedImageExt = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
}
// SaveUploadedImage 保存图片到本地目录,返回公开 URL 路径
func SaveUploadedImage(file *multipart.FileHeader, dir, urlPrefix, namePrefix string) (string, error) {
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedImageExt[ext] {
return "", errors.New("仅支持 jpg/png/gif/webp 格式")
}
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
filename := fmt.Sprintf("%s_%d%s", namePrefix, time.Now().UnixNano(), ext)
destPath := filepath.Join(dir, filename)
src, err := file.Open()
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(destPath)
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return "", err
}
prefix := strings.TrimSuffix(urlPrefix, "/")
return prefix + "/" + filename, nil
}

View File

@@ -3,10 +3,7 @@ package service
import (
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
@@ -15,10 +12,11 @@ import (
type UserService struct {
filter *SensitiveFilter
settings *ForumSettingsService
}
func NewUserService(filter *SensitiveFilter) *UserService {
return &UserService{filter: filter}
func NewUserService(filter *SensitiveFilter, settings *ForumSettingsService) *UserService {
return &UserService{filter: filter, settings: settings}
}
// GetByID 获取用户信息
@@ -51,7 +49,7 @@ func (s *UserService) UpdateNickname(userID uint, nickname string) error {
// UpdatePassword 修改密码
func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error {
if err := ValidatePassword(newPass); err != nil {
if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil {
return err
}
var user model.User
@@ -70,32 +68,11 @@ func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error
// UploadAvatar 上传头像到本地目录
func (s *UserService) UploadAvatar(userID uint, file *multipart.FileHeader, uploadDir string) (string, error) {
ext := strings.ToLower(filepath.Ext(file.Filename))
allowed := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
if !allowed[ext] {
return "", errors.New("仅支持 jpg/png/gif/webp 格式")
}
filename := fmt.Sprintf("%d%s", userID, ext)
destPath := filepath.Join(uploadDir, filename)
src, err := file.Open()
url, err := SaveUploadedImage(file, uploadDir, "/uploads/avatars", fmt.Sprintf("%d", userID))
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(destPath)
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return "", err
}
avatarURL := "/uploads/avatars/" + filename
return avatarURL, model.DB.Model(&model.User{}).Where("id = ?", userID).Update("avatar", avatarURL).Error
return url, model.DB.Model(&model.User{}).Where("id = ?", userID).Update("avatar", url).Error
}
// ListUsers 管理员列出用户