统一 React 管理后台,修复评论换行与帖子置顶
- /admin/* 全部由 React SPA 渲染,替代旧版 HTML 后台页面 - 新增仪表盘、帖子/评论/用户管理、系统设置与 JSON API - 帖子详情页支持管理员置顶;评论换行显示修复 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)。
|
||||
|
||||
|
||||
45
ROADMAP.md
45
ROADMAP.md
@@ -11,58 +11,39 @@
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 前台 SPA(React) | 🚧 | 核心浏览/发帖/回复可用,部分管理能力仍依赖旧版后台 |
|
||||
| 管理后台 | 🚧 | 旧版 HTML 后台功能较全;React 侧尚未统一 |
|
||||
| 评论系统 | 🐛 | 换行显示等问题待修复 |
|
||||
| 帖子管理 | 🐛 | 置顶 API 已有,React 前台缺操作入口 |
|
||||
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 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] 移动端响应式适配
|
||||
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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
53
embed_static/static/spa/assets/ComposePage-BzWi56ib.js
Normal file
53
embed_static/static/spa/assets/ComposePage-BzWi56ib.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/FavoritesPage-DPpP9uxT.js
Normal file
1
embed_static/static/spa/assets/FavoritesPage-DPpP9uxT.js
Normal 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};
|
||||
@@ -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};
|
||||
1
embed_static/static/spa/assets/HomePage-CrU3Xqcn.js
Normal file
1
embed_static/static/spa/assets/HomePage-CrU3Xqcn.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/LoginPage-DGBpvRak.js
Normal file
1
embed_static/static/spa/assets/LoginPage-DGBpvRak.js
Normal 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};
|
||||
@@ -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
1
embed_static/static/spa/assets/ProfilePage-exbWgetG.js
Normal file
1
embed_static/static/spa/assets/ProfilePage-exbWgetG.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/RegisterPage-BTZhIdwh.js
Normal file
1
embed_static/static/spa/assets/RegisterPage-BTZhIdwh.js
Normal 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};
|
||||
@@ -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};
|
||||
1
embed_static/static/spa/assets/alert-dialog-CQ3zNojX.js
Normal file
1
embed_static/static/spa/assets/alert-dialog-CQ3zNojX.js
Normal 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};
|
||||
@@ -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};
|
||||
3
embed_static/static/spa/assets/content-BR0rw1k4.js
Normal file
3
embed_static/static/spa/assets/content-BR0rw1k4.js
Normal file
@@ -0,0 +1,3 @@
|
||||
function r(t){return t.replace(/\r\n/g,`
|
||||
`).replace(/\r/g,`
|
||||
`).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").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};
|
||||
@@ -1 +0,0 @@
|
||||
function a(n,t){return n.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").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
2
embed_static/static/spa/assets/index-DurEdRMk.js
Normal file
2
embed_static/static/spa/assets/index-DurEdRMk.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/index-LgW2jk25.css
Normal file
1
embed_static/static/spa/assets/index-LgW2jk25.css
Normal file
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/input-BN5iO3BQ.js
Normal file
1
embed_static/static/spa/assets/input-BN5iO3BQ.js
Normal 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};
|
||||
@@ -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};
|
||||
67
embed_static/static/spa/assets/react-vendor-CFzzFOsg.js
vendored
Normal file
67
embed_static/static/spa/assets/react-vendor-CFzzFOsg.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
97
frontend/src/layouts/AdminLayout.tsx
Normal file
97
frontend/src/layouts/AdminLayout.tsx
Normal 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' };
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
125
frontend/src/pages/admin/AdminCommentsPage.tsx
Normal file
125
frontend/src/pages/admin/AdminCommentsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
88
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
frontend/src/pages/admin/AdminPostsPage.tsx
Normal file
163
frontend/src/pages/admin/AdminPostsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal file
70
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
110
frontend/src/pages/admin/AdminUsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** 服务端管理后台地址(非 SPA 路由) */
|
||||
/** 管理后台入口(React SPA 内路由) */
|
||||
export const ADMIN_DASHBOARD_URL = '/admin/dashboard';
|
||||
|
||||
/** 跳转到系统管理后台 */
|
||||
export function openAdminDashboard() {
|
||||
window.location.href = ADMIN_DASHBOARD_URL;
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 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>');
|
||||
}
|
||||
|
||||
|
||||
173
handler/api.go
173
handler/api.go
@@ -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"))
|
||||
|
||||
@@ -80,30 +80,37 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
api.DELETE("/comments/:id", h.APIDeleteComment)
|
||||
}
|
||||
|
||||
// 管理员 API(前台板块管理等)
|
||||
// 管理员 API(React 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user