统一 React 管理后台,修复评论换行与帖子置顶

- /admin/* 全部由 React SPA 渲染,替代旧版 HTML 后台页面
- 新增仪表盘、帖子/评论/用户管理、系统设置与 JSON API
- 帖子详情页支持管理员置顶;评论换行显示修复

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-15 23:06:44 +08:00
parent 9230f7272d
commit d0555de28e
63 changed files with 1289 additions and 284 deletions

View File

@@ -27,7 +27,7 @@
<br>
> **开发状态:** 项目积极开发中部分功能尚未完善如评论换行显示、React 前台置顶等)
> **开发状态:** 项目积极开发中。管理后台已统一为 React SPA`/admin`),欢迎参与共建
> 查看 [路线图 ROADMAP.md](ROADMAP.md) · [Issues 反馈](https://git.iioio.com/freefire/jiang13-forum/issues)
</div>
@@ -235,9 +235,9 @@ data/
| 类型 | 示例 |
|------|------|
| 🐛 已知缺陷 | 评论回复换行不显示 |
| 📋 计划中 | React 前台帖子置顶API 已有,缺 UI |
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、楼层评论 |
| ✅ 管理后台 | React SPA`/admin/dashboard` 仪表盘、帖子置顶、用户禁言等 |
| 📋 计划中 | 通知已读优化、邮件提醒 |
完整列表见 **[路线图 ROADMAP.md](ROADMAP.md)**。发现问题请提交 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues),认领任务请参考 [CONTRIBUTING.md](CONTRIBUTING.md)。

View File

@@ -11,58 +11,39 @@
| 模块 | 状态 | 说明 |
|------|------|------|
| 前台 SPAReact | 🚧 | 核心浏览/发帖/回复可用,部分管理能力仍依赖旧版后台 |
| 管理后台 | 🚧 | 旧版 HTML 后台功能较全React 侧尚未统一 |
| 评论系统 | 🐛 | 换行显示等问题待修复 |
| 帖子管理 | 🐛 | 置顶 API 已有React 前台缺操作入口 |
| 前台 SPAReact | | 浏览发帖回复、管理操作已统一在 SPA 内 |
| 管理后台 | | React 后台 `/admin/*`,与前台风格一致 |
| 评论系统 | | 换行显示修复 |
---
## 🐛 已知缺陷Bug
| 优先级 | 问题 | 说明 | Issue |
|--------|------|------|-------|
| 高 | 评论回复换行不显示 | 输入多行回复后,展示时合并为一行 | [创建 Issue](https://git.iioio.com/freefire/jiang13-forum/issues/new/choose) |
| 中 | — | (欢迎补充) | — |
### 评论换行不显示 · 详情
- **现象**:在评论框输入带换行的内容,提交后页面上不保留换行
- **相关代码**`frontend/src/components/CommentContent.tsx``frontend/src/utils/content.ts`
- **可能原因**`\r\n` 未处理、`innerHTML``white-space: pre-wrap` 叠加异常
_当前无已记录缺陷。发现新问题请提交 [Issue](https://git.iioio.com/freefire/jiang13-forum/issues/new/choose)。_
---
## 📋 计划中Planned
| 优先级 | 功能 | 说明 | Issue |
|--------|------|------|-------|
| | React 前台支持帖子置顶 | 后端 `pinned` 字段与 API 已存在,需在 SPA 管理入口暴露操作 | [创建 Issue](https://git.iioio.com/freefire/jiang13-forum/issues/new/choose) |
| | 管理后台 React 化 | 统一旧版 `/admin/*` 与新版 SPA 体验 | — |
| | 通知已读状态优化 | 右栏通知点击后的已读同步 | — |
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 | — |
| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) | — |
### 帖子置顶 · 详情
- **现状**
- ✅ 数据模型有 `pinned` 字段
- ✅ 列表按 `pinned desc` 排序
- ✅ 旧版管理后台 `/admin/posts` 可置顶
- ✅ React 列表/详情可显示「置顶」徽章
- ❌ React SPA 中管理员无法一键置顶/取消
- **相关代码**`service/post.go``handler/admin.go``frontend/src/pages/PostDetailPage.tsx`
| 优先级 | 功能 | 说明 |
|--------|------|------|
| | 通知已读状态优化 | 右栏通知点击后的已读同步 |
| | 帖子搜索增强 | 标题/正文/作者组合筛选 |
| | 邮件通知 | 回复提醒(需 SMTP 配置) |
---
## 🚧 进行中In Progress
_当前无公开认领任务。若你正在开发某项,请在对应 Issue 留言认领。_
_当前无公开认领任务。_
---
## ✅ 已完成Done
- [x] React 管理后台(仪表盘、板块、帖子、评论、用户、设置)
- [x] 帖子置顶(帖子详情 + 管理后台)
- [x] 评论回复换行正确显示
- [x] 三栏布局 + 虚拟滚动帖列表
- [x] 浅色 / 暗色主题切换
- [x] 移动端响应式适配

View File

@@ -0,0 +1 @@
import{e as A,r as i,j as e}from"./react-vendor-CFzzFOsg.js";import{b as D,S as C,B as d,a as m,n as c}from"./index-DurEdRMk.js";import{B as N}from"./badge-E7fnzaIj.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-CQ3zNojX.js";import"./ui-vendor-BLDO93FK.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

@@ -0,0 +1 @@
import{e as h,r as n,j as s}from"./react-vendor-CFzzFOsg.js";import{b as o,a as x,S as j}from"./index-DurEdRMk.js";import{B as u}from"./badge-E7fnzaIj.js";import"./ui-vendor-BLDO93FK.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

@@ -0,0 +1 @@
import{e as k,r as i,j as e}from"./react-vendor-CFzzFOsg.js";import{b as S,B as r,S as C,a as x,n as c}from"./index-DurEdRMk.js";import{I as P}from"./input-BN5iO3BQ.js";import{B as w}from"./badge-E7fnzaIj.js";import{A as z,a as E,b as B,c as _,d as I,e as T,f as F,g as L,h as G}from"./alert-dialog-CQ3zNojX.js";import{k as H}from"./ui-vendor-BLDO93FK.js";function O(){const f=k(),{ready:o}=S(),[j,v]=i.useState([]),[y,m]=i.useState(!0),[n,b]=i.useState(1),[h,D]=i.useState(1),[g,u]=i.useState(""),[d,p]=i.useState(""),l=(s=n,t=d)=>{m(!0),x.adminPosts({page:s,keyword:t}).then(a=>{v(a.posts??[]),b(a.page),D(a.total_pages)}).catch(a=>c.error(a.message)).finally(()=>m(!1))};i.useEffect(()=>{o&&l(1,d)},[o,d]);const N=async s=>{try{const t=await x.adminPinPost(s.id,!s.pinned);c.success(t.message),l()}catch(t){c.error(t instanceof Error?t.message:"操作失败")}},A=async s=>{try{await x.adminDeletePost(s),c.success("帖子已删除"),l()}catch(t){c.error(t instanceof Error?t.message:"删除失败")}};return o?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(),p(g.trim())},children:[e.jsx(P,{value:g,onChange:s=>u(s.target.value),placeholder:"搜索帖子标题…"}),e.jsxs(r,{type:"submit",children:[e.jsx(H,{size:16}),"搜索"]}),d&&e.jsx(r,{type:"button",variant:"outline",onClick:()=>{u(""),p("")},children:"清除"})]}),e.jsx("div",{className:"admin-card",children:y?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("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:()=>f(`/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(w,{variant:"orange",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(r,{size:"sm",variant:"outline",onClick:()=>N(s),children:s.pinned?"取消置顶":"置顶"}),e.jsxs(z,{children:[e.jsx(E,{asChild:!0,children:e.jsx(r,{size:"sm",variant:"ghost",className:"text-destructive",children:"删除"})}),e.jsxs(B,{children:[e.jsxs(_,{children:[e.jsx(I,{children:"确定删除该帖子?"}),e.jsx(T,{children:"相关评论也将一并删除,不可恢复。"})]}),e.jsxs(F,{children:[e.jsx(L,{children:"取消"}),e.jsx(G,{onClick:()=>A(s.id),children:"删除"})]})]})]})]})})]},s.id)})})]}),j.length===0&&e.jsx("div",{className:"admin-empty",children:"没有找到帖子"}),h>1&&e.jsxs("div",{className:"admin-pagination",children:[e.jsx(r,{size:"sm",variant:"outline",disabled:n<=1,onClick:()=>l(n-1),children:"上一页"}),e.jsxs("span",{children:["第 ",n," / ",h," 页"]}),e.jsx(r,{size:"sm",variant:"outline",disabled:n>=h,onClick:()=>l(n+1),children:"下一页"})]})]})})]}):null}export{O as default};

View File

@@ -0,0 +1 @@
import{r as d,j as s}from"./react-vendor-CFzzFOsg.js";import{b as j,a as c,S as m,B as u,n as r}from"./index-DurEdRMk.js";import{ag as f}from"./ui-vendor-BLDO93FK.js";function N(){const{ready:n}=j(),[e,t]=d.useState(null),[l,o]=d.useState(!0),[h,i]=d.useState(!1);d.useEffect(()=>{n&&c.adminSettings().then(t).finally(()=>o(!1))},[n]);const x=async()=>{i(!0);try{const a=await c.adminBackup();r.success(a.message),window.location.href=a.download}catch(a){r.error(a instanceof Error?a.message:"备份失败")}finally{i(!1)}};return!n||l?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(m,{size:"lg"})}):e?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.jsxs("dl",{className:"admin-dl",children:[s.jsx("dt",{children:"数据目录"}),s.jsx("dd",{children:s.jsx("code",{children:e.data_dir})}),s.jsx("dt",{children:"数据库路径"}),s.jsx("dd",{children:s.jsx("code",{children:e.db_path})}),s.jsx("dt",{children:"敏感词配置"}),s.jsx("dd",{children:s.jsx("code",{children:e.filter_path})}),s.jsx("dt",{children:"监听端口"}),s.jsx("dd",{children:e.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(u,{onClick:x,loading:h,children:[s.jsx(f,{size:16}),"立即备份并下载"]})]})]}):null}export{N as default};

View File

@@ -0,0 +1 @@
import{r as i,j as e}from"./react-vendor-CFzzFOsg.js";import{b as v,S as y,B as l,a as j,n as r}from"./index-DurEdRMk.js";import{B as h}from"./badge-E7fnzaIj.js";import"./ui-vendor-BLDO93FK.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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +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-DurEdRMk.js";import{f as y}from"./content-BR0rw1k4.js";import{A as k}from"./ui-vendor-BLDO93FK.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 +0,0 @@
import{e as j,r as n,j as s}from"./react-vendor-CgzNJrV9.js";import{u as f,a as g,B as p}from"./index-474mTIgd.js";import{S as N}from"./spinner-DH1syOsQ.js";import{n as v}from"./notify-Zesh5czd.js";import{f as y}from"./content-CKr4Ur51.js";import{A as k}from"./ui-vendor-CdH1UOKW.js";function E(){const t=j(),{user:a,loading:r}=f(),[i,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!r){if(!a){t("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>v.error(e.message)).finally(()=>x(!1))}},[a,r,t]),r||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(N,{size:"lg"})}):a?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(p,{variant:"ghost",className:"mb-3",onClick:()=>t("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",i.length," 篇收藏帖子"]}),i.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(p,{onClick:()=>t("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:i.map(e=>{var o,l,c,d,m;return s.jsx("div",{className:"post-row",onClick:()=>t(`/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}),((m=(d=e.post)==null?void 0:d.user)==null?void 0:m.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{E 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

@@ -0,0 +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-ScadC3tp.js";import{u as v,B as L,a as S,n as d}from"./index-DurEdRMk.js";import{I as u}from"./input-BN5iO3BQ.js";import"./ui-vendor-BLDO93FK.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 +0,0 @@
import{e as f,r as g,j as s,L as b}from"./react-vendor-CgzNJrV9.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,I as i,f as m,o as N,s as d}from"./form-BzHbu9bJ.js";import{u as v,B as L,a as S}from"./index-474mTIgd.js";import{n as u}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=N({username:d().min(1,"请输入用户名"),password:d().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(),u.success("登录成功"),x("/",{replace:!0})}catch(o){u.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(i,{placeholder:"用户名",autoComplete:"username",...e})}),s.jsx(m,{})]})}),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(i,{type:"password",placeholder:"密码",autoComplete:"current-password",...e})}),s.jsx(m,{})]})}),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};

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

@@ -0,0 +1 @@
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-ScadC3tp.js";import{u as N,B as S,a as v,n as x}from"./index-DurEdRMk.js";import{I as i}from"./input-BN5iO3BQ.js";import"./ui-vendor-BLDO93FK.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

@@ -1 +0,0 @@
import{e as f,r as g,j as s,L as w}from"./react-vendor-CgzNJrV9.js";import{u as b,a as F,F as y,b as a,c as n,d as o,e as t,I as c,f as l,o as k,s as i}from"./form-BzHbu9bJ.js";import{u as N,B as S,a as v}from"./index-474mTIgd.js";import{n as x}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=k({username:i().min(1,"请输入用户名"),nickname:i().optional(),password:i().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(c,{placeholder:"3-32 位字母数字下划线",autoComplete:"username",...e})}),s.jsx(l,{})]})}),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(c,{placeholder:"显示名称(可选)",autoComplete:"nickname",...e})}),s.jsx(l,{})]})}),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(c,{type:"password",placeholder:"至少 6 位",autoComplete:"new-password",...e})}),s.jsx(l,{})]})}),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{r as l,j as s}from"./react-vendor-CFzzFOsg.js";import{a7 as x,a8 as g,a9 as N,aa as d,ab as i,ac as r,ad as n,ae as m,af as c}from"./ui-vendor-BLDO93FK.js";import{c as o,e as f}from"./index-DurEdRMk.js";const T=x,h=g,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(d,{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=d.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(i,{ref:t,className:o("text-lg font-semibold",a),...e}));j.displayName=i.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{T as A,h 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-CgzNJrV9.js";import{c as a,d as o}from"./index-474mTIgd.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,d as o}from"./index-DurEdRMk.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};

View File

@@ -0,0 +1,3 @@
function r(t){return t.replace(/\r\n/g,`
`).replace(/\r/g,`
`).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/\n/g,"<br>")}function i(t,e){return r(t).replace(/@([\w\u4e00-\u9fa5_-]+)/g,'<span class="mention">@$1</span>')}function o(t){const e=new Date(t),n=(new Date().getTime()-e.getTime())/1e3;return n<60?"刚刚":n<3600?`${Math.floor(n/60)}分钟前`:n<86400?`${Math.floor(n/3600)}小时前`:`${e.getMonth()+1}-${e.getDate()} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`}export{o as f,i as h};

View File

@@ -1 +0,0 @@
function a(n,t){return n.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/@([\w\u4e00-\u9fa5_-]+)/g,'<span class="mention">@$1</span>')}function o(n){const t=new Date(n),e=(new Date().getTime()-t.getTime())/1e3;return e<60?"刚刚":e<3600?`${Math.floor(e/60)}分钟前`:e<86400?`${Math.floor(e/3600)}小时前`:`${t.getMonth()+1}-${t.getDate()} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}export{o as f,a as h};

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

@@ -0,0 +1 @@
import{r as t,j as i}from"./react-vendor-CFzzFOsg.js";import{c as n}from"./index-DurEdRMk.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};

View File

@@ -1 +0,0 @@
import{t as s}from"./index-474mTIgd.js";const n={success:r=>s.success(r),error:r=>s.error(r),warning:r=>s.warning(r)};export{n};

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{j as e}from"./react-vendor-CgzNJrV9.js";import{c as s}from"./index-474mTIgd.js";import{p as m}from"./ui-vendor-CdH1UOKW.js";const t={sm:"h-4 w-4",md:"h-5 w-5",lg:"h-8 w-8"};function p({className:r,size:a="md"}){return e.jsx(m,{className:s("animate-spin text-[var(--j13-green)]",t[a],r),"aria-label":"加载中"})}export{p as S};

View File

@@ -27,10 +27,10 @@
document.documentElement.style.colorScheme = theme;
})();
</script>
<script type="module" crossorigin src="/assets/index-474mTIgd.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-vendor-CgzNJrV9.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CdH1UOKW.js">
<link rel="stylesheet" crossorigin href="/assets/index-JNN-o0YU.css">
<script type="module" crossorigin src="/assets/index-DurEdRMk.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-vendor-CFzzFOsg.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BLDO93FK.js">
<link rel="stylesheet" crossorigin href="/assets/index-LgW2jk25.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,9 +1,10 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import './styles/global.css';
import { AuthProvider } from './hooks/useAuth';
import { ThemeProvider } from './hooks/useTheme';
import MainLayout from './layouts/MainLayout';
import AdminLayout from './layouts/AdminLayout';
import ErrorBoundary from './components/ErrorBoundary';
import PageLoader from './components/PageLoader';
import { Toaster } from './components/ui/sonner';
@@ -16,6 +17,11 @@ const ComposePage = lazy(() => import('./pages/ComposePage'));
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
export default function App() {
return (
@@ -26,12 +32,21 @@ export default function App() {
<Routes>
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
</Route>
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/post/:id" element={<PostDetailPage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/boards" element={<BoardsManagePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
</Route>

View File

@@ -1,4 +1,4 @@
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats } from './types';
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings } from './types';
const BASE = '';
@@ -44,6 +44,38 @@ export const api = {
updateBoard: (id: number, body: { name: string; description: string; sort_order: number }) =>
request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }),
// 管理后台 API
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
adminPosts: (params: { page?: number; keyword?: string }) => {
const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page));
if (params.keyword) q.set('keyword', params.keyword);
const qs = q.toString();
return request<{ posts: PostItem[]; total: number; page: number; total_pages: number }>(
`/api/admin/posts${qs ? `?${qs}` : ''}`,
);
},
adminPinPost: (id: number, pinned: boolean) =>
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
method: 'POST', body: JSON.stringify({ pinned }),
}),
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
adminComments: (page = 1) =>
request<{ comments: Comment[]; total: number; page: number; total_pages: number }>(
`/api/admin/comments?page=${page}`,
),
adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }),
adminUsers: (page = 1) =>
request<{ users: User[]; total: number; page: number; total_pages: number }>(
`/api/admin/users?page=${page}`,
),
adminBanUser: (id: number, banned: boolean) =>
request<{ message: string; banned: boolean }>(`/api/admin/users/${id}/ban`, {
method: 'POST', body: JSON.stringify({ banned }),
}),
adminBackup: () =>
request<{ message: string; filename: string; download: string }>('/api/admin/backup', { method: 'POST' }),
updateNickname: (nickname: string) => {
const fd = new FormData();
fd.append('nickname', nickname);

View File

@@ -4,6 +4,8 @@ export interface User {
nickname: string;
avatar: string;
role: 'user' | 'admin';
banned?: boolean;
created_at?: string;
}
export interface Board {
@@ -50,9 +52,33 @@ export interface Comment {
content_hidden?: boolean;
created_at: string;
user?: User;
post?: PostItem;
reply_target?: Comment;
}
export interface AdminDashboard {
users: number;
posts: number;
boards: number;
comments: number;
online: number;
recent_posts: PostItem[];
}
export interface AdminSettings {
filter_path: string;
data_dir: string;
db_path: string;
port: number;
}
export interface Paginated<T> {
total: number;
page: number;
total_pages: number;
items: T;
}
export interface Notification {
id: number;
title: string;

View File

@@ -33,7 +33,7 @@ export default function BoardGrid({ boards, loading = false, selectedId = 0, onS
</p>
{user?.role === 'admin' && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<Button onClick={() => nav('/boards')}>
<Button onClick={() => nav('/admin/boards')}>
<Plus />
</Button>

View File

@@ -8,12 +8,11 @@ interface Props {
/** 渲染评论正文(支持正文内 @ 高亮) */
export default function CommentContent({ content, onMentionClick }: Props) {
return (
<div className="floor-body">
<span
dangerouslySetInnerHTML={{
__html: highlightMentions(content, onMentionClick),
}}
/>
</div>
<div
className="floor-body"
dangerouslySetInnerHTML={{
__html: highlightMentions(content, onMentionClick),
}}
/>
);
}

View File

@@ -50,7 +50,7 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal
<Button size="sm" onClick={() => nav('/login')}></Button>
)}
{!authLoading && user?.role === 'admin' && (
<Button size="sm" variant="outline" onClick={() => nav('/boards')}>
<Button size="sm" variant="outline" onClick={() => nav('/admin/boards')}>
<Settings />
</Button>

View File

@@ -1,10 +1,9 @@
import {
Home, Settings, Star, LayoutDashboard,
Home, Star, LayoutDashboard,
} from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import type { Board } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { openAdminDashboard } from '../utils/admin';
import { cn } from '@/lib/utils';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
@@ -17,7 +16,7 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
if (isNeutralSidebarRoute(pathname)) return null;
if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/boards')) return 'boards';
if (pathname.startsWith('/admin')) return 'admin';
return activeBoard === 0 ? 'all' : String(activeBoard);
}
@@ -77,8 +76,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
<>
<div className="sidebar-section" style={{ marginTop: 8 }}></div>
<nav className="sidebar-nav">
{navItem('boards', '管理板块', <Settings />, () => nav('/boards'))}
{navItem('admin', '系统后台', <LayoutDashboard />, openAdminDashboard)}
{navItem('admin', '管理后台', <LayoutDashboard />, () => nav('/admin/dashboard'))}
</nav>
</>
)}

View File

@@ -0,0 +1,97 @@
import { useEffect } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import {
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '../hooks/useAuth';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
const NAV = [
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
{ to: '/admin/boards', label: '板块管理', icon: FolderKanban },
{ to: '/admin/posts', label: '帖子管理', icon: FileText },
{ to: '/admin/comments', label: '评论管理', icon: MessageSquare },
{ to: '/admin/users', label: '用户管理', icon: Users },
{ to: '/admin/settings', label: '系统设置', icon: Settings },
];
/** React 管理后台布局,与前台 SPA 风格统一 */
export default function AdminLayout() {
const { user, loading } = useAuth();
const nav = useNavigate();
useEffect(() => {
if (loading) return;
if (!user) {
nav('/login');
return;
}
if (user.role !== 'admin') {
notify.warning('需要管理员权限');
nav('/');
}
}, [user, loading, nav]);
if (loading) {
return <div className="flex justify-center py-24"><Spinner size="lg" /></div>;
}
if (!user || user.role !== 'admin') return null;
return (
<div className="admin-shell">
<header className="admin-topbar">
<div className="admin-topbar-brand">
<div className="admin-topbar-mark"></div>
<div>
<div className="admin-topbar-title"></div>
<div className="admin-topbar-sub"></div>
</div>
</div>
<div className="admin-topbar-actions">
<button type="button" className="admin-link-btn" onClick={() => nav('/')}>
<ArrowLeft size={16} />
</button>
<span className="admin-topbar-user">{user.nickname}</span>
</div>
</header>
<div className="admin-body">
<aside className="admin-sidebar">
{NAV.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
>
<Icon size={16} />
{label}
</NavLink>
))}
</aside>
<main className="admin-main">
<Outlet />
</main>
</div>
</div>
);
}
/** 管理页通用权限守卫 */
export function useAdminGuard() {
const { user, loading } = useAuth();
const nav = useNavigate();
useEffect(() => {
if (loading) return;
if (!user) nav('/login');
else if (user.role !== 'admin') {
notify.warning('需要管理员权限');
nav('/');
}
}, [user, loading, nav]);
return { user, loading, ready: !loading && !!user && user.role === 'admin' };
}

View File

@@ -9,7 +9,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { openAdminDashboard } from '../utils/admin';
import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { api } from '../api/client';
@@ -162,8 +161,7 @@ export default function MainLayout() {
{user.role === 'admin' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => nav('/boards')}></DropdownMenuItem>
<DropdownMenuItem onClick={openAdminDashboard}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/admin/dashboard')}></DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, Plus } from 'lucide-react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -25,7 +25,7 @@ import {
} from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import { useAdminGuard } from '../layouts/AdminLayout';
import type { Board } from '../api/types';
const boardSchema = z.object({
@@ -38,7 +38,7 @@ type BoardFormValues = z.infer<typeof boardSchema>;
export default function BoardsManagePage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const { ready } = useAdminGuard();
const [boards, setBoards] = useState<Board[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
@@ -59,11 +59,8 @@ export default function BoardsManagePage() {
};
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (user.role !== 'admin') { nav('/'); notify.warning('需要管理员权限'); return; }
load();
}, [user, authLoading, nav]);
if (ready) load();
}, [ready]);
const openCreate = () => {
setEditing(null);
@@ -112,31 +109,26 @@ export default function BoardsManagePage() {
}
};
if (authLoading) {
if (!ready) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!user || user.role !== 'admin') return null;
return (
<div className="page-wrap">
<div className="page-inner-wide">
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
<ArrowLeft />
</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
<div className="admin-page">
<div className="admin-page-head">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title"></h1>
<p className="page-desc"></p>
<h1></h1>
<p></p>
</div>
<Button onClick={openCreate}>
<Plus />
</Button>
</div>
</div>
<div className="section-card" style={{ padding: 0, overflow: 'hidden' }}>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
@@ -197,7 +189,6 @@ export default function BoardsManagePage() {
)}
</>
)}
</div>
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>

View File

@@ -91,7 +91,7 @@ export default function ComposePage() {
<h2></h2>
<p></p>
{user.role === 'admin' ? (
<button type="button" className="compose-primary-btn" onClick={() => nav('/boards')}>
<button type="button" className="compose-primary-btn" onClick={() => nav('/admin/boards')}>
</button>
) : (

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil } from 'lucide-react';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
@@ -162,6 +162,18 @@ export default function PostDetailPage() {
const authorInitial = post.user?.nickname?.[0] || '?';
const tags = post.tags?.split(/[,]/).map(t => t.trim()).filter(Boolean) ?? [];
const canEdit = user && (user.role === 'admin' || user.id === post.user_id);
const isAdmin = user?.role === 'admin';
const handlePin = async () => {
if (!post) return;
try {
const r = await api.adminPinPost(postId, !post.pinned);
setPost(p => p ? { ...p, pinned: r.pinned } : p);
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
return (
<div className="page-wrap post-detail-page" ref={pageRef}>
@@ -217,6 +229,12 @@ export default function PostDetailPage() {
</Button>
)}
{isAdmin && (
<Button variant="outline" size="sm" onClick={handlePin}>
<Pin />
{post.pinned ? '取消置顶' : '置顶'}
</Button>
)}
</div>
</div>

View File

@@ -12,7 +12,6 @@ import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client';
import { openAdminDashboard } from '../utils/admin';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
@@ -134,11 +133,11 @@ export default function ProfilePage() {
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => nav('/boards')}>
<Button variant="outline" onClick={() => nav('/admin/boards')}>
<Settings />
</Button>
<Button onClick={openAdminDashboard}>
<Button onClick={() => nav('/admin/dashboard')}>
<LayoutDashboard />
</Button>

View File

@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { Comment } from '../../api/types';
export default function AdminCommentsPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const load = (p = page) => {
setLoading(true);
api.adminComments(p)
.then(d => {
setComments(d.comments ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load(1);
}, [ready]);
const remove = async (id: number) => {
try {
await api.adminDeleteComment(id);
notify.success('评论已删除');
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{comments.map(c => (
<tr key={c.id}>
<td>{c.id}</td>
<td>#{c.floor}</td>
<td>
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${c.post_id}`)}>
{c.post?.title ?? `#${c.post_id}`}
</button>
</td>
<td>{c.user?.nickname || c.guest_nick || '游客'}</td>
<td className="max-w-[200px] truncate">{c.content}</td>
<td>{c.is_private ? <Badge variant="secondary"></Badge> : '—'}</td>
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>
<td>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive"></Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => remove(c.id)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
))}
</tbody>
</table>
{comments.length === 0 && <div className="admin-empty"></div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}></Button>
<span> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}></Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminDashboard } from '../../api/types';
export default function AdminDashboardPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [data, setData] = useState<AdminDashboard | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!ready) return;
api.adminDashboard()
.then(setData)
.finally(() => setLoading(false));
}, [ready]);
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!data) return null;
const stats = [
{ label: '注册用户', value: data.users, cls: 'admin-stat-users' },
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
{ label: '当前在线', value: data.online, cls: 'admin-stat-online' },
];
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<div className="admin-stat-grid">
{stats.map(s => (
<div key={s.label} className={`admin-stat-card ${s.cls}`}>
<div className="admin-stat-value">{s.value}</div>
<div className="admin-stat-label">{s.label}</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-card-head">
<span></span>
<button type="button" className="admin-text-link" onClick={() => nav('/admin/posts')}>
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data.recent_posts.map(p => (
<tr key={p.id}>
<td>{p.id}</td>
<td>
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
{p.title}
</button>
</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
</tr>
))}
</tbody>
</table>
{data.recent_posts.length === 0 && <div className="admin-empty"></div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { PostItem } from '../../api/types';
export default function AdminPostsPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [posts, setPosts] = useState<PostItem[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [keyword, setKeyword] = useState('');
const [search, setSearch] = useState('');
const load = (p = page, kw = search) => {
setLoading(true);
api.adminPosts({ page: p, keyword: kw })
.then(d => {
setPosts(d.posts ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load(1, search);
}, [ready, search]);
const togglePin = async (post: PostItem) => {
try {
const r = await api.adminPinPost(post.id, !post.pinned);
notify.success(r.message);
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const remove = async (id: number) => {
try {
await api.adminDeletePost(id);
notify.success('帖子已删除');
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<form
className="admin-search-bar"
onSubmit={e => { e.preventDefault(); setSearch(keyword.trim()); }}
>
<Input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索帖子标题…"
/>
<Button type="submit"><Search size={16} /></Button>
{search && (
<Button type="button" variant="outline" onClick={() => { setKeyword(''); setSearch(''); }}>
</Button>
)}
</form>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{posts.map(p => (
<tr key={p.id}>
<td>{p.id}</td>
<td className="max-w-[220px] truncate">
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
{p.title}
</button>
</td>
<td>{p.board?.name ?? '—'}</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{p.like_count}</td>
<td>{p.view_count}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
<td>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
{p.pinned ? '取消置顶' : '置顶'}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive"></Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => remove(p.id)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</td>
</tr>
))}
</tbody>
</table>
{posts.length === 0 && <div className="admin-empty"></div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}></Button>
<span> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}></Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
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';
export default function AdminSettingsPage() {
const { ready } = useAdminGuard();
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [backing, setBacking] = useState(false);
useEffect(() => {
if (!ready) return;
api.adminSettings()
.then(setSettings)
.finally(() => setLoading(false));
}, [ready]);
const handleBackup = async () => {
setBacking(true);
try {
const r = await api.adminBackup();
notify.success(r.message);
window.location.href = r.download;
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '备份失败');
} finally {
setBacking(false);
}
};
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!settings) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<div className="admin-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>
</dl>
</div>
<div className="admin-card">
<div className="admin-card-head"></div>
<p className="admin-card-desc">
SQLite <code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
</p>
<Button onClick={handleBackup} loading={backing}>
<Database size={16} />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { User } from '../../api/types';
export default function AdminUsersPage() {
const { ready } = useAdminGuard();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const load = (p = page) => {
setLoading(true);
api.adminUsers(p)
.then(d => {
setUsers(d.users ?? []);
setPage(d.page);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load(1);
}, [ready]);
const toggleBan = async (user: User) => {
if (user.role === 'admin') {
notify.warning('不能禁言管理员');
return;
}
try {
const r = await api.adminBanUser(user.id, !user.banned);
notify.success(r.message);
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
if (!ready) return null;
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<div className="admin-card">
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.username}</td>
<td>{u.nickname}</td>
<td>
{u.role === 'admin'
? <Badge variant="orange"></Badge>
: <Badge variant="secondary"></Badge>}
</td>
<td>{u.banned ? <Badge variant="destructive"></Badge> : '正常'}</td>
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
<td>
{u.role !== 'admin' && (
<Button size="sm" variant="outline" onClick={() => toggleBan(u)}>
{u.banned ? '解除禁言' : '禁言'}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && <div className="admin-empty"></div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}></Button>
<span> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}></Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1659,7 +1659,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
font-size: 14px;
line-height: 1.65;
word-break: break-word;
white-space: pre-wrap;
}
.waline-comment-bubble .quote-block {
margin: 6px 0;
@@ -2369,3 +2368,76 @@ a.waline-comment-author:hover { color: var(--j13-green); }
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
/* ========== React 管理后台 ========== */
.admin-shell { min-height: 100vh; background: hsl(var(--background)); }
.admin-topbar {
display: flex; align-items: center; justify-content: space-between;
height: 56px; padding: 0 20px; border-bottom: 1px solid var(--j13-border);
background: hsl(var(--card));
}
.admin-topbar-brand { display: flex; align-items: center; gap: 10px; }
.admin-topbar-mark {
width: 32px; height: 32px; border-radius: 8px; background: var(--j13-green);
color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.admin-topbar-title { font-size: 14px; font-weight: 600; line-height: 1.2; }
.admin-topbar-sub { font-size: 12px; color: hsl(var(--muted-foreground)); }
.admin-topbar-actions { display: flex; align-items: center; gap: 12px; }
.admin-topbar-user { font-size: 13px; color: hsl(var(--muted-foreground)); }
.admin-link-btn {
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-sidebar {
width: 200px; flex-shrink: 0; padding: 16px 10px; border-right: 1px solid var(--j13-border);
background: hsl(var(--card));
}
.admin-nav-item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 9px 12px; margin-bottom: 2px; border-radius: 8px; font-size: 13px;
color: hsl(var(--muted-foreground)); text-decoration: none; transition: background .15s, color .15s;
}
.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-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)); }
.admin-stat-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px; margin-bottom: 20px;
}
.admin-stat-card {
padding: 16px; border-radius: 10px; border: 1px solid var(--j13-border); background: hsl(var(--card));
}
.admin-stat-value { font-size: 24px; font-weight: 700; 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;
}
.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 .admin-card-desc + button { margin: 12px 16px 16px; }
.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); }
.admin-table tr:last-child td { border-bottom: none; }
.admin-empty { padding: 32px; text-align: center; color: hsl(var(--muted-foreground)); font-size: 13px; }
.admin-text-link {
background: none; border: none; padding: 0; color: var(--j13-green); cursor: pointer; font-size: inherit;
}
.admin-text-link:hover { text-decoration: underline; }
.admin-search-bar { display: flex; gap: 8px; margin-bottom: 16px; max-width: 480px; }
.admin-pagination {
display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 12px; border-top: 1px solid var(--j13-border); font-size: 13px;
}
.admin-dl { padding: 16px; display: grid; grid-template-columns: 120px 1fr; gap: 10px 16px; font-size: 13px; }
.admin-dl dt { color: hsl(var(--muted-foreground)); }
.admin-dl dd code { font-size: 12px; background: hsl(var(--muted)); padding: 2px 6px; border-radius: 4px; }

View File

@@ -1,7 +1,6 @@
/** 服务端管理后台地址(非 SPA 路由) */
/** 管理后台入口React SPA 路由) */
export const ADMIN_DASHBOARD_URL = '/admin/dashboard';
/** 跳转到系统管理后台 */
export function openAdminDashboard() {
window.location.href = ADMIN_DASHBOARD_URL;
}

View File

@@ -1,11 +1,17 @@
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @ */
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
/** 转义 HTML 并保留换行 */
function escapeWithBreaks(text: string): string {
return text
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// innerHTML 解析会吞掉换行,需转为 <br>
.replace(/\n/g, '<br>')
.replace(/\n/g, '<br>');
}
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @ */
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
return escapeWithBreaks(text)
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
}

View File

@@ -2,7 +2,9 @@ package handler
import (
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/model"
@@ -99,6 +101,177 @@ func (h *Handlers) APIAdminDeleteBoard(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "板块已删除"})
}
// APIAdminDashboard 管理后台概览
func (h *Handlers) APIAdminDashboard(c *gin.Context) {
var userCount, postCount, boardCount, commentCount int64
model.DB.Model(&model.User{}).Count(&userCount)
model.DB.Model(&model.Post{}).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount)
model.DB.Model(&model.Comment{}).Count(&commentCount)
recentPosts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
if recentPosts == nil {
recentPosts = []model.Post{}
}
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
"comments": commentCount, "online": h.Online.Count(),
"recent_posts": recentPosts,
})
}
// APIAdminPosts 管理员帖子列表
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})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if posts == nil {
posts = []model.Post{}
}
c.JSON(http.StatusOK, gin.H{
"posts": posts, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminPinPost 置顶/取消置顶JSON
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Pinned bool `json:"pinned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetPinned(uint(id), req.Pinned); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已取消置顶"
if req.Pinned {
msg = "已置顶"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
}
// APIAdminDeletePost 管理员删除帖子
func (h *Handlers) APIAdminDeletePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Post.Delete(0, uint(id), true); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "帖子已删除"})
}
// APIAdminComments 管理员评论列表
func (h *Handlers) APIAdminComments(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
comments, total, err := h.Comment.ListRecent(page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if comments == nil {
comments = []model.Comment{}
}
c.JSON(http.StatusOK, gin.H{
"comments": comments, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminDeleteComment 管理员删除评论
func (h *Handlers) APIAdminDeleteComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.Comment.AdminDelete(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
}
// APIAdminUsers 管理员用户列表
func (h *Handlers) APIAdminUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
users, total, err := h.User.ListUsers(page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if users == nil {
users = []model.User{}
}
c.JSON(http.StatusOK, gin.H{
"users": users, "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminBanUser 禁言/解除禁言JSON
func (h *Handlers) APIAdminBanUser(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Banned bool `json:"banned"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.User.BanUser(uint(id), req.Banned); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已解除禁言"
if req.Banned {
msg = "已禁言"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "banned": req.Banned})
}
// APIAdminBackup 导出 SQLite 备份
func (h *Handlers) APIAdminBackup(c *gin.Context) {
path, err := h.Backup.ExportSQLite()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename := filepath.Base(path)
c.JSON(http.StatusOK, gin.H{
"message": "备份成功",
"filename": filename,
"download": "/api/admin/backup/download/" + filename,
})
}
// APIAdminDownloadBackup 下载备份文件
func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
name := c.Param("name")
if !strings.HasPrefix(name, "jiang13_backup_") || !strings.HasSuffix(name, ".db") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的备份文件名"})
return
}
path := filepath.Join(h.Cfg.DataDir, name)
c.FileAttachment(path, name)
}
// APIAdminSettings 系统设置信息
func (h *Handlers) APIAdminSettings(c *gin.Context) {
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,
})
}
// APIPosts 帖子列表(分页)
func (h *Handlers) APIPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))

View File

@@ -80,30 +80,37 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.DELETE("/comments/:id", h.APIDeleteComment)
}
// 管理员 API前台板块管理等
// 管理员 APIReact SPA 后台统一使用 JSON
adminAPI := r.Group("/api/admin", authMW.RequireAuth(), authMW.RequireAdmin())
{
adminAPI.GET("/dashboard", h.APIAdminDashboard)
adminAPI.GET("/settings", h.APIAdminSettings)
adminAPI.POST("/boards", h.APIAdminCreateBoard)
adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard)
adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard)
adminAPI.GET("/posts", h.APIAdminPosts)
adminAPI.POST("/posts/:id/pin", h.APIAdminPinPost)
adminAPI.DELETE("/posts/:id", h.APIAdminDeletePost)
adminAPI.GET("/comments", h.APIAdminComments)
adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment)
adminAPI.GET("/users", h.APIAdminUsers)
adminAPI.POST("/users/:id/ban", h.APIAdminBanUser)
adminAPI.POST("/backup", h.APIAdminBackup)
adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup)
}
// 后台管理(保留服务端模板)
// 后台管理API 保留兼容,页面统一由 React SPA 渲染
admin := r.Group("/admin")
{
admin.GET("/login", h.AdminLoginPage)
admin.GET("/login", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
})
admin.POST("/api/login", middleware.RateLimitMiddleware(limiter, "admin_login"), h.AdminAPILogin)
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
{
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
adminAuth.GET("/dashboard", h.AdminDashboard)
adminAuth.GET("/boards", h.AdminBoardsPage)
adminAuth.GET("/posts", h.AdminPostsPage)
adminAuth.GET("/comments", h.AdminCommentsPage)
adminAuth.GET("/users", h.AdminUsersPage)
adminAuth.GET("/settings", h.AdminSettingsPage)
adminAuth.POST("/api/logout", h.AdminAPILogout)
// 遗留 form API旧模板脚本仍可能调用
adminAuth.POST("/api/boards", h.AdminAPICreateBoard)
adminAuth.PUT("/api/boards/:id", h.AdminAPIUpdateBoard)
adminAuth.DELETE("/api/boards/:id", h.AdminAPIDeleteBoard)
@@ -113,6 +120,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAuth.POST("/api/users/:id/ban", h.AdminAPIBanUser)
adminAuth.POST("/api/backup", h.AdminAPIBackup)
adminAuth.GET("/api/backup/download/:name", h.AdminDownloadBackup)
// React SPA 管理页面
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
for _, page := range []string{"dashboard", "boards", "posts", "comments", "users", "settings"} {
adminAuth.GET("/"+page, embed_static.ServeSPA)
}
}
}